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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -61,9 +61,9 @@ export default function PreviewPane() {
|
||||
{previewMode === "micron" ? (
|
||||
compiledMicron ? (
|
||||
<div
|
||||
className="p-2 font-mono text-[11px] whitespace-pre leading-tight"
|
||||
className="p-2 font-mono text-[11px] leading-tight"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMicron(compiledMicron),
|
||||
__html: renderMicron(compiledMicron, true),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
import MicronParser from "micron-parser";
|
||||
|
||||
/** Render inline Micron formatting codes within a line of text. */
|
||||
function renderInline(raw: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
let darkParser: MicronParser | null = null;
|
||||
let lightParser: MicronParser | null = null;
|
||||
|
||||
// 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("");
|
||||
export function renderMicron(source: string, darkTheme: boolean = true): string {
|
||||
if (darkTheme) {
|
||||
if (!darkParser) darkParser = new MicronParser(true, true);
|
||||
return darkParser.convertMicronToHtml(source);
|
||||
} else {
|
||||
htmlLines.push(renderInline(line));
|
||||
if (!lightParser) lightParser = new MicronParser(false, true);
|
||||
return lightParser.convertMicronToHtml(source);
|
||||
}
|
||||
}
|
||||
|
||||
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