""" Lesson loading, ordering, and markdown rendering. """ import os import re import html as html_module import bleach from functools import lru_cache from threading import Lock from urllib.parse import urlparse import markdown as md from config import CONTENT_DIR # 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_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: 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. 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 [] lesson_list_content = parts[-1] # Allow optional leading slash in /lesson/ prefix links = re.findall(r'\[([^\]]+)\]\((?:/?lesson/)?([^\)]+)\)', lesson_list_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 # --------------------------------------------------------------------------- # Lesson listing # --------------------------------------------------------------------------- @lru_cache(maxsize=128) def find_lesson_file(filename): """Recursively search for filename in CONTENT_DIR and return its full path.""" # 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: return os.path.join(root, filename) return None @lru_cache(maxsize=32) def get_lessons(): """Get lessons from the Available_Lessons section in home.md.""" lessons = [] home_content = _read_home_md() if not home_content: return lessons for link_text, filename in _parse_lesson_links(home_content): file_path = find_lesson_file(filename) if not file_path: continue with open(file_path, 'r', encoding='utf-8') as f: content = f.read() lines = content.split('\n') title = link_text description = "Learn C programming concepts with practical examples." for i, line in enumerate(lines): if line.startswith('# ') and title == link_text: if title == "Untitled" or title == link_text: title = line[2:].strip() elif title != "Untitled" and line.strip() != "" and not line.startswith('#') and i < 10: clean_line = line.strip().replace('#', '').strip() if len(clean_line) > 10: description = clean_line break lessons.append({ 'filename': filename, 'title': title, 'description': description, 'path': file_path, }) return lessons @lru_cache(maxsize=32) def get_lesson_names(): """Get lesson names (without .md extension) from Available_Lessons.""" home_content = _read_home_md() if not home_content: return [] names = [] for _link_text, filename in _parse_lesson_links(home_content): file_path = find_lesson_file(filename) if file_path: names.append(filename.replace('.md', '')) return names @lru_cache(maxsize=32) def get_lessons_with_learning_objectives(): """Get lessons with learning objectives extracted from LESSON_INFO sections.""" lessons = [] home_content = _read_home_md() if not home_content: return lessons for link_text, filename in _parse_lesson_links(home_content): file_path = find_lesson_file(filename) if not file_path: continue with open(file_path, 'r', encoding='utf-8') as f: content = f.read() title = link_text description = "Learn C programming concepts with practical examples." lesson_info_start = content.find('---LESSON_INFO---') lesson_info_end = content.find('---END_LESSON_INFO---') prerequisite_titles = [] if lesson_info_start != -1 and lesson_info_end != -1: lesson_info_section = content[lesson_info_start + len('---LESSON_INFO---'):lesson_info_end] # Extract Learning Objectives 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]) else: lines_after = lesson_info_section[objectives_start:].split('\n')[1:4] description = ' '.join(line.strip() for line in lines_after if line.strip()) # Extract Prerequisites prereq_start = lesson_info_section.find('**Prerequisites:**') if prereq_start != -1: prereq_section = lesson_info_section[prereq_start + len('**Prerequisites:**'):] # Look for bullet points - support both plain text and markdown link format # Plain text: - Hello, World! # Markdown link: - [Hello, World!](lesson/hello_world.md) bullet_lines = re.findall(r'- ([^\n]+)', prereq_section) prerequisite_slugs = [] for bullet in bullet_lines: bullet = bullet.strip() # Filter out "None" or "Tidak ada" if bullet.lower() in ('tidak ada', 'none', '-', ''): continue # Check if it's a markdown link format [title](path) md_link_match = re.match(r'\[([^\]]+)\]\(([^)]+)\)', bullet) if md_link_match: # Extract slug from the link path link_path = md_link_match.group(2) # Handle paths like lesson/hello_world.md or just hello_world.md slug = link_path.replace('.md', '').split('/')[-1] prerequisite_slugs.append(slug) else: # Plain text - keep as title for later resolution prerequisite_slugs.append(bullet) prerequisite_titles = prerequisite_slugs content_after_info = content[lesson_info_end + len('---END_LESSON_INFO---'):].strip() for line in content_after_info.split('\n'): if line.startswith('# '): title = line[2:].strip() break else: lines = content.split('\n') for line in lines: if line.startswith('# ') and title == link_text: if title == "Untitled" or title == link_text: title = line[2:].strip() break lessons.append({ 'filename': filename, 'title': title, 'description': description, 'path': file_path, 'prerequisite_titles': prerequisite_titles, }) return lessons def get_ordered_lessons_with_learning_objectives(progress=None): """Get lessons ordered per home.md with completion status from progress dict.""" home_content = _read_home_md() lesson_links = _parse_lesson_links(home_content) if home_content else [] all_lessons = get_lessons_with_learning_objectives() # Build title -> slug mapping for prerequisite resolution title_to_slug = {lesson['title']: lesson['filename'].replace('.md', '') for lesson in all_lessons} # Also map link text from home.md for link_text, filename in lesson_links: title_to_slug[link_text] = filename.replace('.md', '') def _add_completion_and_prereqs(lesson, progress): slug = lesson['filename'].replace('.md', '') if progress: status = progress.get(slug, '') lesson['completed'] = status not in (None, '', 'not_started') else: lesson['completed'] = False # Resolve prerequisites - now contains slugs directly from markdown links # or still contains plain text titles that need resolution items = lesson.get('prerequisite_titles', []) resolved_prereqs = [] for item in items: # If it's already a valid slug (exists in all_lessons), use it directly if item in title_to_slug.values(): resolved_prereqs.append(item) # Otherwise try to resolve via title mapping elif item in title_to_slug: resolved_prereqs.append(title_to_slug[item]) lesson['prerequisites'] = resolved_prereqs return lesson if lesson_links: ordered = [] for link_text, filename in lesson_links: for lesson in all_lessons: if lesson['filename'] == filename: copy = lesson.copy() copy['title'] = link_text _add_completion_and_prereqs(copy, progress) ordered.append(copy) break seen = {l['filename'] for l in ordered} for lesson in all_lessons: if lesson['filename'] not in seen: copy = lesson.copy() _add_completion_and_prereqs(copy, progress) ordered.append(copy) return ordered ordered_fallback = [] for lesson in all_lessons: copy = lesson.copy() _add_completion_and_prereqs(copy, progress) ordered_fallback.append(copy) 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 # --------------------------------------------------------------------------- MD_EXTENSIONS = ['fenced_code', 'tables', 'nl2br', 'toc', 'mdx_math'] # Domain blacklist for embed iframe src (must be https). EMBED_BLOCKED_HOSTS = { 'localhost', '127.0.0.1', '0.0.0.0', 'metadata.google.internal', '169.254.169.254', } # HTML sanitization config for ```embed fences (raw HTML embed code) EMBED_ALLOWED_TAGS = ['div', 'iframe', 'a', 'span', 'p', 'br', 'img'] EMBED_ALLOWED_ATTRS = { 'div': ['style', 'class'], 'iframe': ['src', 'style', 'loading', 'allowfullscreen', 'allow', 'title', 'class'], 'a': ['href', 'target', 'rel', 'style', 'class'], 'span': ['style', 'class'], 'p': ['style', 'class'], 'img': ['src', 'alt', 'style', 'class', 'loading'], '*': ['class'], } EMBED_ALLOWED_STYLES = [ 'position', 'width', 'height', 'padding', 'padding-top', 'padding-bottom', 'padding-left', 'padding-right', 'margin', 'margin-top', 'margin-bottom', 'margin-left', 'margin-right', 'border', 'border-radius', 'overflow', 'box-shadow', 'top', 'left', 'right', 'bottom', 'will-change', 'display', 'flex-direction', 'gap', 'max-width', 'max-height', 'min-height', ] def _process_circuit_embeds(text): """Replace ```circuit[,width][,height] code fences with embeddable HTML divs. Supported formats: ```circuit -> width=100%, height=400px ```circuit,500px -> width=100%, height=500px ```circuit,80%,500px -> width=80%, height=500px """ pattern = re.compile( r'```circuit(?:,([^\s,`]+))?(?:,([^\s,`]+))?\s*\n(.*?)```', re.DOTALL, ) def _replacer(match): param1 = match.group(1) param2 = match.group(2) # One param = height only; two params = width, height if param1 and param2: width, height = param1, param2 elif param1: width, height = '100%', param1 else: width, height = '100%', '400px' data = html_module.escape(match.group(3).strip()) return ( f'
' ) return pattern.sub(_replacer, text) def _process_flowchart_embeds(text): """Replace ```flowchart[,width][,height] code fences with embeddable HTML divs. Supported formats: ```flowchart -> width=100%, height=400px ```flowchart,500px -> width=100%, height=500px ```flowchart,80%,500px -> width=80%, height=500px """ pattern = re.compile( r'```flowchart(?:,([^\s,`]+))?(?:,([^\s,`]+))?\s*\n(.*?)```', re.DOTALL, ) def _replacer(match): param1 = match.group(1) param2 = match.group(2) # One param = height only; two params = width, height if param1 and param2: width, height = param1, param2 elif param1: width, height = '100%', param1 else: width, height = '100%', '400px' data = html_module.escape(match.group(3).strip()) return ( f'' ) return pattern.sub(_replacer, text) def _sanitize_embed_html(html_text): """Sanitize raw embed HTML: whitelist tags/attrs/styles + check iframe src domain.""" cleaned = bleach.clean( html_text, tags=EMBED_ALLOWED_TAGS, attributes=EMBED_ALLOWED_ATTRS, strip=True, ) # Optional CSS sanitization — requires tinycss2 (skip if not installed) try: from bleach.css_sanitizer import CSSSanitizer css_sanitizer = CSSSanitizer(allowed_css_properties=EMBED_ALLOWED_STYLES) cleaned = bleach.clean( html_text, tags=EMBED_ALLOWED_TAGS, attributes=EMBED_ALLOWED_ATTRS, css_sanitizer=css_sanitizer, strip=True, ) except ImportError: pass # tinycss2 missing — CSS styles left unsanitized but tags/attrs still stripped # Check every iframe src: must be https + not blacklisted for match in re.finditer(r'