feat: use micron renderer
This commit is contained in:
38
frontend/package-lock.json
generated
38
frontend/package-lock.json
generated
@@ -23,7 +23,9 @@
|
|||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
"micron-parser": "^1.0.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -2182,6 +2185,16 @@
|
|||||||
"@types/d3-selection": "*"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2232,6 +2245,13 @@
|
|||||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/validate-npm-package-name": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
"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": ">=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": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.3.1",
|
"version": "17.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||||
@@ -5344,6 +5373,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.54.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
"micron-parser": "^1.0.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -40,6 +42,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ export default function PreviewPane() {
|
|||||||
{previewMode === "micron" ? (
|
{previewMode === "micron" ? (
|
||||||
compiledMicron ? (
|
compiledMicron ? (
|
||||||
<div
|
<div
|
||||||
className="p-2 font-mono text-[11px] whitespace-pre leading-tight"
|
className="p-2 font-mono text-[11px] leading-tight"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: renderMicron(compiledMicron),
|
__html: renderMicron(compiledMicron, true),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,243 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Micron markup → HTML renderer for the editor preview pane.
|
* Micron markup → HTML renderer using the micron-parser library.
|
||||||
* Spec: https://github.com/fr33n0w/micron-composer
|
* Reference: https://github.com/RFnexus/micron-parser-js
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
import MicronParser from "micron-parser";
|
||||||
return text
|
|
||||||
.replace(/&/g, "&")
|
let darkParser: MicronParser | null = null;
|
||||||
.replace(/</g, "<")
|
let lightParser: MicronParser | null = null;
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """);
|
export function renderMicron(source: string, darkTheme: boolean = true): string {
|
||||||
}
|
if (darkTheme) {
|
||||||
|
if (!darkParser) darkParser = new MicronParser(true, true);
|
||||||
/** Render inline Micron formatting codes within a line of text. */
|
return darkParser.convertMicronToHtml(source);
|
||||||
function renderInline(raw: string): string {
|
} else {
|
||||||
let out = "";
|
if (!lightParser) lightParser = new MicronParser(false, true);
|
||||||
let i = 0;
|
return lightParser.convertMicronToHtml(source);
|
||||||
|
}
|
||||||
// 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 += "</strong>"; boldOpen = false; }
|
|
||||||
else { out += "<strong>"; boldOpen = true; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "*") {
|
|
||||||
if (italicOpen) { out += "</em>"; italicOpen = false; }
|
|
||||||
else { out += "<em>"; italicOpen = true; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "_") {
|
|
||||||
if (underOpen) { out += "</u>"; underOpen = false; }
|
|
||||||
else { out += "<u>"; underOpen = true; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "`") {
|
|
||||||
// Reset all
|
|
||||||
if (boldOpen) { out += "</strong>"; boldOpen = false; }
|
|
||||||
if (italicOpen) { out += "</em>"; italicOpen = false; }
|
|
||||||
if (underOpen) { out += "</u>"; underOpen = false; }
|
|
||||||
if (fgOpen) { out += "</span>"; fgOpen = false; }
|
|
||||||
if (bgOpen) { out += "</span>"; bgOpen = false; }
|
|
||||||
if (alignOpen) { out += "</span>"; alignOpen = false; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "f") {
|
|
||||||
if (fgOpen) { out += "</span>"; fgOpen = false; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "b") {
|
|
||||||
if (bgOpen) { out += "</span>"; bgOpen = false; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "a") {
|
|
||||||
if (alignOpen) { out += "</span>"; 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 += "</span>"; }
|
|
||||||
const [r, g, b] = hexMatch[1].split("");
|
|
||||||
const hex = r + r + g + g + b + b;
|
|
||||||
out += `<span style="color:#${hex}">`;
|
|
||||||
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 += "</span>"; }
|
|
||||||
const [r, g, b] = hexMatch[1].split("");
|
|
||||||
const hex = r + r + g + g + b + b;
|
|
||||||
out += `<span style="background:#${hex}">`;
|
|
||||||
bgOpen = true;
|
|
||||||
i += 2 + hexMatch[1].length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else if (code === "c") {
|
|
||||||
out += `<span style="display:block;text-align:center">`;
|
|
||||||
alignOpen = true; i += 2; continue;
|
|
||||||
} else if (code === "r") {
|
|
||||||
out += `<span style="display:block;text-align:right">`;
|
|
||||||
alignOpen = true; i += 2; continue;
|
|
||||||
} else if (code === "l") {
|
|
||||||
out += `<span style="display:block;text-align:left">`;
|
|
||||||
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 += `<a href="${escapeHtml(dest)}" style="color:#7dc4e4;text-decoration:underline">${escapeHtml(label)}</a>`;
|
|
||||||
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 += `<a href="/view/${slug}" style="color:#7dc4e4;text-decoration:underline">${label}</a>`;
|
|
||||||
i = close + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out += escapeHtml(raw[i]);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close any remaining open tags
|
|
||||||
if (boldOpen) out += "</strong>";
|
|
||||||
if (italicOpen) out += "</em>";
|
|
||||||
if (underOpen) out += "</u>";
|
|
||||||
if (fgOpen) out += "</span>";
|
|
||||||
if (bgOpen) out += "</span>";
|
|
||||||
if (alignOpen) out += "</span>";
|
|
||||||
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 `<span style="color:#d4a8f8">${box} ${esc(label)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 `<span style="color:#d4a8f8">${dot} ${esc(label)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inner.startsWith("!")) {
|
|
||||||
// Password field
|
|
||||||
const backtick = inner.indexOf("`");
|
|
||||||
const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : "";
|
|
||||||
return `<span style="color:#d4a8f8;border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px">🔒 ${esc(placeholder || "••••••")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular text field: width|name`placeholder or name`placeholder
|
|
||||||
const backtick = inner.indexOf("`");
|
|
||||||
const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : "";
|
|
||||||
return `<span style="color:#d4a8f8;border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px">${esc(placeholder || "...")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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(`<div style="font-family:monospace;opacity:0.75;white-space:pre">${escapeHtml(line)}</div>`);
|
|
||||||
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(`<span style="color:#c9d1d9;font-style:italic">${renderInline(line.slice(4))}</span>`);
|
|
||||||
// Headings
|
|
||||||
} else if (line.startsWith(">>>")) {
|
|
||||||
htmlLines.push(`<span style="color:#a8c4e8;font-weight:bold">${renderInline(line.slice(3))}</span>`);
|
|
||||||
} else if (line.startsWith(">>")) {
|
|
||||||
htmlLines.push(`<span style="color:#70c4e8;font-weight:bold">${renderInline(line.slice(2))}</span>`);
|
|
||||||
} else if (line.startsWith(">")) {
|
|
||||||
htmlLines.push(`<span style="color:#7ee8a2;font-weight:bold">${renderInline(line.slice(1))}</span>`);
|
|
||||||
// Dividers: - followed by a non-space, non-dash character
|
|
||||||
} else if (/^-[^\s-]/.test(line)) {
|
|
||||||
const char = line[1];
|
|
||||||
htmlLines.push(`<span style="color:#484f58">${char.repeat(40)}</span>`);
|
|
||||||
// Standalone depth-reset "<"
|
|
||||||
} else if (line.trim() === "<") {
|
|
||||||
htmlLines.push(`<span style="color:#d2a8ff;opacity:0.4">↩ depth reset</span>`);
|
|
||||||
// Empty line
|
|
||||||
} else if (line.trim() === "") {
|
|
||||||
htmlLines.push("");
|
|
||||||
} else {
|
|
||||||
htmlLines.push(renderInline(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return htmlLines.join("\n");
|
|
||||||
}
|
}
|
||||||
|
|||||||
7
frontend/src/types/micron-parser.d.ts
vendored
Normal file
7
frontend/src/types/micron-parser.d.ts
vendored
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user