Improve documentation navigation and code blocks
This commit is contained in:
@@ -35,8 +35,10 @@ function renderNav(items, prefix = []) {
|
||||
<div class={playground || frontpage ? 'layout playground-layout' : 'layout'}>
|
||||
{!playground && !frontpage && (
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-content">
|
||||
<a class="sidebar-title" href="/docs/">Manual</a>
|
||||
<nav set:html={renderNav(nav)} />
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
<main class={playground ? 'playground' : frontpage ? 'frontpage' : ''}>
|
||||
@@ -47,5 +49,20 @@ function renderNav(items, prefix = []) {
|
||||
<span>2026 Hare</span>
|
||||
<a href="https://gitea.hareworks.net/Hare/Decodal">Source</a>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -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<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) {
|
||||
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">'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"/);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user