""" 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'
' f'' f'
Memuat simulator...
' 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'
' f'' f'
Memuat flowchart...
' 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']+src="([^"]*)"', cleaned): src = match.group(1) try: host = (urlparse(src).hostname or '').lower() except Exception: return '
Konten embed ditolak: URL iframe tidak valid.
' if not src.startswith('https://'): return '
Konten embed ditolak: iframe harus https.
' if host in EMBED_BLOCKED_HOSTS or any(host.endswith('.' + h) for h in EMBED_BLOCKED_HOSTS): return '
Konten embed ditolak: domain iframe diblokir.
' return cleaned def _process_embed_embeds(text): """Replace ```embed fences containing raw HTML embed code with sanitized HTML. User pastes embed code from Canva/YouTube/Google Docs (Share → Embed). HTML is sanitized via bleach (whitelist tags/attrs/styles) and iframe src is checked against EMBED_BLOCKED_HOSTS. """ pattern = re.compile( r'```embed\s*\n(.*?)```', re.DOTALL, ) def _replacer(match): raw_html = match.group(1).strip() if not raw_html: return '
Konten embed kosong.
' return _sanitize_embed_html(raw_html) return pattern.sub(_replacer, text) _OPTION_LINE_RE = re.compile(r'^\s*-\s*\[([ xX]?)\]\s*(.*)$') def _iter_option_lines(body): """Yield (char_index, mark, content) for option lines outside fenced code. Lines inside ``` fenced blocks are skipped so code examples containing '- [x] ...' are never mistaken for real options. """ in_fence = False pos = 0 for line in body.split('\n'): if line.strip().startswith('```'): in_fence = not in_fence elif not in_fence: m = _OPTION_LINE_RE.match(line) if m: yield pos, m.group(1), m.group(2) pos += len(line) + 1 def _extract_body_image(text): """Return (image_url, cleaned_text) for the first markdown image found outside fenced code blocks. cleaned_text has that image markup removed. Returns (None, text) when no image is found. """ lines = text.split('\n') fence = False for idx, line in enumerate(lines): stripped = line.strip() if stripped.startswith('```'): fence = not fence continue if fence: continue m = re.search(r'!\[[^\]]*\]\(([^)]+)\)', line) if m: url = m.group(1).strip() cleaned_line = line[:m.start()] + line[m.end():] cleaned_lines = lines[:idx] + ([cleaned_line] if cleaned_line.strip() else []) + lines[idx + 1:] return url, '\n'.join(cleaned_lines).strip() return None, text def _parse_flashcards(text): """Parse a string of markdown with headings and options into a list of dicts. Supports two formats: 1. Simple Flashcard: '### Question\nAnswer' 2. Multiple Choice (MCQ): '### Question [optional rich markdown body: paragraphs, fenced code, tables, math] - [] option 1 - [x] option 2 (Correct) - [] option 3 > Explanation' Every question and option receives a stable id based on original parse order ('q-0', 'q-0-o-0', ...) — the frontend uses these ids for shuffling and scoring, so they must never depend on display order. """ if not text.strip(): return [] # Split by headings starting with #, ##, or ### parts = re.split(r'^#{1,3}\s+', text, flags=re.MULTILINE) flashcards = [] for part in parts: if not part.strip(): continue # First line is the question (Front) subparts = part.split('\n', 1) question = subparts[0].strip() body = subparts[1] if len(subparts) > 1 else "" if not question: continue # Initialize image_url - will be set from markdown image or 'image:' directive image_url = "" # Extract image from markdown syntax ![...](path) in question md_image_match = re.search(r'!\[.*?\]\(([^)]+)\)', question) if md_image_match: md_image_path = md_image_match.group(1).strip() # Jika path lokal (/assets/ atau bare filename) dan belum ada image dari 'image:' directive if md_image_path.startswith('/assets/') or (not md_image_path.startswith(('http://', 'https://')) and not image_url): image_url = md_image_path # Check for image: URL image_match = re.search(r'^\s*image:\s*(.*)$', body, re.MULTILINE) if image_match: image_url = image_match.group(1).strip() # Override jika ada 'image:' directive if image_url.startswith('/assets/'): # Path sudah lengkap, tidak perlu konversi pass elif not image_url.startswith(('http://', 'https://', '/')): image_url = f'/assets/{image_url}' body = re.sub(r'^\s*image:\s*.*$', '', body, flags=re.MULTILINE).strip() # Option lines outside fenced code, in original order option_lines = list(_iter_option_lines(body)) first_option_idx = option_lines[0][0] if option_lines else len(body) # Explanation blockquote: after the options for MCQ, anywhere for flashcard explanation_search_region = body[first_option_idx:] if option_lines else body explanation_match = re.search(r'^\s*(>.*)$', explanation_search_region, re.MULTILINE | re.DOTALL) explanation = explanation_match.group(1).strip() if explanation_match else "" q_id = f'q-{len(flashcards)}' # If MCQ options exist, it's an MCQ. if option_lines: parsed_options = [] for _, mark, content in option_lines: is_correct = mark.lower() == 'x' parsed_options.append({ 'id': f'{q_id}-o-{len(parsed_options)}', 'text': md.markdown(content.strip(), extensions=MD_EXTENSIONS), 'is_correct': is_correct }) correct_count = sum(1 for opt in parsed_options if opt['is_correct']) if correct_count != 1: raise ValueError( f"Soal kuis ke-{len(flashcards) + 1} wajib punya tepat satu opsi benar " f"(ditemukan {correct_count})." ) # Rich question prompt: heading + everything before the first option # line (paragraphs, fenced code, tables, math), through the same # embed pipeline used for lesson material. prompt_body = body[:first_option_idx].strip() # Extract markdown image from the prompt body (outside code fences) # if no image was found in the heading / image: directive yet, and # drop it from the prompt so it is not rendered twice. if not image_url: extracted_url, prompt_body = _extract_body_image(prompt_body) if extracted_url: image_url = extracted_url prompt_text = f"{question}\n\n{prompt_body}" if prompt_body else question prompt_text = _process_circuit_embeds(prompt_text) prompt_text = _process_flowchart_embeds(prompt_text) prompt_text = _process_embed_embeds(prompt_text) flashcards.append({ 'id': q_id, 'type': 'mcq', 'question': md.markdown(prompt_text, extensions=MD_EXTENSIONS), 'options': parsed_options, 'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "", 'image': image_url }) else: # It's a simple Flashcard # Remove explanation from body if it's there to keep 'back' clean clean_back = body if explanation_match: clean_back = body[:explanation_match.start()].strip() flashcards.append({ 'id': q_id, 'type': 'flashcard', 'front': md.markdown(question, extensions=MD_EXTENSIONS), 'back': md.markdown(clean_back, extensions=MD_EXTENSIONS), 'explanation': md.markdown(explanation, extensions=MD_EXTENSIONS) if explanation else "", 'image': image_url, 'options': None }) return flashcards def _extract_section(content, start_marker, end_marker): """Extract text between markers and return (extracted, remaining_content).""" if start_marker not in content or end_marker not in content: return "", content start_idx = content.find(start_marker) end_idx = content.find(end_marker) if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: return "", content extracted = content[start_idx + len(start_marker):end_idx].strip() remaining = content[:start_idx] + content[end_idx + len(end_marker):] return extracted, remaining def render_markdown_content(file_path): """Parse a lesson markdown file and return structured HTML parts as a dictionary.""" try: current_mtime = os.path.getmtime(file_path) except OSError: current_mtime = 0.0 with _markdown_lock: cached = _markdown_cache.get(file_path) if cached and cached['mtime'] == current_mtime: return cached['data'] with open(file_path, 'r', encoding='utf-8') as f: content = f.read() lesson_content = content active_tabs = [] # Check for collective tags before extracting them # Priority: INITIAL_CODE_ARDUINO → velxio mode (exclusive, ignores C/Python tabs) if '---INITIAL_CODE_ARDUINO---' in lesson_content: active_tabs.append('velxio') else: if '---INITIAL_CODE---' in lesson_content: active_tabs.append('c') if '---INITIAL_PYTHON---' in lesson_content: active_tabs.append('python') if '---INITIAL_CIRCUIT---' in lesson_content: active_tabs.append('circuit') if '---INITIAL_FLOWCHART---' in lesson_content: active_tabs.append('flowchart') if '---INITIAL_QUIZ---' in lesson_content: active_tabs.append('quiz') if '---QUIZ_FLASHCARD---' in lesson_content: active_tabs.append('quiz') # Velxio circuit-only: has VELXIO_CIRCUIT but no INITIAL_CODE_ARDUINO if '---VELXIO_CIRCUIT---' in lesson_content and 'velxio' not in active_tabs: active_tabs.append('velxio') # Default to 'c' if nothing specified (for backwards compatibility) if not active_tabs and '---INITIAL_CODE---' not in lesson_content and '---INITIAL_PYTHON---' not in lesson_content and '---INITIAL_CIRCUIT---' not in lesson_content and '---INITIAL_FLOWCHART---' not in lesson_content and '---INITIAL_QUIZ---' not in lesson_content and '---QUIZ_FLASHCARD---' not in lesson_content: # If it's a completely plain old file, assume it has a code editor available if '---EXERCISE---' in lesson_content: active_tabs.append('c') # Extract special sections (order matters — each extraction removes the section) expected_output, lesson_content = _extract_section( lesson_content, '---EXPECTED_OUTPUT---', '---END_EXPECTED_OUTPUT---') expected_output_python, lesson_content = _extract_section( lesson_content, '---EXPECTED_OUTPUT_PYTHON---', '---END_EXPECTED_OUTPUT_PYTHON---') expected_circuit_output, lesson_content = _extract_section( lesson_content, '---EXPECTED_CIRCUIT_OUTPUT---', '---END_EXPECTED_CIRCUIT_OUTPUT---') key_text, lesson_content = _extract_section( lesson_content, '---KEY_TEXT---', '---END_KEY_TEXT---') key_text_circuit, lesson_content = _extract_section( lesson_content, '---KEY_TEXT_CIRCUIT---', '---END_KEY_TEXT_CIRCUIT---') # Lesson info has a special fallback for old format lesson_info = "" if '---LESSON_INFO---' in lesson_content and '---END_LESSON_INFO---' in lesson_content: lesson_info, lesson_content = _extract_section( lesson_content, '---LESSON_INFO---', '---END_LESSON_INFO---') elif '---LESSON_INFO---' in lesson_content: parts = lesson_content.split('---LESSON_INFO---', 1) if len(parts) == 2: lesson_info = parts[0].strip() lesson_content = parts[1].strip() solution_code, lesson_content = _extract_section( lesson_content, '---SOLUTION_CODE---', '---END_SOLUTION_CODE---') solution_circuit, lesson_content = _extract_section( lesson_content, '---SOLUTION_CIRCUIT---', '---END_SOLUTION_CIRCUIT---') solution_python, lesson_content = _extract_section( lesson_content, '---SOLUTION_PYTHON---', '---END_SOLUTION_PYTHON---') # Initial codes (C, Python, Circuit, Quiz) initial_code_c, lesson_content = _extract_section( lesson_content, '---INITIAL_CODE---', '---END_INITIAL_CODE---') initial_python, lesson_content = _extract_section( lesson_content, '---INITIAL_PYTHON---', '---END_INITIAL_PYTHON---') initial_circuit, lesson_content = _extract_section( lesson_content, '---INITIAL_CIRCUIT---', '---END_INITIAL_CIRCUIT---') initial_flowchart_str, lesson_content = _extract_section( lesson_content, '---INITIAL_FLOWCHART---', '---END_INITIAL_FLOWCHART---') initial_flowchart = None if initial_flowchart_str: import json try: initial_flowchart = json.loads(initial_flowchart_str) except: initial_flowchart = {} initial_quiz, lesson_content = _extract_section( lesson_content, '---INITIAL_QUIZ---', '---END_INITIAL_QUIZ---') quiz_flashcard_raw, lesson_content = _extract_section( lesson_content, '---QUIZ_FLASHCARD---', '---END_QUIZ_FLASHCARD---') quiz_data = _parse_flashcards(quiz_flashcard_raw) # Arduino/Velxio sections initial_code_arduino, lesson_content = _extract_section( lesson_content, '---INITIAL_CODE_ARDUINO---', '---END_INITIAL_CODE_ARDUINO---') velxio_circuit, lesson_content = _extract_section( lesson_content, '---VELXIO_CIRCUIT---', '---END_VELXIO_CIRCUIT---') expected_serial_output, lesson_content = _extract_section( lesson_content, '---EXPECTED_SERIAL_OUTPUT---', '---END_EXPECTED_SERIAL_OUTPUT---') expected_wiring, lesson_content = _extract_section( lesson_content, '---EXPECTED_WIRING---', '---END_EXPECTED_WIRING---') expected_flowchart, lesson_content = _extract_section( lesson_content, '---EXPECTED_FLOWCHART---', '---END_EXPECTED_FLOWCHART---') evaluation_config, lesson_content = _extract_section( lesson_content, '---EVALUATION_CONFIG---', '---END_EVALUATION_CONFIG---') # Extract Slides slides_raw, _ = _extract_section(lesson_content, '---slide-start---', '---slide-end---') slides_html = [] if slides_raw: # Replace the entire slide block with a mount point in the lesson_content # We need to find the exact indices to replace it surgically start_marker = '---slide-start---' end_marker = '---slide-end---' s_idx = lesson_content.find(start_marker) e_idx = lesson_content.find(end_marker) if s_idx != -1 and e_idx != -1 and e_idx > s_idx: lesson_content = ( lesson_content[:s_idx] + '
' + lesson_content[e_idx + len(end_marker):] ) # Parse slides slide_parts = re.split(r'^\s*---\s*$', slides_raw, flags=re.MULTILINE) for s in slide_parts: if s.strip(): # Process embeds in slides too s = _process_circuit_embeds(s) s = _process_flowchart_embeds(s) s = _process_embed_embeds(s) slides_html.append(md.markdown(s.strip(), extensions=MD_EXTENSIONS)) # Just use whichever initial code matched as the generic 'initial_code' for simplicity # if only one type exists, but return all as dictionary values. # Typically frontend uses 'initial_code' for legacy. initial_code = initial_code_c or initial_python or initial_circuit or initial_quiz # Split lesson vs exercise parts = lesson_content.split('---EXERCISE---') lesson_content = parts[0] if parts else lesson_content exercise_content = parts[1] if len(parts) > 1 else "" # Convert ```circuit and ```flowchart fences to embed divs before markdown rendering lesson_content = _process_circuit_embeds(lesson_content) lesson_content = _process_flowchart_embeds(lesson_content) lesson_content = _process_embed_embeds(lesson_content) if exercise_content: exercise_content = _process_circuit_embeds(exercise_content) exercise_content = _process_flowchart_embeds(exercise_content) exercise_content = _process_embed_embeds(exercise_content) if lesson_info: lesson_info = _process_circuit_embeds(lesson_info) lesson_info = _process_flowchart_embeds(lesson_info) lesson_info = _process_embed_embeds(lesson_info) lesson_html = md.markdown(lesson_content, extensions=MD_EXTENSIONS) exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else "" lesson_info_html = md.markdown(lesson_info, extensions=MD_EXTENSIONS) if lesson_info else "" parsed_data = { 'lesson_html': lesson_html, 'exercise_html': exercise_html, 'expected_output': expected_output, 'expected_output_python': expected_output_python, 'expected_circuit_output': expected_circuit_output, 'expected_flowchart': expected_flowchart, 'lesson_info': lesson_info_html, 'initial_code': initial_code, 'solution_code': solution_code, 'solution_circuit': solution_circuit, 'solution_python': solution_python, 'key_text': key_text, 'key_text_circuit': key_text_circuit, 'initial_code_c': initial_code_c, 'initial_python': initial_python, 'initial_circuit': initial_circuit, 'initial_flowchart': initial_flowchart_str or initial_flowchart, 'initial_quiz': initial_quiz, 'initial_code_arduino': initial_code_arduino, 'velxio_circuit': velxio_circuit, 'expected_serial_output': expected_serial_output, 'expected_wiring': expected_wiring, 'evaluation_config': evaluation_config, 'quiz_data': quiz_data, 'active_tabs': active_tabs, 'slides': slides_html } with _markdown_lock: if len(_markdown_cache) >= 128: _markdown_cache.clear() _markdown_cache[file_path] = {'data': parsed_data, 'mtime': current_mtime} return parsed_data def render_home_content(): """Render the home.md intro section (before Available_Lessons) as HTML. Not cached separately — relies on _read_home_md() mtime-based cache, which is already fast (1 syscall per request). This avoids the lru_cache multi-worker stale data problem. """ home_content = _read_home_md() if not home_content: return "" # Use robust regex to split Available_Lessons parts = re.split(r'-{3,}Available_Lessons-{3,}', home_content) main_content = parts[0] if parts else home_content return md.markdown(main_content, extensions=['fenced_code', 'tables', 'mdx_math'])