feat: migrate markdown preview rendering to client-side SvelteKit
This commit is contained in:
parent
db4d15ba50
commit
bb04245d85
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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  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;
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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(/'/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 (
|
||||
`<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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue