diff --git a/frontend/src/routes/bab/[folder]/+page.svelte b/frontend/src/routes/bab/[folder]/+page.svelte new file mode 100644 index 0000000..e1a167c --- /dev/null +++ b/frontend/src/routes/bab/[folder]/+page.svelte @@ -0,0 +1,55 @@ + + + + {data.title || env.PUBLIC_PAGE_TITLE_SUFFIX || 'Elemes LMS'} + + +
+ {#if data.introHtml} +
+ {@html data.introHtml} +
+ {/if} + + {#if data.lessons.length === 0} +

Belum ada pelajaran yang ditemukan untuk bab ini.

+ {:else} +
+ {#each data.lessons as lesson (lesson.filename)} + + {/each} +
+ {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/routes/bab/[folder]/+page.ts b/frontend/src/routes/bab/[folder]/+page.ts new file mode 100644 index 0000000..77fd9a7 --- /dev/null +++ b/frontend/src/routes/bab/[folder]/+page.ts @@ -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, + }; +}; \ No newline at end of file diff --git a/generate_tokens.py b/generate_tokens.py index 6f5c28a..043e685 100644 --- a/generate_tokens.py +++ b/generate_tokens.py @@ -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() diff --git a/load-test/content_parser.py b/load-test/content_parser.py index 64876d3..a4e697d 100644 --- a/load-test/content_parser.py +++ b/load-test/content_parser.py @@ -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:") diff --git a/routes/lessons.py b/routes/lessons.py index 30797b8..455ff37 100644 --- a/routes/lessons.py +++ b/routes/lessons.py @@ -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/') +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/.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, }) diff --git a/services/lesson_service.py b/services/lesson_service.py index b9f4efc..ec57aa2 100644 --- a/services/lesson_service.py +++ b/services/lesson_service.py @@ -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 # ---------------------------------------------------------------------------