diff --git a/site/decodal-site/src/layouts/ManualLayout.astro b/site/decodal-site/src/layouts/ManualLayout.astro index 57e4870..c89f92f 100644 --- a/site/decodal-site/src/layouts/ManualLayout.astro +++ b/site/decodal-site/src/layouts/ManualLayout.astro @@ -35,8 +35,10 @@ function renderNav(items, prefix = []) {
{!playground && !frontpage && ( )}
@@ -47,5 +49,20 @@ function renderNav(items, prefix = []) { 2026 Hare Source + diff --git a/site/decodal-site/src/lib/highlight.js b/site/decodal-site/src/lib/highlight.js index ac90641..ac15490 100644 --- a/site/decodal-site/src/lib/highlight.js +++ b/site/decodal-site/src/lib/highlight.js @@ -2,6 +2,23 @@ const DECODAL_KEYWORDS = new Set(['let', 'in', 'fn', 'match', 'import', 'default const DECODAL_TYPES = new Set(['String', 'Int', 'Float', 'Bool', 'Unknown']); const DECODAL_LITERALS = new Set(['true', 'false']); +const RUST_KEYWORDS = new Set([ + 'as', 'const', 'crate', 'dyn', 'else', 'enum', 'extern', 'fn', 'for', 'if', 'impl', + 'in', 'let', 'loop', 'match', 'mod', 'move', 'mut', 'pub', 'ref', 'return', 'self', + 'Self', 'static', 'struct', 'super', 'trait', 'type', 'unsafe', 'use', 'where', + 'while', 'async', 'await', +]); +const RUST_LITERALS = new Set(['true', 'false', 'None', 'Some', 'Ok', 'Err']); + +const JAVASCRIPT_KEYWORDS = new Set([ + 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', + 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'from', + 'function', 'get', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'of', 'return', + 'set', 'static', 'switch', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'with', + 'yield', +]); +const JAVASCRIPT_LITERALS = new Set(['true', 'false', 'null', 'undefined']); + const HTML_ESCAPE = { '&': '&', '<': '<', @@ -22,6 +39,9 @@ export function highlightCode(code, language = '') { const normalized = language.toLowerCase(); if (normalized === 'dcdl' || normalized === 'decodal') return highlightDecodal(code); if (normalized === 'sh' || normalized === 'bash' || normalized === 'shell') return highlightShell(code); + if (normalized === 'rs' || normalized === 'rust') return highlightRust(code); + if (normalized === 'js' || normalized === 'javascript') return highlightJavaScript(code); + if (normalized === 'toml') return highlightToml(code); return escapeHtml(code); } @@ -173,6 +193,139 @@ function highlightShellCode(source) { .replace(/(^|\s)(--?[A-Za-z0-9][A-Za-z0-9-]*)/g, '$1$2'); } +function highlightRust(source) { + return highlightGeneric(source, { + keywords: RUST_KEYWORDS, + literals: RUST_LITERALS, + lineComment: '//', + blockComments: true, + rawStrings: true, + typeNames: true, + }); +} + +function highlightJavaScript(source) { + return highlightGeneric(source, { + keywords: JAVASCRIPT_KEYWORDS, + literals: JAVASCRIPT_LITERALS, + lineComment: '//', + blockComments: true, + quotes: ['"', "'", '`'], + typeNames: true, + }); +} + +function highlightToml(source) { + return highlightGeneric(source, { + literals: new Set(['true', 'false']), + lineComment: '#', + classifyIdentifier(_identifier, start, end) { + const before = source.slice(0, start).trimEnd().at(-1); + const after = source.slice(end).match(/^\s*(.)/)?.[1]; + if (before === '[') return 'type'; + if (after === '=') return 'keyword'; + return ''; + }, + }); +} + +function highlightGeneric(source, options) { + const { + keywords = new Set(), + literals = new Set(), + lineComment = '', + blockComments = false, + rawStrings = false, + quotes = ['"', "'"], + typeNames = false, + classifyIdentifier, + } = options; + let html = ''; + let index = 0; + + while (index < source.length) { + if (lineComment && source.startsWith(lineComment, index)) { + const end = readUntilLineEnd(source, index); + html += token('comment', source.slice(index, end)); + index = end; + continue; + } + + if (blockComments && source.startsWith('/*', index)) { + const close = source.indexOf('*/', index + 2); + const end = close < 0 ? source.length : close + 2; + html += token('comment', source.slice(index, end)); + index = end; + continue; + } + + if (rawStrings && source[index] === 'r' && /^r#*"/.test(source.slice(index))) { + const hashes = source.slice(index + 1).match(/^#*/)?.[0] ?? ''; + const openLength = 2 + hashes.length; + const close = source.indexOf(`"${hashes}`, index + openLength); + const end = close < 0 ? source.length : close + 1 + hashes.length; + html += token('string', source.slice(index, end)); + index = end; + continue; + } + + if (quotes.includes(source[index])) { + const end = readQuoted(source, index, source[index]); + html += token('string', source.slice(index, end)); + index = end; + continue; + } + + if (/[0-9]/.test(source[index])) { + const match = source.slice(index).match(/^(?:0x[0-9a-fA-F_]+|0b[01_]+|\d[\d_]*(?:\.\d[\d_]*)?)/); + const value = match?.[0] ?? source[index]; + html += token('number', value); + index += value.length; + continue; + } + + if (/[A-Za-z_$]/.test(source[index])) { + const match = source.slice(index).match(/^[A-Za-z_$][A-Za-z0-9_$-]*/); + const identifier = match?.[0] ?? source[index]; + const end = index + identifier.length; + let kind = ''; + if (keywords.has(identifier)) kind = 'keyword'; + else if (literals.has(identifier)) kind = 'literal'; + else if (classifyIdentifier) kind = classifyIdentifier(identifier, index, end); + else if (typeNames && /^[A-Z]/.test(identifier)) kind = 'type'; + html += kind ? token(kind, identifier) : escapeHtml(identifier); + index = end; + continue; + } + + if ('&=<>!|:;,.{}()[]+-*/?@#'.includes(source[index])) { + let end = index + 1; + while (end < source.length && '&=<>!|:+-*?'.includes(source[end])) end += 1; + html += token('operator', source.slice(index, end)); + index = end; + continue; + } + + html += escapeHtml(source[index]); + index += 1; + } + + return html; +} + +function readQuoted(source, start, quote) { + let index = start + 1; + while (index < source.length) { + if (source[index] === '\\') { + index += 2; + continue; + } + if (source[index] === quote) return index + 1; + index += 1; + } + return source.length; +} + function token(kind, value) { return `${escapeHtml(value)}`; } diff --git a/site/decodal-site/src/scripts/highlight.test.mjs b/site/decodal-site/src/scripts/highlight.test.mjs new file mode 100644 index 0000000..d850791 --- /dev/null +++ b/site/decodal-site/src/scripts/highlight.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { highlightCode } from '../lib/highlight.js'; + +test('highlights Rust examples in the embedding guide', () => { + const html = highlightCode('use decodal::Engine;\nlet mut engine = Engine::new();', 'rust'); + + assert.match(html, /tok-keyword">use/); + assert.match(html, /tok-keyword">let/); + assert.match(html, /tok-type">Engine/); +}); + +test('highlights JavaScript examples in the embedding guide', () => { + const html = highlightCode("import init from 'decodal-wasm';\nconst enabled = true;", 'js'); + + assert.match(html, /tok-keyword">import/); + assert.match(html, /tok-string">'decodal-wasm'/); + assert.match(html, /tok-literal">true/); +}); + +test('highlights TOML examples in the packages guide', () => { + const html = highlightCode('[dependencies]\ndecodal = "0.4"', 'toml'); + + assert.match(html, /tok-type">dependencies/); + assert.match(html, /tok-keyword">decodal/); + assert.match(html, /tok-string">"0.4"/); +}); diff --git a/site/decodal-site/src/style.css b/site/decodal-site/src/style.css index bdb793e..f638bde 100644 --- a/site/decodal-site/src/style.css +++ b/site/decodal-site/src/style.css @@ -20,6 +20,7 @@ --inline-code-text: #3730a3; --editor-bg: #0f172a; --editor-text: #e5e7eb; + --code-outline: #475569; --danger: #b91c1c; --error: #fecaca; --selection: rgb(59 130 246 / 0.35); @@ -45,6 +46,7 @@ --shadow: 0 8px 24px rgb(0 0 0 / 0.25); --inline-code-bg: #1e293b; --inline-code-text: #bfdbfe; + --code-outline: #64748b; --danger: #fca5a5; } } @@ -52,7 +54,10 @@ body { background: var(--bg); color: var(--text); + display: flex; + flex-direction: column; margin: 0; + min-height: 100vh; } a { @@ -69,6 +74,7 @@ a:hover { background: var(--topbar-bg); color: var(--topbar-text); display: flex; + flex: 0 0 auto; height: 56px; justify-content: space-between; padding: 0 24px; @@ -94,6 +100,7 @@ a:hover { border-top: 1px solid var(--border); color: var(--subtle); display: flex; + flex: 0 0 auto; font-size: 0.9rem; gap: 14px; justify-content: center; @@ -102,8 +109,9 @@ a:hover { .layout { display: grid; + flex: 1 0 auto; grid-template-columns: 300px minmax(0, 1fr); - min-height: calc(100vh - 104px); + min-height: 0; } .playground-layout { @@ -113,8 +121,12 @@ a:hover { .sidebar { background: var(--surface); border-right: 1px solid var(--border); - overflow: auto; +} + +.sidebar-content { + position: sticky; padding: 22px 18px; + top: var(--sidebar-sticky-top, 0); } .sidebar-title { @@ -188,6 +200,7 @@ main.playground { .markdown pre, .output-pane pre { background: var(--editor-bg); + border: 1px solid var(--code-outline); border-radius: 10px; color: var(--editor-text); overflow: auto; @@ -632,6 +645,10 @@ button:disabled { border-bottom: 1px solid var(--border); border-right: 0; max-height: 260px; + overflow: auto; + } + .sidebar-content { + position: static; } .playground-shell, .home-grid,