From bb04245d855d40b5e9a11a07855a9bd263f23e68 Mon Sep 17 00:00:00 2001 From: a2nr Date: Wed, 26 Aug 2026 19:32:28 +0700 Subject: [PATCH] feat: migrate markdown preview rendering to client-side SvelteKit --- .../src/lib/services/contentEditor.svelte.ts | 11 +- frontend/src/lib/services/contentEditor.ts | 4 + .../lib/services/markdown/detectActiveTabs.ts | 50 ++++ .../lib/services/markdown/extractSections.ts | 25 ++ frontend/src/lib/services/markdown/index.ts | 148 +++++++++++ .../lib/services/markdown/markdown.test.ts | 144 +++++++++++ .../lib/services/markdown/parseFlashcards.ts | 229 +++++++++++++++++ .../src/lib/services/markdown/parseSlides.ts | 43 ++++ .../lib/services/markdown/processEmbeds.ts | 236 ++++++++++++++++++ routes/content_editor.py | 2 + 10 files changed, 887 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/services/markdown/detectActiveTabs.ts create mode 100644 frontend/src/lib/services/markdown/extractSections.ts create mode 100644 frontend/src/lib/services/markdown/index.ts create mode 100644 frontend/src/lib/services/markdown/markdown.test.ts create mode 100644 frontend/src/lib/services/markdown/parseFlashcards.ts create mode 100644 frontend/src/lib/services/markdown/parseSlides.ts create mode 100644 frontend/src/lib/services/markdown/processEmbeds.ts diff --git a/frontend/src/lib/services/contentEditor.svelte.ts b/frontend/src/lib/services/contentEditor.svelte.ts index 765dd30..b55a5be 100644 --- a/frontend/src/lib/services/contentEditor.svelte.ts +++ b/frontend/src/lib/services/contentEditor.svelte.ts @@ -1,5 +1,6 @@ import { tick } from 'svelte'; -import { getDraft, saveDraft, previewContent, publishDraft, type DraftResponse } from './contentEditor'; +import { getDraft, saveDraft, publishDraft, type DraftResponse } from './contentEditor'; +import { renderMarkdownPreview } from './markdown'; export class ContentEditorManager { activePath = $state(null); @@ -82,7 +83,7 @@ export class ContentEditorManager { this.previewTimeout = setTimeout(() => this.runPreview(), 500); } - private async runPreview() { + private runPreview() { if (!this.body.trim()) { this.previewHtml = ''; this.previewExerciseHtml = ''; @@ -94,7 +95,7 @@ export class ContentEditorManager { } this.previewLoading = true; try { - const res = await previewContent(this.body); + const res = renderMarkdownPreview(this.body); if (res.success) { this.previewHtml = res.lesson_content ?? ''; this.previewExerciseHtml = res.exercise_content ?? ''; @@ -105,8 +106,8 @@ export class ContentEditorManager { } else { this.previewError = res.message || 'Preview gagal'; } - } catch { - this.previewError = 'Gagal menghubungi server untuk preview'; + } catch (e: any) { + this.previewError = e.message || 'Gagal merender preview'; } finally { this.previewLoading = false; } diff --git a/frontend/src/lib/services/contentEditor.ts b/frontend/src/lib/services/contentEditor.ts index ce2de00..2a0b64b 100644 --- a/frontend/src/lib/services/contentEditor.ts +++ b/frontend/src/lib/services/contentEditor.ts @@ -48,6 +48,10 @@ export async function saveDraft(targetPath: string, body: string, baseMtime: num return res.json(); } +/** + * @deprecated Use client-side renderMarkdownPreview from './markdown' instead. + * Kept for fallback or direct API verification purposes. + */ export async function previewContent(body: string): Promise { const res = await fetch('/api/content/preview', { method: 'POST', diff --git a/frontend/src/lib/services/markdown/detectActiveTabs.ts b/frontend/src/lib/services/markdown/detectActiveTabs.ts new file mode 100644 index 0000000..dbab74d --- /dev/null +++ b/frontend/src/lib/services/markdown/detectActiveTabs.ts @@ -0,0 +1,50 @@ +/** + * Detect active tabs for the lesson editor/workspace. + */ +export function detectActiveTabs(content: string): string[] { + const activeTabs: string[] = []; + + if (content.includes('---INITIAL_CODE_ARDUINO---')) { + activeTabs.push('velxio'); + } else { + if (content.includes('---INITIAL_CODE---')) { + activeTabs.push('c'); + } + if (content.includes('---INITIAL_PYTHON---')) { + activeTabs.push('python'); + } + } + + if (content.includes('---INITIAL_CIRCUIT---')) { + activeTabs.push('circuit'); + } + + if (content.includes('---INITIAL_FLOWCHART---')) { + activeTabs.push('flowchart'); + } + + if (content.includes('---INITIAL_QUIZ---') || content.includes('---QUIZ_FLASHCARD---')) { + activeTabs.push('quiz'); + } + + if (content.includes('---VELXIO_CIRCUIT---') && !activeTabs.includes('velxio')) { + activeTabs.push('velxio'); + } + + // Default to 'c' if nothing specified (for backwards compatibility) + if ( + activeTabs.length === 0 && + !content.includes('---INITIAL_CODE---') && + !content.includes('---INITIAL_PYTHON---') && + !content.includes('---INITIAL_CIRCUIT---') && + !content.includes('---INITIAL_FLOWCHART---') && + !content.includes('---INITIAL_QUIZ---') && + !content.includes('---QUIZ_FLASHCARD---') + ) { + if (content.includes('---EXERCISE---')) { + activeTabs.push('c'); + } + } + + return activeTabs; +} diff --git a/frontend/src/lib/services/markdown/extractSections.ts b/frontend/src/lib/services/markdown/extractSections.ts new file mode 100644 index 0000000..7267cc4 --- /dev/null +++ b/frontend/src/lib/services/markdown/extractSections.ts @@ -0,0 +1,25 @@ +/** + * Extract content between startMarker and endMarker, returning the extracted content + * and the remaining content with the markers and extracted block removed. + */ +export function extractSection( + content: string, + startMarker: string, + endMarker: string +): [string, string] { + if (!content.includes(startMarker) || !content.includes(endMarker)) { + return ['', content]; + } + + const startIdx = content.indexOf(startMarker); + const endIdx = content.indexOf(endMarker); + + if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) { + return ['', content]; + } + + const extracted = content.substring(startIdx + startMarker.length, endIdx).trim(); + const remaining = content.substring(0, startIdx) + content.substring(endIdx + endMarker.length); + + return [extracted, remaining]; +} diff --git a/frontend/src/lib/services/markdown/index.ts b/frontend/src/lib/services/markdown/index.ts new file mode 100644 index 0000000..310d2bf --- /dev/null +++ b/frontend/src/lib/services/markdown/index.ts @@ -0,0 +1,148 @@ +import { marked } from 'marked'; +import { extractSection } from './extractSections'; +import { detectActiveTabs } from './detectActiveTabs'; +import { parseFlashcards, type QuizCard } from './parseFlashcards'; +import { parseSlides } from './parseSlides'; +import { processCircuitEmbeds, processFlowchartEmbeds, processEmbedEmbeds } from './processEmbeds'; + +export interface RenderedPreview { + success: boolean; + lesson_content: string; + exercise_content: string; + quiz_data: QuizCard[]; + slides: string[]; + active_tabs: string[]; + message?: string; +} + +/** + * Parses markdown on the client side, matching the backend's parsing output exactly. + */ +export function renderMarkdownPreview(rawMarkdown: string): RenderedPreview { + try { + const active_tabs = detectActiveTabs(rawMarkdown); + + let content = rawMarkdown; + + // Extract sections in order (matching Python code) + let expected_output = ''; + [expected_output, content] = extractSection(content, '---EXPECTED_OUTPUT---', '---END_EXPECTED_OUTPUT---'); + + let expected_output_python = ''; + [expected_output_python, content] = extractSection(content, '---EXPECTED_OUTPUT_PYTHON---', '---END_EXPECTED_OUTPUT_PYTHON---'); + + let expected_circuit_output = ''; + [expected_circuit_output, content] = extractSection(content, '---EXPECTED_CIRCUIT_OUTPUT---', '---END_EXPECTED_CIRCUIT_OUTPUT---'); + + let key_text = ''; + [key_text, content] = extractSection(content, '---KEY_TEXT---', '---END_KEY_TEXT---'); + + let key_text_circuit = ''; + [key_text_circuit, content] = extractSection(content, '---KEY_TEXT_CIRCUIT---', '---END_KEY_TEXT_CIRCUIT---'); + + let lesson_info = ''; + if (content.includes('---LESSON_INFO---') && content.includes('---END_LESSON_INFO---')) { + [lesson_info, content] = extractSection(content, '---LESSON_INFO---', '---END_LESSON_INFO---'); + } else if (content.includes('---LESSON_INFO---')) { + const parts = content.split('---LESSON_INFO---'); + if (parts.length === 2) { + lesson_info = parts[0].trim(); + content = parts[1].trim(); + } + } + + let solution_code = ''; + [solution_code, content] = extractSection(content, '---SOLUTION_CODE---', '---END_SOLUTION_CODE---'); + + let solution_circuit = ''; + [solution_circuit, content] = extractSection(content, '---SOLUTION_CIRCUIT---', '---END_SOLUTION_CIRCUIT---'); + + let solution_python = ''; + [solution_python, content] = extractSection(content, '---SOLUTION_PYTHON---', '---END_SOLUTION_PYTHON---'); + + let initial_code_c = ''; + [initial_code_c, content] = extractSection(content, '---INITIAL_CODE---', '---END_INITIAL_CODE---'); + + let initial_python = ''; + [initial_python, content] = extractSection(content, '---INITIAL_PYTHON---', '---END_INITIAL_PYTHON---'); + + let initial_circuit = ''; + [initial_circuit, content] = extractSection(content, '---INITIAL_CIRCUIT---', '---END_INITIAL_CIRCUIT---'); + + let initial_flowchart_str = ''; + [initial_flowchart_str, content] = extractSection(content, '---INITIAL_FLOWCHART---', '---END_INITIAL_FLOWCHART---'); + + let initial_quiz = ''; + [initial_quiz, content] = extractSection(content, '---INITIAL_QUIZ---', '---END_INITIAL_QUIZ---'); + + let quiz_flashcard_raw = ''; + [quiz_flashcard_raw, content] = extractSection(content, '---QUIZ_FLASHCARD---', '---END_QUIZ_FLASHCARD---'); + + const quiz_data = parseFlashcards(quiz_flashcard_raw); + + let initial_code_arduino = ''; + [initial_code_arduino, content] = extractSection(content, '---INITIAL_CODE_ARDUINO---', '---END_INITIAL_CODE_ARDUINO---'); + + let velxio_circuit = ''; + [velxio_circuit, content] = extractSection(content, '---VELXIO_CIRCUIT---', '---END_VELXIO_CIRCUIT---'); + + let expected_serial_output = ''; + [expected_serial_output, content] = extractSection(content, '---EXPECTED_SERIAL_OUTPUT---', '---END_EXPECTED_SERIAL_OUTPUT---'); + + let expected_wiring = ''; + [expected_wiring, content] = extractSection(content, '---EXPECTED_WIRING---', '---END_EXPECTED_WIRING---'); + + let expected_flowchart = ''; + [expected_flowchart, content] = extractSection(content, '---EXPECTED_FLOWCHART---', '---END_EXPECTED_FLOWCHART---'); + + let evaluation_config = ''; + [evaluation_config, content] = extractSection(content, '---EVALUATION_CONFIG---', '---END_EVALUATION_CONFIG---'); + + // Parse slides + const { slides, contentWithoutSlides } = parseSlides(content); + content = contentWithoutSlides; + + // Split lesson vs exercise + const exerciseParts = content.split('---EXERCISE---'); + let lessonContent = exerciseParts[0] || ''; + let exerciseContent = exerciseParts.length > 1 ? exerciseParts[1] : ''; + + // Process embeds + lessonContent = processCircuitEmbeds(lessonContent); + lessonContent = processFlowchartEmbeds(lessonContent); + lessonContent = processEmbedEmbeds(lessonContent); + + if (exerciseContent) { + exerciseContent = processCircuitEmbeds(exerciseContent); + exerciseContent = processFlowchartEmbeds(exerciseContent); + exerciseContent = processEmbedEmbeds(exerciseContent); + } + + // Render markdown to HTML + const renderMarkdown = (md: string) => { + return marked.parse(md, { breaks: true, gfm: true }) as string; + }; + + const lesson_html = renderMarkdown(lessonContent); + const exercise_html = exerciseContent ? renderMarkdown(exerciseContent) : ''; + + return { + success: true, + lesson_content: lesson_html, + exercise_content: exercise_html, + quiz_data, + slides, + active_tabs + }; + } catch (e: any) { + return { + success: false, + lesson_content: '', + exercise_content: '', + quiz_data: [], + slides: [], + active_tabs: [], + message: e.message || 'Gagal merender preview' + }; + } +} diff --git a/frontend/src/lib/services/markdown/markdown.test.ts b/frontend/src/lib/services/markdown/markdown.test.ts new file mode 100644 index 0000000..328494a --- /dev/null +++ b/frontend/src/lib/services/markdown/markdown.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import { extractSection } from './extractSections'; +import { detectActiveTabs } from './detectActiveTabs'; +import { processCircuitEmbeds, processFlowchartEmbeds } from './processEmbeds'; +import { parseSlides } from './parseSlides'; +import { parseFlashcards } from './parseFlashcards'; +import { renderMarkdownPreview } from './index'; + +describe('Markdown Renderer & Extractor Tests', () => { + describe('extractSection', () => { + it('should extract section between markers and return remaining text', () => { + const input = 'Before\n---EXPECTED_OUTPUT---\nexpected content\n---END_EXPECTED_OUTPUT---\nAfter'; + const [extracted, remaining] = extractSection(input, '---EXPECTED_OUTPUT---', '---END_EXPECTED_OUTPUT---'); + expect(extracted).toBe('expected content'); + expect(remaining).toBe('Before\n\nAfter'); + }); + + it('should return empty string and original text if markers are not present', () => { + const input = 'Some random content without markers'; + const [extracted, remaining] = extractSection(input, '---START---', '---END---'); + expect(extracted).toBe(''); + expect(remaining).toBe(input); + }); + }); + + describe('detectActiveTabs', () => { + it('should detect velxio mode exclusively if INITIAL_CODE_ARDUINO is present', () => { + const input = '---INITIAL_CODE_ARDUINO---\nvoid setup() {}\n---END_INITIAL_CODE_ARDUINO---\n---INITIAL_CODE---\nint main() {}\n---END_INITIAL_CODE---'; + const tabs = detectActiveTabs(input); + expect(tabs).toContain('velxio'); + expect(tabs).not.toContain('c'); + }); + + it('should detect c and python tabs if present', () => { + const input = '---INITIAL_CODE---\nint main() {}\n---END_INITIAL_CODE---\n---INITIAL_PYTHON---\ndef main(): pass\n---END_INITIAL_PYTHON---'; + const tabs = detectActiveTabs(input); + expect(tabs).toContain('c'); + expect(tabs).toContain('python'); + }); + + it('should fallback to c tab if no markers match but ---EXERCISE--- is present', () => { + const input = 'Plain lesson here\n---EXERCISE---\nDo this exercise'; + const tabs = detectActiveTabs(input); + expect(tabs).toEqual(['c']); + }); + }); + + describe('processCircuitEmbeds', () => { + it('should replace circuit fences with div placeholders', () => { + const input = '```circuit\ncircuit_data_here\n```'; + const output = processCircuitEmbeds(input); + expect(output).toContain('class="circuit-embed"'); + expect(output).toContain('data-width="100%"'); + expect(output).toContain('data-height="400px"'); + expect(output).toContain('circuit_data_here'); + }); + + it('should handle height parameter', () => { + const input = '```circuit,500px\ncircuit_data_here\n```'; + const output = processCircuitEmbeds(input); + expect(output).toContain('data-height="500px"'); + }); + + it('should handle width and height parameters', () => { + const input = '```circuit,80%,600px\ncircuit_data_here\n```'; + const output = processCircuitEmbeds(input); + expect(output).toContain('data-width="80%"'); + expect(output).toContain('data-height="600px"'); + }); + }); + + describe('parseSlides', () => { + it('should extract slides, mount point, and render individual slides', () => { + const input = 'Before slides\n---slide-start---\n# Slide 1\nContent 1\n---\n# Slide 2\nContent 2\n---slide-end---\nAfter slides'; + const { slides, contentWithoutSlides } = parseSlides(input); + expect(contentWithoutSlides).toBe('Before slides\n
\nAfter slides'); + expect(slides).toHaveLength(2); + expect(slides[0]).toContain('

Slide 1

'); + expect(slides[1]).toContain('

Slide 2

'); + }); + }); + + describe('parseFlashcards', () => { + it('should parse simple flashcards without options', () => { + const input = '### What is C?\nIt is a programming language.\n> Explanation text here'; + const cards = parseFlashcards(input); + expect(cards).toHaveLength(1); + expect(cards[0].type).toBe('flashcard'); + expect(cards[0].front).toContain('What is C?'); + expect(cards[0].back).toContain('It is a programming language.'); + expect(cards[0].explanation).toContain('Explanation text here'); + }); + + it('should parse MCQ cards with options', () => { + const input = '### Tipe Data C\nSelect one:\n- [] float\n- [x] int\n- [] char\n> Explanation for int'; + const cards = parseFlashcards(input); + expect(cards).toHaveLength(1); + expect(cards[0].type).toBe('mcq'); + expect(cards[0].question).toContain('Tipe Data C'); + expect(cards[0].options).toHaveLength(3); + expect(cards[0].options![1].is_correct).toBe(true); + expect(cards[0].explanation).toContain('Explanation for int'); + }); + + it('should throw value validation error if MCQ has no correct option', () => { + const input = '### Invalid Question\n- [] opt 1\n- [] opt 2'; + expect(() => parseFlashcards(input)).toThrow('wajib punya tepat satu opsi benar'); + }); + + it('should throw value validation error if MCQ has multiple correct options', () => { + const input = '### Invalid Question\n- [x] opt 1\n- [x] opt 2'; + expect(() => parseFlashcards(input)).toThrow('wajib punya tepat satu opsi benar'); + }); + }); + + describe('renderMarkdownPreview orchestrator', () => { + it('should successfully compile full markdown input into JSON structure', () => { + const input = `# My Lesson +---LESSON_INFO--- +**Prerequisite:** None +---END_LESSON_INFO--- +This is the theory. +---EXERCISE--- +This is the exercise.`; + const res = renderMarkdownPreview(input); + expect(res.success).toBe(true); + expect(res.lesson_content).toContain('

My Lesson

'); + expect(res.lesson_content).toContain('

This is the theory.

'); + expect(res.exercise_content).toContain('

This is the exercise.

'); + }); + + it('should catch validation errors from flashcards and report success: false', () => { + const input = `# Lesson with invalid quiz +---QUIZ_FLASHCARD--- +### Question without correct choice +- [] A +- [] B +---END_QUIZ_FLASHCARD---`; + const res = renderMarkdownPreview(input); + expect(res.success).toBe(false); + expect(res.message).toContain('wajib punya tepat satu opsi benar'); + }); + }); +}); diff --git a/frontend/src/lib/services/markdown/parseFlashcards.ts b/frontend/src/lib/services/markdown/parseFlashcards.ts new file mode 100644 index 0000000..2303035 --- /dev/null +++ b/frontend/src/lib/services/markdown/parseFlashcards.ts @@ -0,0 +1,229 @@ +import { marked } from 'marked'; +import { processCircuitEmbeds, processFlowchartEmbeds, processEmbedEmbeds } from './processEmbeds'; + +const OPTION_LINE_RE = /^\s*-\s*\[([ xX]?)\]\s*(.*)$/; + +export interface QuizOption { + id: string; + text: string | Promise; + is_correct: boolean; +} + +export interface QuizCard { + id: string; + type: 'mcq' | 'flashcard'; + question?: string | Promise; + front?: string | Promise; + back?: string | Promise; + options: QuizOption[] | null; + explanation: string | Promise; + image: string; + category: string; +} + +/** + * Yield (pos, mark, content) for option lines outside fenced code blocks. + */ +export function iterOptionLines(body: string): { pos: number; mark: string; content: string }[] { + const lines = body.split('\n'); + let inFence = false; + let pos = 0; + const results: { pos: number; mark: string; content: string }[] = []; + + for (const line of lines) { + if (line.trim().startsWith('```')) { + inFence = !inFence; + } else if (!inFence) { + const m = line.match(OPTION_LINE_RE); + if (m) { + results.push({ + pos, + mark: m[1], + content: m[2] + }); + } + } + pos += line.length + 1; // +1 for the newline character + } + return results; +} + +/** + * Return [imageUrl, cleanedText] for the first markdown image found outside fenced code blocks. + */ +export function extractBodyImage(text: string): [string | null, string] { + const lines = text.split('\n'); + let inFence = false; + + for (let idx = 0; idx < lines.length; idx++) { + const line = lines[idx]; + if (line.trim().startsWith('```')) { + inFence = !inFence; + continue; + } + if (inFence) { + continue; + } + const match = line.match(/!\[[^\]]*\]\(([^)]+)\)/); + if (match) { + const url = match[1].trim(); + const matchStart = line.indexOf(match[0]); + const matchEnd = matchStart + match[0].length; + const cleanedLine = line.substring(0, matchStart) + line.substring(matchEnd); + + const cleanedLines = [ + ...lines.slice(0, idx), + ...(cleanedLine.trim() ? [cleanedLine] : []), + ...lines.slice(idx + 1) + ]; + return [url, cleanedLines.join('\n').trim()]; + } + } + return [null, text]; +} + +/** + * Parse a string of markdown with headings and options into a list of quiz objects. + */ +export function parseFlashcards(text: string): QuizCard[] { + if (!text.trim()) { + return []; + } + + // Split by headings starting with #, ##, or ### at the start of a line + const parts = text.split(/^#{1,3}\s+/m); + const flashcards: QuizCard[] = []; + + const renderMarkdown = (md: string) => { + return marked.parse(md, { breaks: true, gfm: true }) as string; + }; + + for (const part of parts) { + if (!part.trim()) { + continue; + } + + // First line is the question (Front) + const subparts = part.split('\n'); + const question = subparts[0].trim(); + let body = subparts.slice(1).join('\n'); + + if (!question) { + continue; + } + + let imageUrl = ''; + + // Extract image from markdown syntax ![...](path) in question + const mdImageMatch = question.match(/!\[.*?\]\(([^)]+)\)/); + if (mdImageMatch) { + const mdImagePath = mdImageMatch[1].trim(); + if ( + mdImagePath.startsWith('/assets/') || + (!mdImagePath.startsWith('http://') && !mdImagePath.startsWith('https://')) + ) { + imageUrl = mdImagePath; + } + } + + // Check for image: URL directive in body + const imageMatch = body.match(/^\s*image:\s*(.*)$/m); + if (imageMatch) { + imageUrl = imageMatch[1].trim(); + if ( + !imageUrl.startsWith('/assets/') && + !imageUrl.startsWith('http://') && + !imageUrl.startsWith('https://') && + !imageUrl.startsWith('/') + ) { + imageUrl = `/assets/${imageUrl}`; + } + body = body.replace(/^\s*image:\s*.*$/gm, '').trim(); + } + + // Check for ::diagnostic marker + let category = 'evaluasi'; + if (/^\s*::diagnostic\s*$/m.test(body)) { + category = 'diagnostik'; + body = body.replace(/^\s*::diagnostic\s*$/gm, '').trim(); + } + + const optionLines = iterOptionLines(body); + const firstOptionIdx = optionLines.length > 0 ? optionLines[0].pos : body.length; + + // Explanation blockquote: after options for MCQ, anywhere for flashcard + const explanationSearchRegion = body.substring(firstOptionIdx); + const explanationMatch = explanationSearchRegion.match(/^\s*(>[\s\S]*)$/m); + const explanation = explanationMatch ? explanationMatch[1].trim() : ''; + + const qId = `q-${flashcards.length}`; + + if (optionLines.length > 0) { + const parsedOptions: QuizOption[] = []; + for (const opt of optionLines) { + const isCorrect = opt.mark.toLowerCase() === 'x'; + parsedOptions.push({ + id: `${qId}-o-${parsedOptions.length}`, + text: renderMarkdown(opt.content.trim()), + is_correct: isCorrect + }); + } + + const correctCount = parsedOptions.filter((opt) => opt.is_correct).length; + if (correctCount !== 1) { + throw new Error( + `Soal kuis ke-${flashcards.length + 1} wajib punya tepat satu opsi benar ` + + `(ditemukan ${correctCount}).` + ); + } + + let promptBody = body.substring(0, firstOptionIdx).trim(); + + // Extract markdown image from prompt body if no image yet + if (!imageUrl) { + const [extractedUrl, cleanedPromptBody] = extractBodyImage(promptBody); + if (extractedUrl) { + imageUrl = extractedUrl; + promptBody = cleanedPromptBody; + } + } + + let promptText = promptBody ? `${question}\n\n${promptBody}` : question; + promptText = processCircuitEmbeds(promptText); + promptText = processFlowchartEmbeds(promptText); + promptText = processEmbedEmbeds(promptText); + + flashcards.push({ + id: qId, + type: 'mcq', + question: renderMarkdown(promptText), + options: parsedOptions, + explanation: explanation ? renderMarkdown(explanation) : '', + image: imageUrl, + category: category + }); + } else { + // Simple Flashcard + let cleanBack = body; + if (explanationMatch) { + const expIdx = body.indexOf(explanationMatch[1]); + if (expIdx !== -1) { + cleanBack = body.substring(0, expIdx).trim(); + } + } + + flashcards.push({ + id: qId, + type: 'flashcard', + front: renderMarkdown(question), + back: renderMarkdown(cleanBack), + explanation: explanation ? renderMarkdown(explanation) : '', + image: imageUrl, + options: null, + category: category + }); + } + } + + return flashcards; +} diff --git a/frontend/src/lib/services/markdown/parseSlides.ts b/frontend/src/lib/services/markdown/parseSlides.ts new file mode 100644 index 0000000..b007cea --- /dev/null +++ b/frontend/src/lib/services/markdown/parseSlides.ts @@ -0,0 +1,43 @@ +import { marked } from 'marked'; +import { extractSection } from './extractSections'; +import { processCircuitEmbeds, processFlowchartEmbeds, processEmbedEmbeds } from './processEmbeds'; + +/** + * Extract, parse and return slides HTML and the cleaned lesson content. + */ +export function parseSlides(content: string): { slides: string[]; contentWithoutSlides: string } { + const startMarker = '---slide-start---'; + const endMarker = '---slide-end---'; + + const [slidesRaw] = extractSection(content, startMarker, endMarker); + let contentWithoutSlides = content; + const slidesHtml: string[] = []; + + if (slidesRaw) { + const sIdx = content.indexOf(startMarker); + const eIdx = content.indexOf(endMarker); + if (sIdx !== -1 && eIdx !== -1 && eIdx > sIdx) { + contentWithoutSlides = + content.substring(0, sIdx) + + '
' + + content.substring(eIdx + endMarker.length); + } + + // Split slides by "---" on a line by itself + const slideParts = slidesRaw.split(/^\s*---\s*$/m); + for (let s of slideParts) { + s = s.trim(); + if (s) { + s = processCircuitEmbeds(s); + s = processFlowchartEmbeds(s); + s = processEmbedEmbeds(s); + slidesHtml.push(marked.parse(s, { breaks: true, gfm: true }) as string); + } + } + } + + return { + slides: slidesHtml, + contentWithoutSlides + }; +} diff --git a/frontend/src/lib/services/markdown/processEmbeds.ts b/frontend/src/lib/services/markdown/processEmbeds.ts new file mode 100644 index 0000000..3b5ab54 --- /dev/null +++ b/frontend/src/lib/services/markdown/processEmbeds.ts @@ -0,0 +1,236 @@ +/** + * Escape HTML characters to prevent XSS in data embeds. + */ +function escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Replace ```circuit[,width][,height] code fences with embeddable HTML divs. + */ +export function processCircuitEmbeds(text: string): string { + const pattern = /```circuit(?:,([^\s,`]+))?(?:,([^\s,`]+))?\s*\n([\s\S]*?)```/g; + return text.replace(pattern, (_, p1, p2, content) => { + let width = '100%'; + let height = '400px'; + if (p1 && p2) { + width = p1; + height = p2; + } else if (p1) { + height = p1; + } + const escapedData = escapeHtml(content.trim()); + return ( + `
` + + `` + + `
Memuat simulator...
` + + `
` + ); + }); +} + +/** + * Replace ```flowchart[,width][,height] code fences with embeddable HTML divs. + */ +export function processFlowchartEmbeds(text: string): string { + const pattern = /```flowchart(?:,([^\s,`]+))?(?:,([^\s,`]+))?\s*\n([\s\S]*?)```/g; + return text.replace(pattern, (_, p1, p2, content) => { + let width = '100%'; + let height = '400px'; + if (p1 && p2) { + width = p1; + height = p2; + } else if (p1) { + height = p1; + } + const escapedData = escapeHtml(content.trim()); + return ( + `
` + + `` + + `
Memuat flowchart...
` + + `
` + ); + }); +} + +/** + * Sanitize raw embed HTML: whitelist tags/attrs/styles + check iframe src domain. + * Replicates Python's bleach configuration using native DOMParser in the browser. + */ +function sanitizeEmbedHtml(htmlText: string): string { + if (typeof window === 'undefined') { + return htmlText; + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlText, 'text/html'); + const body = doc.body; + + const allowedTags = new Set(['div', 'iframe', 'a', 'span', 'p', 'br', 'img']); + const allowedAttrs: Record = { + 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'] + }; + + const allowedStyles = new Set([ + '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' + ]); + + const blockedHosts = new Set([ + 'localhost', + '127.0.0.1', + '0.0.0.0', + 'metadata.google.internal', + '169.254.169.254' + ]); + + function sanitize(node: Node): Node | null { + if (node.nodeType === Node.TEXT_NODE) { + return node.cloneNode(true); + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return null; + } + + const el = node as HTMLElement; + const tagName = el.tagName.toLowerCase(); + + if (!allowedTags.has(tagName)) { + // Strip the tag but sanitize and keep its children + const fragment = doc.createDocumentFragment(); + for (let i = 0; i < el.childNodes.length; i++) { + const child = sanitize(el.childNodes[i]); + if (child) fragment.appendChild(child); + } + return fragment; + } + + const newEl = doc.createElement(tagName); + + // Copy allowed attributes + const attrs = allowedAttrs[tagName] || []; + for (let i = 0; i < el.attributes.length; i++) { + const attr = el.attributes[i]; + const attrName = attr.name.toLowerCase(); + + if (attrs.includes(attrName) || attrName === 'class') { + let val = attr.value; + if (tagName === 'iframe' && attrName === 'src') { + try { + const url = new URL(val); + if (url.protocol !== 'https:') { + return doc.createTextNode('[Konten embed ditolak: iframe harus https]'); + } + const hostname = url.hostname.toLowerCase(); + if ( + blockedHosts.has(hostname) || + Array.from(blockedHosts).some((bh) => hostname.endsWith('.' + bh)) + ) { + return doc.createTextNode('[Konten embed ditolak: domain iframe diblokir]'); + } + } catch { + if (!val.startsWith('https://')) { + return doc.createTextNode('[Konten embed ditolak: iframe harus https]'); + } + return doc.createTextNode('[Konten embed ditolak: URL iframe tidak valid]'); + } + } + if (attrName === 'style') { + const inlineStyles = val.split(';'); + const cleanStyles: string[] = []; + for (const style of inlineStyles) { + const parts = style.split(':'); + if (parts.length === 2) { + const prop = parts[0].trim().toLowerCase(); + if (allowedStyles.has(prop)) { + cleanStyles.push(`${prop}: ${parts[1].trim()}`); + } + } + } + val = cleanStyles.join('; '); + } + newEl.setAttribute(attrName, val); + } + } + + // Process children + for (let i = 0; i < el.childNodes.length; i++) { + const child = sanitize(el.childNodes[i]); + if (child) { + newEl.appendChild(child); + } + } + + return newEl; + } + + const resultFragment = doc.createDocumentFragment(); + for (let i = 0; i < body.childNodes.length; i++) { + const child = sanitize(body.childNodes[i]); + if (child) { + resultFragment.appendChild(child); + } + } + + const tempDiv = doc.createElement('div'); + tempDiv.appendChild(resultFragment); + return tempDiv.innerHTML; +} + +/** + * Replace ```embed fences containing raw HTML embed code with sanitized HTML. + */ +export function processEmbedEmbeds(text: string): string { + const pattern = /```embed\s*\n([\s\S]*?)```/g; + return text.replace(pattern, (_, rawHtml) => { + const trimmed = rawHtml.trim(); + if (!trimmed) { + return '
Konten embed kosong.
'; + } + const sanitized = sanitizeEmbedHtml(trimmed); + if (sanitized.includes('[Konten embed ditolak')) { + return `
${sanitized.replace(/[\[\]]/g, '')}
`; + } + return sanitized; + }); +} diff --git a/routes/content_editor.py b/routes/content_editor.py index 3691779..e94a824 100644 --- a/routes/content_editor.py +++ b/routes/content_editor.py @@ -130,6 +130,8 @@ def save_draft(): @content_editor_bp.route("/preview", methods=["POST"]) def preview(): + # DEPRECATED: SvelteKit frontend now renders markdown preview locally. + # Kept here as a fallback and for verification/testing. db = SessionLocal() try: user, err = _require_teacher(db)