feat: implement sub-home/bab feature
- Generalize _read_md_cached() for generic file caching - Add find_sub_home_for_lesson() and get_sub_home_data() helpers - Add /api/bab/<folder> endpoint - Detect sub-home in api_lesson for nav context - Skip sub-home.md in find_lesson_file() and _parse_lesson_links() - Add title to bab frontend page data - Update generate_tokens.py & content_parser.py to skip sub-home.md
This commit is contained in:
parent
9637740ea9
commit
2f689afc27
|
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts">
|
||||
import LessonCard from '$components/LessonCard.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { renderMath } from '$lib/actions/renderMath';
|
||||
|
||||
export let data: {
|
||||
title: string;
|
||||
introHtml: string;
|
||||
lessons: import('$types/lesson').Lesson[];
|
||||
folder: string;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.title || env.PUBLIC_PAGE_TITLE_SUFFIX || 'Elemes LMS'}</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="bab-page">
|
||||
{#if data.introHtml}
|
||||
<section class="intro" use:renderMath>
|
||||
{@html data.introHtml}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if data.lessons.length === 0}
|
||||
<p class="empty">Belum ada pelajaran yang ditemukan untuk bab ini.</p>
|
||||
{:else}
|
||||
<section class="lesson-grid">
|
||||
{#each data.lessons as lesson (lesson.filename)}
|
||||
<LessonCard {lesson} />
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.bab-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.intro {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.lesson-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import type { PageLoad } from './$types';
|
||||
import type { Lesson } from '$types/lesson';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
const res = await fetch(`/api/bab/${params.folder}`);
|
||||
if (!res.ok) {
|
||||
throw error(404, 'Bab not found');
|
||||
}
|
||||
const data = await res.json();
|
||||
return {
|
||||
title: data.title,
|
||||
introHtml: data.intro_html,
|
||||
lessons: data.lessons as Lesson[],
|
||||
folder: params.folder,
|
||||
};
|
||||
};
|
||||
|
|
@ -44,7 +44,7 @@ def get_lesson_names():
|
|||
lesson_files = glob.glob(os.path.join(CONTENT_DIR, "*.md"))
|
||||
for file_path in lesson_files:
|
||||
filename = os.path.basename(file_path)
|
||||
if filename == "home.md":
|
||||
if filename in ("home.md", "sub-home.md"):
|
||||
continue
|
||||
lesson_names.append(filename.replace('.md', ''))
|
||||
lesson_names.sort()
|
||||
|
|
|
|||
|
|
@ -244,7 +244,7 @@ def main():
|
|||
ordered_slugs = [
|
||||
f.replace('.md', '')
|
||||
for f in sorted(os.listdir(content_dir))
|
||||
if f.endswith('.md') and f != 'home.md'
|
||||
if f.endswith('.md') and f not in ('home.md', 'sub-home.md')
|
||||
]
|
||||
|
||||
print(f" 📚 Found {len(ordered_slugs)} lessons:")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ from compiler import compiler_factory
|
|||
from config import CONTENT_DIR, ASSETS_DIR
|
||||
from services.lesson_service import (
|
||||
get_ordered_lessons_with_learning_objectives,
|
||||
get_sub_home_data,
|
||||
find_sub_home_for_lesson,
|
||||
render_markdown_content,
|
||||
render_home_content,
|
||||
find_lesson_file,
|
||||
|
|
@ -50,6 +52,15 @@ def api_lessons():
|
|||
})
|
||||
|
||||
|
||||
@lessons_bp.route('/bab/<folder>')
|
||||
def api_bab(folder):
|
||||
"""Return sub-home data for a given folder (e.g. 'dasar', 'arduino')."""
|
||||
data = get_sub_home_data(folder)
|
||||
if not data:
|
||||
return jsonify({'error': 'Bab not found'}), 404
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@lessons_bp.route('/lesson/<filename>.json')
|
||||
def api_lesson(filename):
|
||||
"""Return single lesson data as JSON."""
|
||||
|
|
@ -139,6 +150,17 @@ def api_lesson(filename):
|
|||
prev_lesson = all_lessons[current_idx - 1] if current_idx > 0 else None
|
||||
next_lesson = all_lessons[current_idx + 1] if 0 <= current_idx < len(all_lessons) - 1 else None
|
||||
|
||||
# Detect sub-home for this lesson's folder
|
||||
sub_home_path, sub_home_folder = find_sub_home_for_lesson(file_path)
|
||||
sub_home = None
|
||||
if sub_home_path and sub_home_folder:
|
||||
sub_home_data = get_sub_home_data(sub_home_folder)
|
||||
sub_home = {
|
||||
'folder': sub_home_folder,
|
||||
'url': f'/bab/{sub_home_folder}',
|
||||
'title': sub_home_data['title'] if sub_home_data else sub_home_folder.replace('_', ' ').title(),
|
||||
}
|
||||
|
||||
# Derive default language from active_tabs (frontend manages switching)
|
||||
if 'python' in active_tabs and 'c' not in active_tabs:
|
||||
programming_language = 'python'
|
||||
|
|
@ -200,13 +222,14 @@ def api_lesson(filename):
|
|||
'quiz_data': quiz_data,
|
||||
'slides': parsed_data.get('slides', []),
|
||||
'lesson_progress_status': lesson_progress_status,
|
||||
'lesson_title': full_filename.replace('.md', '').replace('_', ' ').title(),
|
||||
'lesson_title': current_lesson_meta['title'] if current_lesson_meta else full_filename.replace('.md', '').replace('_', ' ').title(),
|
||||
'lesson_completed': lesson_completed,
|
||||
'locked': is_locked,
|
||||
'missing_prerequisites': missing_prereqs,
|
||||
'prev_lesson': prev_lesson,
|
||||
'next_lesson': next_lesson,
|
||||
'ordered_lessons': all_lessons,
|
||||
'sub_home': sub_home,
|
||||
'language': programming_language,
|
||||
'language_display_name': language_display_name,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,40 +14,71 @@ import markdown as md
|
|||
|
||||
from config import CONTENT_DIR
|
||||
|
||||
_home_cache = {'content': None, 'mtime': -1.0}
|
||||
_home_lock = Lock()
|
||||
# Generic file cache (path -> {content, mtime})
|
||||
_file_cache = {} # {path: {'content': str, 'mtime': float}}
|
||||
_file_cache_lock = Lock()
|
||||
|
||||
_markdown_cache = {}
|
||||
_markdown_lock = Lock()
|
||||
|
||||
# Pre-computed absolute path for home.md (avoids repeated syscall on hot path)
|
||||
_HOME_MD_PATH = os.path.abspath(os.path.join(CONTENT_DIR, "home.md"))
|
||||
|
||||
def _read_home_md():
|
||||
"""Read home.md and return its content, or empty string if missing."""
|
||||
path = os.path.join(CONTENT_DIR, "home.md")
|
||||
|
||||
def _read_md_cached(path):
|
||||
"""Read any markdown file with mtime-based caching.
|
||||
|
||||
Uses a single dict cache keyed by absolute path.
|
||||
Returns empty string if file is missing or unreadable.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
try:
|
||||
current_mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
return _home_cache.get('content') or ""
|
||||
|
||||
with _home_lock:
|
||||
if current_mtime != _home_cache['mtime']:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
_home_cache['content'] = f.read()
|
||||
_home_cache['mtime'] = current_mtime
|
||||
# Invalidate all downstream caches
|
||||
find_lesson_file.cache_clear()
|
||||
get_lessons.cache_clear()
|
||||
get_lesson_names.cache_clear()
|
||||
get_lessons_with_learning_objectives.cache_clear()
|
||||
with _markdown_lock:
|
||||
_markdown_cache.clear()
|
||||
return _home_cache['content']
|
||||
cached = _file_cache.get(path)
|
||||
return (cached['content'] if cached else "") or ""
|
||||
|
||||
with _file_cache_lock:
|
||||
cached = _file_cache.get(path)
|
||||
if cached and cached['mtime'] == current_mtime:
|
||||
return cached['content']
|
||||
|
||||
# Read outside the lock (no file I/O under lock)
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
except (OSError, PermissionError) as e:
|
||||
print(f"Warning: Could not read {path}: {e}")
|
||||
cached = _file_cache.get(path)
|
||||
return (cached['content'] if cached else "") or ""
|
||||
|
||||
with _file_cache_lock:
|
||||
_file_cache[path] = {'content': content, 'mtime': current_mtime}
|
||||
|
||||
# If this is the root home.md, invalidate downstream caches
|
||||
if os.path.abspath(path) == _HOME_MD_PATH:
|
||||
find_lesson_file.cache_clear()
|
||||
get_lessons.cache_clear()
|
||||
get_lesson_names.cache_clear()
|
||||
get_lessons_with_learning_objectives.cache_clear()
|
||||
with _markdown_lock:
|
||||
_markdown_cache.clear()
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def _read_home_md():
|
||||
"""Read root home.md — thin wrapper for backwards compatibility."""
|
||||
path = os.path.join(CONTENT_DIR, "home.md")
|
||||
return _read_md_cached(path)
|
||||
|
||||
|
||||
def _parse_lesson_links(home_content):
|
||||
"""Extract (link_text, filename) pairs from the Available_Lessons section."""
|
||||
"""Extract (link_text, filename) pairs from the Available_Lessons section.
|
||||
|
||||
Skips sub-home.md entries so they don't appear as lessons.
|
||||
"""
|
||||
parts = re.split(r'-{3,}Available_Lessons-{3,}', home_content)
|
||||
if len(parts) <= 1:
|
||||
return []
|
||||
|
|
@ -59,6 +90,9 @@ def _parse_lesson_links(home_content):
|
|||
processed_links = []
|
||||
for title, slug in links:
|
||||
filename = slug if slug.endswith('.md') else slug + '.md'
|
||||
# Skip sub-home.md — it's not a lesson
|
||||
if filename == 'sub-home.md':
|
||||
continue
|
||||
processed_links.append((title, filename))
|
||||
return processed_links
|
||||
|
||||
|
|
@ -73,6 +107,9 @@ def find_lesson_file(filename):
|
|||
# Security: Prevent directory traversal
|
||||
if '/' in filename or '\\' in filename:
|
||||
return None
|
||||
# Skip sub-home.md — it's not a lesson
|
||||
if filename == 'sub-home.md':
|
||||
return None
|
||||
|
||||
for root, _, files in os.walk(CONTENT_DIR):
|
||||
if filename in files:
|
||||
|
|
@ -289,6 +326,133 @@ def get_ordered_lessons_with_learning_objectives(progress=None):
|
|||
return ordered_fallback
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sub-Home helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def find_sub_home_for_lesson(file_path):
|
||||
"""Find sub-home.md in the same folder as file_path, or None.
|
||||
|
||||
Returns (sub_home_path, folder_name) or (None, None).
|
||||
Handles PermissionError gracefully.
|
||||
"""
|
||||
if not file_path:
|
||||
return None, None
|
||||
folder = os.path.dirname(file_path)
|
||||
sub_home_path = os.path.join(folder, 'sub-home.md')
|
||||
if not os.path.exists(sub_home_path):
|
||||
return None, None
|
||||
# Permission check
|
||||
try:
|
||||
if not os.access(sub_home_path, os.R_OK):
|
||||
print(f"Warning: Cannot read {sub_home_path} (permission denied)")
|
||||
return None, None
|
||||
except OSError:
|
||||
return None, None
|
||||
folder_name = os.path.basename(folder)
|
||||
return sub_home_path, folder_name
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def get_sub_home_data(folder_name):
|
||||
"""Return parsed sub-home data for a given folder name.
|
||||
|
||||
Returns dict with keys: title, intro_html, lessons, folder, url.
|
||||
Returns None if no sub-home.md found or unreadable.
|
||||
"""
|
||||
folder_path = os.path.join(CONTENT_DIR, folder_name)
|
||||
if not os.path.isdir(folder_path):
|
||||
return None
|
||||
sub_home_path = os.path.join(folder_path, 'sub-home.md')
|
||||
if not os.path.exists(sub_home_path):
|
||||
return None
|
||||
# Permission check
|
||||
try:
|
||||
if not os.access(sub_home_path, os.R_OK):
|
||||
print(f"Warning: Cannot read {sub_home_path} (permission denied)")
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
content = _read_md_cached(sub_home_path)
|
||||
if not content:
|
||||
return None
|
||||
|
||||
# Extract intro (before Available_Lessons)
|
||||
parts = re.split(r'-{3,}Available_Lessons-{3,}', content)
|
||||
intro_raw = parts[0] if parts else content
|
||||
# Remove heading from intro if present (it becomes the title)
|
||||
title = folder_name.replace('_', ' ').title()
|
||||
intro_lines = intro_raw.strip().split('\n')
|
||||
if intro_lines and intro_lines[0].startswith('# '):
|
||||
title = intro_lines[0][2:].strip()
|
||||
intro_raw = '\n'.join(intro_lines[1:])
|
||||
intro_html = md.markdown(intro_raw, extensions=['fenced_code', 'tables', 'mdx_math']) if intro_raw.strip() else ''
|
||||
|
||||
# Parse lesson links from Available_Lessons
|
||||
lesson_links = _parse_lesson_links(content)
|
||||
lessons = []
|
||||
for link_text, filename in lesson_links:
|
||||
file_path = find_lesson_file(filename)
|
||||
if not file_path:
|
||||
continue
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
lesson_content = f.read()
|
||||
except (OSError, PermissionError):
|
||||
continue
|
||||
|
||||
lesson_title = link_text
|
||||
description = "Learn C programming concepts with practical examples."
|
||||
prerequisite_titles = []
|
||||
lesson_info_start = lesson_content.find('---LESSON_INFO---')
|
||||
lesson_info_end = lesson_content.find('---END_LESSON_INFO---')
|
||||
if lesson_info_start != -1 and lesson_info_end != -1:
|
||||
lesson_info_section = lesson_content[lesson_info_start + len('---LESSON_INFO---'):lesson_info_end]
|
||||
objectives_start = lesson_info_section.find('**Learning Objectives:**')
|
||||
if objectives_start != -1:
|
||||
objectives_section = lesson_info_section[objectives_start:]
|
||||
objective_matches = re.findall(r'- ([^\n]+)', objectives_section)
|
||||
if objective_matches:
|
||||
description = '; '.join(objective_matches[:3])
|
||||
# Extract Prerequisites
|
||||
prereq_start = lesson_info_section.find('**Prerequisites:**')
|
||||
if prereq_start != -1:
|
||||
prereq_section = lesson_info_section[prereq_start + len('**Prerequisites:**'):]
|
||||
bullet_lines = re.findall(r'- ([^\n]+)', prereq_section)
|
||||
for bullet in bullet_lines:
|
||||
bullet = bullet.strip()
|
||||
if bullet.lower() in ('tidak ada', 'none', '-', ''):
|
||||
continue
|
||||
md_link_match = re.match(r'\[([^\]]+)\]\(([^)]+)\)', bullet)
|
||||
if md_link_match:
|
||||
link_path = md_link_match.group(2)
|
||||
slug = link_path.replace('.md', '').split('/')[-1]
|
||||
prerequisite_titles.append(slug)
|
||||
else:
|
||||
prerequisite_titles.append(bullet)
|
||||
else:
|
||||
for line in lesson_content.split('\n')[:10]:
|
||||
if line.startswith('# '):
|
||||
lesson_title = line[2:].strip()
|
||||
break
|
||||
|
||||
lessons.append({
|
||||
'filename': filename,
|
||||
'title': lesson_title,
|
||||
'description': description,
|
||||
'path': file_path,
|
||||
'prerequisite_titles': prerequisite_titles,
|
||||
})
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'intro_html': intro_html,
|
||||
'lessons': lessons,
|
||||
'folder': folder_name,
|
||||
'url': f'/bab/{folder_name}',
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue