Improve documentation navigation and code blocks

This commit is contained in:
2026-08-14 08:37:03 +09:00
parent e0970acdcc
commit 6653607ed3
4 changed files with 219 additions and 4 deletions
@@ -35,8 +35,10 @@ function renderNav(items, prefix = []) {
<div class={playground || frontpage ? 'layout playground-layout' : 'layout'}> <div class={playground || frontpage ? 'layout playground-layout' : 'layout'}>
{!playground && !frontpage && ( {!playground && !frontpage && (
<aside class="sidebar"> <aside class="sidebar">
<a class="sidebar-title" href="/docs/">Manual</a> <div class="sidebar-content">
<nav set:html={renderNav(nav)} /> <a class="sidebar-title" href="/docs/">Manual</a>
<nav set:html={renderNav(nav)} />
</div>
</aside> </aside>
)} )}
<main class={playground ? 'playground' : frontpage ? 'frontpage' : ''}> <main class={playground ? 'playground' : frontpage ? 'frontpage' : ''}>
@@ -47,5 +49,20 @@ function renderNav(items, prefix = []) {
<span>2026 Hare</span> <span>2026 Hare</span>
<a href="https://gitea.hareworks.net/Hare/Decodal">Source</a> <a href="https://gitea.hareworks.net/Hare/Decodal">Source</a>
</footer> </footer>
<script>
const sidebar = document.querySelector('.sidebar-content');
if (sidebar instanceof HTMLElement) {
const updateStickyOffset = () => {
const stickyTop = Math.min(0, window.innerHeight - sidebar.offsetHeight);
sidebar.style.setProperty('--sidebar-sticky-top', `${stickyTop}px`);
};
const observer = new ResizeObserver(updateStickyOffset);
observer.observe(sidebar);
window.addEventListener('resize', updateStickyOffset);
updateStickyOffset();
}
</script>
</body> </body>
</html> </html>
+153
View File
@@ -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_TYPES = new Set(['String', 'Int', 'Float', 'Bool', 'Unknown']);
const DECODAL_LITERALS = new Set(['true', 'false']); 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 = { const HTML_ESCAPE = {
'&': '&amp;', '&': '&amp;',
'<': '&lt;', '<': '&lt;',
@@ -22,6 +39,9 @@ export function highlightCode(code, language = '') {
const normalized = language.toLowerCase(); const normalized = language.toLowerCase();
if (normalized === 'dcdl' || normalized === 'decodal') return highlightDecodal(code); if (normalized === 'dcdl' || normalized === 'decodal') return highlightDecodal(code);
if (normalized === 'sh' || normalized === 'bash' || normalized === 'shell') return highlightShell(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); return escapeHtml(code);
} }
@@ -173,6 +193,139 @@ function highlightShellCode(source) {
.replace(/(^|\s)(--?[A-Za-z0-9][A-Za-z0-9-]*)/g, '$1<span class="tok-operator">$2</span>'); .replace(/(^|\s)(--?[A-Za-z0-9][A-Za-z0-9-]*)/g, '$1<span class="tok-operator">$2</span>');
} }
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) { function token(kind, value) {
return `<span class="tok-${kind}">${escapeHtml(value)}</span>`; return `<span class="tok-${kind}">${escapeHtml(value)}</span>`;
} }
@@ -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">&#39;decodal-wasm&#39;/);
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">&quot;0.4&quot;/);
});
+19 -2
View File
@@ -20,6 +20,7 @@
--inline-code-text: #3730a3; --inline-code-text: #3730a3;
--editor-bg: #0f172a; --editor-bg: #0f172a;
--editor-text: #e5e7eb; --editor-text: #e5e7eb;
--code-outline: #475569;
--danger: #b91c1c; --danger: #b91c1c;
--error: #fecaca; --error: #fecaca;
--selection: rgb(59 130 246 / 0.35); --selection: rgb(59 130 246 / 0.35);
@@ -45,6 +46,7 @@
--shadow: 0 8px 24px rgb(0 0 0 / 0.25); --shadow: 0 8px 24px rgb(0 0 0 / 0.25);
--inline-code-bg: #1e293b; --inline-code-bg: #1e293b;
--inline-code-text: #bfdbfe; --inline-code-text: #bfdbfe;
--code-outline: #64748b;
--danger: #fca5a5; --danger: #fca5a5;
} }
} }
@@ -52,7 +54,10 @@
body { body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
display: flex;
flex-direction: column;
margin: 0; margin: 0;
min-height: 100vh;
} }
a { a {
@@ -69,6 +74,7 @@ a:hover {
background: var(--topbar-bg); background: var(--topbar-bg);
color: var(--topbar-text); color: var(--topbar-text);
display: flex; display: flex;
flex: 0 0 auto;
height: 56px; height: 56px;
justify-content: space-between; justify-content: space-between;
padding: 0 24px; padding: 0 24px;
@@ -94,6 +100,7 @@ a:hover {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
color: var(--subtle); color: var(--subtle);
display: flex; display: flex;
flex: 0 0 auto;
font-size: 0.9rem; font-size: 0.9rem;
gap: 14px; gap: 14px;
justify-content: center; justify-content: center;
@@ -102,8 +109,9 @@ a:hover {
.layout { .layout {
display: grid; display: grid;
flex: 1 0 auto;
grid-template-columns: 300px minmax(0, 1fr); grid-template-columns: 300px minmax(0, 1fr);
min-height: calc(100vh - 104px); min-height: 0;
} }
.playground-layout { .playground-layout {
@@ -113,8 +121,12 @@ a:hover {
.sidebar { .sidebar {
background: var(--surface); background: var(--surface);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
overflow: auto; }
.sidebar-content {
position: sticky;
padding: 22px 18px; padding: 22px 18px;
top: var(--sidebar-sticky-top, 0);
} }
.sidebar-title { .sidebar-title {
@@ -188,6 +200,7 @@ main.playground {
.markdown pre, .markdown pre,
.output-pane pre { .output-pane pre {
background: var(--editor-bg); background: var(--editor-bg);
border: 1px solid var(--code-outline);
border-radius: 10px; border-radius: 10px;
color: var(--editor-text); color: var(--editor-text);
overflow: auto; overflow: auto;
@@ -632,6 +645,10 @@ button:disabled {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
border-right: 0; border-right: 0;
max-height: 260px; max-height: 260px;
overflow: auto;
}
.sidebar-content {
position: static;
} }
.playground-shell, .playground-shell,
.home-grid, .home-grid,