diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7768d26..92fd4ff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,9 @@ "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.3.3", "lucide-react": "^1.7.0", + "micron-parser": "^1.0.3", "next-themes": "^0.4.6", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -38,6 +40,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/dompurify": "^3.0.5", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -2182,6 +2185,16 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2232,6 +2245,13 @@ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -3498,6 +3518,15 @@ "node": ">=0.3.1" } }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "17.3.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", @@ -5344,6 +5373,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/micron-parser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/micron-parser/-/micron-parser-1.0.3.tgz", + "integrity": "sha512-6BKZa6eoS2AeFkjfQ5pZp7qoFNhI/N/3cZAiD9gp7bVkwjRi2+Wc7SV7np6DL9hkOmy+BGBcysbX86ygYRYhKg==", + "license": "MIT", + "dependencies": { + "dompurify": "*" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 63f430f..03b89c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,7 +25,9 @@ "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.3.3", "lucide-react": "^1.7.0", + "micron-parser": "^1.0.3", "next-themes": "^0.4.6", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -40,6 +42,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@types/dompurify": "^3.0.5", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/components/editor/PreviewPane.tsx b/frontend/src/components/editor/PreviewPane.tsx index d1301ca..7e0a8e9 100644 --- a/frontend/src/components/editor/PreviewPane.tsx +++ b/frontend/src/components/editor/PreviewPane.tsx @@ -61,9 +61,9 @@ export default function PreviewPane() { {previewMode === "micron" ? ( compiledMicron ? (
) : ( diff --git a/frontend/src/components/editor/micronRenderer.ts b/frontend/src/components/editor/micronRenderer.ts index 463a049..5bba110 100644 --- a/frontend/src/components/editor/micronRenderer.ts +++ b/frontend/src/components/editor/micronRenderer.ts @@ -1,243 +1,19 @@ /** - * Micron markup → HTML renderer for the editor preview pane. - * Spec: https://github.com/fr33n0w/micron-composer + * Micron markup → HTML renderer using the micron-parser library. + * Reference: https://github.com/RFnexus/micron-parser-js */ -function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -/** Render inline Micron formatting codes within a line of text. */ -function renderInline(raw: string): string { - let out = ""; - let i = 0; - - // Track open/close state for toggle-style tags - let boldOpen = false; - let italicOpen = false; - let underOpen = false; - let fgOpen = false; - let bgOpen = false; - let alignOpen = false; - - while (i < raw.length) { - // Backtick formatting codes - if (raw[i] === "`") { - const code = raw[i + 1]; - if (code === "!") { - if (boldOpen) { out += ""; boldOpen = false; } - else { out += ""; boldOpen = true; } - i += 2; continue; - } else if (code === "*") { - if (italicOpen) { out += ""; italicOpen = false; } - else { out += ""; italicOpen = true; } - i += 2; continue; - } else if (code === "_") { - if (underOpen) { out += ""; underOpen = false; } - else { out += ""; underOpen = true; } - i += 2; continue; - } else if (code === "`") { - // Reset all - if (boldOpen) { out += ""; boldOpen = false; } - if (italicOpen) { out += ""; italicOpen = false; } - if (underOpen) { out += ""; underOpen = false; } - if (fgOpen) { out += ""; fgOpen = false; } - if (bgOpen) { out += ""; bgOpen = false; } - if (alignOpen) { out += ""; alignOpen = false; } - i += 2; continue; - } else if (code === "f") { - if (fgOpen) { out += ""; fgOpen = false; } - i += 2; continue; - } else if (code === "b") { - if (bgOpen) { out += ""; bgOpen = false; } - i += 2; continue; - } else if (code === "a") { - if (alignOpen) { out += ""; alignOpen = false; } - i += 2; continue; - } else if (code === "F") { - // Foreground color — 3-digit hex only per spec - const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})/); - if (hexMatch) { - if (fgOpen) { out += ""; } - const [r, g, b] = hexMatch[1].split(""); - const hex = r + r + g + g + b + b; - out += ``; - fgOpen = true; - i += 2 + hexMatch[1].length; - continue; - } - } else if (code === "B") { - // Background color — 3-digit hex only per spec - const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})/); - if (hexMatch) { - if (bgOpen) { out += ""; } - const [r, g, b] = hexMatch[1].split(""); - const hex = r + r + g + g + b + b; - out += ``; - bgOpen = true; - i += 2 + hexMatch[1].length; - continue; - } - } else if (code === "c") { - out += ``; - alignOpen = true; i += 2; continue; - } else if (code === "r") { - out += ``; - alignOpen = true; i += 2; continue; - } else if (code === "l") { - out += ``; - alignOpen = true; i += 2; continue; - } else if (code === "<") { - // Form element: `<...> or ` or ` or `<^...> - const closeAngle = raw.indexOf(">", i + 2); - if (closeAngle !== -1) { - const inner = raw.slice(i + 2, closeAngle); - out += renderFormTag(inner); - i = closeAngle + 1; - continue; - } - } else if (code === "[") { - // Link with inline formatting: `[`!Label`!`:/dest] - const closeBracket = raw.indexOf("]", i + 2); - if (closeBracket !== -1) { - const inner = raw.slice(i + 2, closeBracket); - const colonIdx = inner.indexOf("`:"); - if (colonIdx !== -1) { - const labelRaw = inner.slice(0, colonIdx); - const dest = inner.slice(colonIdx + 2); - // Strip formatting tags from label for display - const label = labelRaw.replace(/`[!*_]/g, ""); - out += `${escapeHtml(label)}`; - i = closeBracket + 1; - continue; - } - } - } - } - - // Links: [label`slug] or [label`slug.mu] - if (raw[i] === "[") { - const close = raw.indexOf("]", i); - if (close !== -1) { - const inner = raw.slice(i + 1, close); - const backtick = inner.indexOf("`"); - if (backtick !== -1) { - const label = escapeHtml(inner.slice(0, backtick)); - const slug = escapeHtml(inner.slice(backtick + 1).replace(/\.mu$/, "")); - out += `${label}`; - i = close + 1; - continue; - } - } - } - - out += escapeHtml(raw[i]); - i++; - } - - // Close any remaining open tags - if (boldOpen) out += ""; - if (italicOpen) out += ""; - if (underOpen) out += ""; - if (fgOpen) out += ""; - if (bgOpen) out += ""; - if (alignOpen) out += ""; - return out; -} - -/** Render a Micron form tag `<...> as styled HTML. */ -function renderFormTag(inner: string): string { - const esc = escapeHtml; - - // Text field: width|name`placeholder or name`placeholder - // Password: !width|name`placeholder - // Checkbox: ?|name|value`label or ?|name|value|*`label - // Radio: ^|group|value`label or ^|group|value|*`label - - if (inner.startsWith("?")) { - // Checkbox - const backtick = inner.indexOf("`"); - const label = backtick !== -1 ? inner.slice(backtick + 1) : ""; - const checked = inner.includes("|*"); - const box = checked ? "☑" : "☐"; - return `${box} ${esc(label)}`; - } - - if (inner.startsWith("^")) { - // Radio button - const backtick = inner.indexOf("`"); - const label = backtick !== -1 ? inner.slice(backtick + 1) : ""; - const selected = inner.includes("|*"); - const dot = selected ? "◉" : "○"; - return `${dot} ${esc(label)}`; - } - - if (inner.startsWith("!")) { - // Password field - const backtick = inner.indexOf("`"); - const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : ""; - return `🔒 ${esc(placeholder || "••••••")}`; - } - - // Regular text field: width|name`placeholder or name`placeholder - const backtick = inner.indexOf("`"); - const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : ""; - return `${esc(placeholder || "...")}`; -} - - -export function renderMicron(source: string): string { - const lines = source.split("\n"); - const htmlLines: string[] = []; - let literalMode = false; - - for (const line of lines) { - // Toggle literal mode on standalone `= line - if (line.trimEnd() === "`=") { - literalMode = !literalMode; - continue; - } - - // In literal mode — render verbatim - if (literalMode) { - htmlLines.push(`
${escapeHtml(line)}
`); - continue; - } - - // Comment lines — hidden in output - if (line.startsWith("#")) continue; - - // All output uses inline spans — the parent container has white-space:pre - // so newlines come from the \n join at the end. - - // Depth-4+ indent (before >>> check) - if (line.startsWith(">>>>")) { - htmlLines.push(`${renderInline(line.slice(4))}`); - // Headings - } else if (line.startsWith(">>>")) { - htmlLines.push(`${renderInline(line.slice(3))}`); - } else if (line.startsWith(">>")) { - htmlLines.push(`${renderInline(line.slice(2))}`); - } else if (line.startsWith(">")) { - htmlLines.push(`${renderInline(line.slice(1))}`); - // Dividers: - followed by a non-space, non-dash character - } else if (/^-[^\s-]/.test(line)) { - const char = line[1]; - htmlLines.push(`${char.repeat(40)}`); - // Standalone depth-reset "<" - } else if (line.trim() === "<") { - htmlLines.push(`↩ depth reset`); - // Empty line - } else if (line.trim() === "") { - htmlLines.push(""); - } else { - htmlLines.push(renderInline(line)); - } - } - - return htmlLines.join("\n"); +import MicronParser from "micron-parser"; + +let darkParser: MicronParser | null = null; +let lightParser: MicronParser | null = null; + +export function renderMicron(source: string, darkTheme: boolean = true): string { + if (darkTheme) { + if (!darkParser) darkParser = new MicronParser(true, true); + return darkParser.convertMicronToHtml(source); + } else { + if (!lightParser) lightParser = new MicronParser(false, true); + return lightParser.convertMicronToHtml(source); + } } diff --git a/frontend/src/types/micron-parser.d.ts b/frontend/src/types/micron-parser.d.ts new file mode 100644 index 0000000..2961554 --- /dev/null +++ b/frontend/src/types/micron-parser.d.ts @@ -0,0 +1,7 @@ +declare module "micron-parser" { + export default class MicronParser { + constructor(darkTheme?: boolean, enableForceMonospace?: boolean); + convertMicronToHtml(markup: string): string; + convertMicronToFragment(markup: string): DocumentFragment; + } +}