feat: migrate markdown preview rendering to client-side SvelteKit

This commit is contained in:
a2nr 2026-08-26 19:32:28 +07:00
parent db4d15ba50
commit bb04245d85
10 changed files with 887 additions and 5 deletions

View File

@ -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<string | null>(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;
}

View File

@ -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<PreviewResponse> {
const res = await fetch('/api/content/preview', {
method: 'POST',

View File

@ -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;
}

View File

@ -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];
}

View File

@ -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'
};
}
}

View File

@ -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<div id="slide-mount-point"></div>\nAfter slides');
expect(slides).toHaveLength(2);
expect(slides[0]).toContain('<h1>Slide 1</h1>');
expect(slides[1]).toContain('<h1>Slide 2</h1>');
});
});
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('<h1>My Lesson</h1>');
expect(res.lesson_content).toContain('<p>This is the theory.</p>');
expect(res.exercise_content).toContain('<p>This is the exercise.</p>');
});
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');
});
});
});

View File

@ -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<string>;
is_correct: boolean;
}
export interface QuizCard {
id: string;
type: 'mcq' | 'flashcard';
question?: string | Promise<string>;
front?: string | Promise<string>;
back?: string | Promise<string>;
options: QuizOption[] | null;
explanation: string | Promise<string>;
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;
}

View File

@ -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) +
'<div id="slide-mount-point"></div>' +
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
};
}

View File

@ -0,0 +1,236 @@
/**
* Escape HTML characters to prevent XSS in data embeds.
*/
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 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 (
`<div class="circuit-embed" ` +
`data-width="${escapeHtml(width)}" ` +
`data-height="${escapeHtml(height)}">` +
`<pre class="circuit-data" style="display:none">${escapedData}</pre>` +
`<div class="circuit-embed-loading">Memuat simulator...</div>` +
`</div>`
);
});
}
/**
* 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 (
`<div class="flowchart-embed" ` +
`data-width="${escapeHtml(width)}" ` +
`data-height="${escapeHtml(height)}">` +
`<pre class="flowchart-data" style="display:none">${escapedData}</pre>` +
`<div class="flowchart-embed-loading">Memuat flowchart...</div>` +
`</div>`
);
});
}
/**
* 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<string, string[]> = {
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 '<div class="embed-error">Konten embed kosong.</div>';
}
const sanitized = sanitizeEmbedHtml(trimmed);
if (sanitized.includes('[Konten embed ditolak')) {
return `<div class="embed-error">${sanitized.replace(/[\[\]]/g, '')}</div>`;
}
return sanitized;
});
}

View File

@ -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)