From d91eec397305d6ee62a22c6b3e40f5e04eb37db0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 5 May 2026 23:43:45 +0100 Subject: [PATCH] docs: add syntax highlighting --- scripts/build-docs-site.mjs | 85 +++++++++++++++++++++++++++++++++++- scripts/docs-site-assets.mjs | 12 ++++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/scripts/build-docs-site.mjs b/scripts/build-docs-site.mjs index 4536f95..6511d17 100644 --- a/scripts/build-docs-site.mjs +++ b/scripts/build-docs-site.mjs @@ -219,7 +219,7 @@ function markdownToHtml(markdown, currentRel) { closeList(); flushBlockquote(); if (fence) { - html.push(`
${escapeHtml(fence.lines.join("\n"))}
`); + html.push(`
${highlightCode(fence.lines.join("\n"), fence.lang)}
`); fence = null; } else { fence = { lang: fenceMatch[1] || "text", lines: [] }; @@ -304,6 +304,89 @@ function markdownToHtml(markdown, currentRel) { return html.join("\n"); } +function highlightCode(code, lang) { + const normalized = String(lang || "text").toLowerCase(); + if (["bash", "sh", "shell", "zsh"].includes(normalized)) return highlightBash(code); + if (normalized === "json") return highlightJSON(code); + if (normalized === "toml") return highlightConfig(code, "toml"); + if (["yaml", "yml"].includes(normalized)) return highlightConfig(code, "yaml"); + if (normalized === "cron") return highlightCron(code); + return escapeHtml(code); +} + +function highlightBash(code) { + return code.split("\n").map((line) => { + if (/^\s*#/.test(line)) return span("comment", line); + return highlightSegments(line, /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`[^`]*`|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|--?[A-Za-z0-9][A-Za-z0-9_-]*|\b(?:brew|case|cd|curl|do|done|else|esac|export|fi|for|gh|git|gitcrawl|go|if|in|jq|ln|local|mkdir|set|then|while)\b|#.*)/g, (token) => { + if (token.startsWith("#")) return span("comment", token); + if (/^["'`]/.test(token)) return span("string", token); + if (token.startsWith("$")) return span("variable", token); + if (token.startsWith("-")) return span("option", token); + return span("keyword", token); + }); + }).join("\n"); +} + +function highlightJSON(code) { + return highlightSegments(code, /("(?:\\.|[^"\\])*"\s*:)|("(?:\\.|[^"\\])*")|\b(?:true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g, (token) => { + if (token.endsWith(":")) return `${span("key", token.slice(0, -1))}:`; + if (token.startsWith('"')) return span("string", token); + if (/^(?:true|false|null)$/.test(token)) return span("literal", token); + return span("number", token); + }); +} + +function highlightConfig(code, lang) { + return code.split("\n").map((line) => { + if (/^\s*#/.test(line)) return span("comment", line); + const commentMatch = line.match(/(^|[^"'])#.*/); + const commentStart = commentMatch ? commentMatch.index + commentMatch[1].length : -1; + const body = commentStart >= 0 ? line.slice(0, commentStart) : line; + const comment = commentStart >= 0 ? line.slice(commentStart) : ""; + const highlighted = lang === "toml" + ? highlightSegments(body, /(^\s*[A-Za-z0-9_.-]+(?=\s*=))|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|\b(?:true|false)\b|-?\b\d+(?:\.\d+)?\b/g, configToken) + : highlightSegments(body, /(^\s*[A-Za-z0-9_.-]+(?=\s*:))|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|\b(?:true|false|null)\b|-?\b\d+(?:\.\d+)?\b/g, configToken); + return highlighted + (comment ? span("comment", comment) : ""); + }).join("\n"); +} + +function configToken(token) { + if (/^\s*[A-Za-z0-9_.-]+$/.test(token)) { + const leading = token.match(/^\s*/)[0]; + return `${escapeHtml(leading)}${span("key", token.slice(leading.length))}`; + } + if (/^["']/.test(token)) return span("string", token); + if (/^(?:true|false|null)$/.test(token)) return span("literal", token); + return span("number", token); +} + +function highlightCron(code) { + return code.split("\n").map((line) => { + if (/^\s*#/.test(line)) return span("comment", line); + return highlightSegments(line, /(\*|(?:\d+)(?:[-/,]\d+)*)|("[^"]*"|'[^']*')|#.*|\b[A-Z_][A-Z0-9_]*=/g, (token) => { + if (token.startsWith("#")) return span("comment", token); + if (/^["']/.test(token)) return span("string", token); + if (token.endsWith("=")) return span("key", token.slice(0, -1)) + "="; + return span("number", token); + }); + }).join("\n"); +} + +function highlightSegments(text, pattern, classify) { + let out = ""; + let last = 0; + for (const match of text.matchAll(pattern)) { + out += escapeHtml(text.slice(last, match.index)); + out += classify(match[0]); + last = match.index + match[0].length; + } + return out + escapeHtml(text.slice(last)); +} + +function span(kind, value) { + return `${escapeHtml(value)}`; +} + function inline(text, currentRel) { const stash = []; let out = text.replace(/`([^`]+)`/g, (_, code) => { diff --git a/scripts/docs-site-assets.mjs b/scripts/docs-site-assets.mjs index 78feefd..e557bd5 100644 --- a/scripts/docs-site-assets.mjs +++ b/scripts/docs-site-assets.mjs @@ -1,7 +1,7 @@ export function css() { return ` -:root{--ink:#0f1115;--text:#1f2328;--text-soft:#3b4147;--muted:#6b7280;--subtle:#9aa1ab;--bg:#fafafa;--paper:#ffffff;--accent:#2563eb;--accent-strong:#1d4ed8;--accent-soft:rgba(37,99,235,.08);--line:#e5e7eb;--line-soft:#eef0f3;--branch:#d0d7de;--code-bg:#0f172a;--code-fg:#e6edf3;--code-border:#1f2937;--code-scroll:#334155;--shadow:rgba(15,17,21,.08);--shadow-strong:rgba(15,17,21,.18);--tag-bg:#ddf4ff;--tag-fg:#0969da;--ring:rgba(37,99,235,.32);color-scheme:light} -[data-theme="dark"]{--ink:#e6edf3;--text:#c9d1d9;--text-soft:#8b949e;--muted:#8b949e;--subtle:#6e7681;--bg:#0d1117;--paper:#161b22;--accent:#58a6ff;--accent-strong:#79b8ff;--accent-soft:rgba(56,139,253,.16);--line:#30363d;--line-soft:#21262d;--branch:#30363d;--code-bg:#010409;--code-fg:#e6edf3;--code-border:#21262d;--code-scroll:#30363d;--shadow:rgba(0,0,0,.5);--shadow-strong:rgba(0,0,0,.7);--tag-bg:rgba(56,139,253,.16);--tag-fg:#58a6ff;--ring:rgba(56,139,253,.4);color-scheme:dark} +:root{--ink:#0f1115;--text:#1f2328;--text-soft:#3b4147;--muted:#6b7280;--subtle:#9aa1ab;--bg:#fafafa;--paper:#ffffff;--accent:#2563eb;--accent-strong:#1d4ed8;--accent-soft:rgba(37,99,235,.08);--line:#e5e7eb;--line-soft:#eef0f3;--branch:#d0d7de;--code-bg:#0f172a;--code-fg:#e6edf3;--code-border:#1f2937;--code-scroll:#334155;--hl-comment:#94a3b8;--hl-keyword:#93c5fd;--hl-string:#86efac;--hl-number:#fbbf24;--hl-literal:#c4b5fd;--hl-key:#67e8f9;--hl-variable:#f0abfc;--hl-option:#fda4af;--shadow:rgba(15,17,21,.08);--shadow-strong:rgba(15,17,21,.18);--tag-bg:#ddf4ff;--tag-fg:#0969da;--ring:rgba(37,99,235,.32);color-scheme:light} +[data-theme="dark"]{--ink:#e6edf3;--text:#c9d1d9;--text-soft:#8b949e;--muted:#8b949e;--subtle:#6e7681;--bg:#0d1117;--paper:#161b22;--accent:#58a6ff;--accent-strong:#79b8ff;--accent-soft:rgba(56,139,253,.16);--line:#30363d;--line-soft:#21262d;--branch:#30363d;--code-bg:#010409;--code-fg:#e6edf3;--code-border:#21262d;--code-scroll:#30363d;--hl-comment:#8b949e;--hl-keyword:#79c0ff;--hl-string:#a5d6ff;--hl-number:#ffa657;--hl-literal:#d2a8ff;--hl-key:#7ee787;--hl-variable:#ff7b72;--hl-option:#f2cc60;--shadow:rgba(0,0,0,.5);--shadow-strong:rgba(0,0,0,.7);--tag-bg:rgba(56,139,253,.16);--tag-fg:#58a6ff;--ring:rgba(56,139,253,.4);color-scheme:dark} *{box-sizing:border-box} html{scroll-behavior:smooth;scroll-padding-top:24px} body{margin:0;background:var(--bg);color:var(--text);font-family:"Inter",ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;line-height:1.65;overflow-x:hidden;-webkit-font-smoothing:antialiased;font-feature-settings:"cv02","cv03","cv04","cv11"} @@ -80,6 +80,14 @@ body:not(.home) .doc>h1:first-child{display:none} .doc pre::-webkit-scrollbar{height:8px;width:8px} .doc pre::-webkit-scrollbar-thumb{background:var(--code-scroll);border-radius:8px} .doc pre code{display:block;background:transparent;border:0;color:inherit;padding:0;font-size:1em;white-space:pre} +.doc pre .hl-comment{color:var(--hl-comment);font-style:italic} +.doc pre .hl-keyword{color:var(--hl-keyword);font-weight:500} +.doc pre .hl-string{color:var(--hl-string)} +.doc pre .hl-number{color:var(--hl-number)} +.doc pre .hl-literal{color:var(--hl-literal);font-weight:500} +.doc pre .hl-key{color:var(--hl-key)} +.doc pre .hl-variable{color:var(--hl-variable)} +.doc pre .hl-option{color:var(--hl-option)} .doc pre .copy{position:absolute;top:8px;right:8px;background:rgba(255,255,255,.06);color:var(--code-fg);border:1px solid rgba(255,255,255,.16);border-radius:6px;padding:3px 9px;font:500 .7rem/1 "Inter",sans-serif;cursor:pointer;opacity:0;transition:opacity .15s,background .15s,border-color .15s} .doc pre:hover .copy,.doc pre .copy:focus{opacity:1} .doc pre .copy:hover{background:rgba(255,255,255,.12)}