Add site syntax highlighting

This commit is contained in:
Keisuke Hirata 2026-06-17 07:39:00 +09:00
parent 0e873fbd51
commit 19c9de1601
No known key found for this signature in database
5 changed files with 295 additions and 8 deletions

View File

@ -1,4 +1,5 @@
import { marked } from 'marked';
import { escapeAttribute, escapeHtml, highlightCode } from './highlight.js';
const modules = import.meta.glob('../../../../doc/manual/souce/**/*.md', {
query: '?raw',
@ -79,7 +80,17 @@ export const nav = [
{ title: 'Open Issues', slug: 'open-issues' },
];
marked.setOptions({ gfm: true });
const renderer = new marked.Renderer();
renderer.code = (code, language = '') => {
const normalizedLanguage = language.split(/\s+/)[0] ?? '';
const className = normalizedLanguage ? ` class="language-${escapeAttribute(normalizedLanguage)}"` : '';
return `<pre class="code-block"><code${className}>${highlightCode(code, normalizedLanguage)}</code></pre>`;
};
renderer.codespan = (code) => `<code>${escapeHtml(code)}</code>`;
marked.setOptions({ gfm: true, renderer });
export function allDocSlugs() {
return Object.keys(docs).filter((slug) => slug !== 'index');

View File

@ -0,0 +1,196 @@
const DECODAL_KEYWORDS = new Set(['let', 'in', 'fn', 'match', 'import', 'default']);
const DECODAL_TYPES = new Set(['String', 'Int', 'Float', 'Bool']);
const DECODAL_LITERALS = new Set(['true', 'false']);
const HTML_ESCAPE = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
export function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (char) => HTML_ESCAPE[char]);
}
export function escapeAttribute(value) {
return escapeHtml(value).replace(/`/g, '&#96;');
}
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);
return escapeHtml(code);
}
export function highlightDecodal(source) {
let html = '';
let index = 0;
while (index < source.length) {
const char = source[index];
const next = source[index + 1];
if (char === '/' && next === '/') {
const end = readUntilLineEnd(source, index);
html += token('comment', source.slice(index, end));
index = end;
continue;
}
if (char === '"') {
const end = readString(source, index);
html += token('string', source.slice(index, end));
index = end;
continue;
}
if (char === '/' && next && next !== '/') {
const end = readRegex(source, index);
html += token('regex', source.slice(index, end));
index = end;
continue;
}
if (isNumberStart(source, index)) {
const end = readNumber(source, index);
html += token('number', source.slice(index, end));
index = end;
continue;
}
if (isIdentifierStart(char)) {
const end = readIdentifier(source, index);
const ident = source.slice(index, end);
if (DECODAL_KEYWORDS.has(ident)) html += token('keyword', ident);
else if (DECODAL_TYPES.has(ident)) html += token('type', ident);
else if (DECODAL_LITERALS.has(ident)) html += token('literal', ident);
else html += escapeHtml(ident);
index = end;
continue;
}
if (isOperatorStart(char)) {
const end = readOperator(source, index);
html += token('operator', source.slice(index, end));
index = end;
continue;
}
html += escapeHtml(char);
index += 1;
}
return html;
}
function highlightShell(source) {
return source
.split(/(\n)/)
.map((line) => {
if (line === '\n') return line;
const commentIndex = line.indexOf('#');
const code = commentIndex >= 0 ? line.slice(0, commentIndex) : line;
const comment = commentIndex >= 0 ? line.slice(commentIndex) : '';
return `${highlightShellCode(code)}${comment ? token('comment', comment) : ''}`;
})
.join('');
}
function highlightShellCode(source) {
const escaped = escapeHtml(source);
return escaped
.replace(/(&quot;[^&]*(?:&(?!quot;)[^&]*)*&quot;|'[^']*')/g, '<span class="tok-string">$1</span>')
.replace(/(^|\s)(--?[A-Za-z0-9][A-Za-z0-9-]*)/g, '$1<span class="tok-operator">$2</span>');
}
function token(kind, value) {
return `<span class="tok-${kind}">${escapeHtml(value)}</span>`;
}
function readUntilLineEnd(source, start) {
const end = source.indexOf('\n', start);
return end < 0 ? source.length : end;
}
function readString(source, start) {
let index = start + 1;
while (index < source.length) {
if (source[index] === '\\') {
index += 2;
continue;
}
if (source[index] === '"') return index + 1;
index += 1;
}
return source.length;
}
function readRegex(source, start) {
let index = start + 1;
let inClass = false;
while (index < source.length) {
const char = source[index];
if (char === '\\') {
index += 2;
continue;
}
if (char === '[') inClass = true;
else if (char === ']') inClass = false;
else if (char === '/' && !inClass) {
index += 1;
while (/[A-Za-z]/.test(source[index] ?? '')) index += 1;
return index;
}
if (char === '\n') return index;
index += 1;
}
return source.length;
}
function isNumberStart(source, index) {
const char = source[index];
const next = source[index + 1];
const prev = source[index - 1];
if (/[0-9]/.test(char)) return true;
return char === '-' && /[0-9]/.test(next ?? '') && !isIdentifierPart(prev ?? '');
}
function readNumber(source, start) {
let index = start;
if (source[index] === '-') index += 1;
while (/[0-9]/.test(source[index] ?? '')) index += 1;
if (source[index] === '.' && /[0-9]/.test(source[index + 1] ?? '')) {
index += 1;
while (/[0-9]/.test(source[index] ?? '')) index += 1;
}
return index;
}
function isIdentifierStart(char) {
return /[A-Za-z_]/.test(char ?? '');
}
function isIdentifierPart(char) {
return /[A-Za-z0-9_-]/.test(char ?? '');
}
function readIdentifier(source, start) {
let index = start + 1;
while (isIdentifierPart(source[index])) index += 1;
return index;
}
function isOperatorStart(char) {
return /[&=<>!|:;,.{}()[\]]/.test(char ?? '');
}
function readOperator(source, start) {
let index = start + 1;
if ((source[start] === '<' || source[start] === '>' || source[start] === '=' || source[start] === '!') && source[index] === '=') {
index += 1;
}
return index;
}

View File

@ -12,9 +12,12 @@ import ManualLayout from '../layouts/ManualLayout.astro';
</div>
<p id="status" class="status">Loading WASM...</p>
<div class="playground-grid">
<label class="pane">
<label class="pane input-pane">
<span>Input</span>
<textarea id="source" spellcheck="false"></textarea>
<div class="editor-wrap">
<pre id="source-highlight" aria-hidden="true"></pre>
<textarea id="source" spellcheck="false"></textarea>
</div>
</label>
<section class="pane output-pane">
<span>Output</span>
@ -22,5 +25,7 @@ import ManualLayout from '../layouts/ManualLayout.astro';
</section>
</div>
</section>
<script type="module" src="../scripts/playground.js"></script>
<script>
import '../scripts/playground.js';
</script>
</ManualLayout>

View File

@ -1,4 +1,5 @@
import init, { evaluate } from '../wasm/decodal_wasm.js';
import { highlightDecodal } from '../lib/highlight.js';
const starter = `let
Service = {
@ -14,11 +15,22 @@ in
`;
const source = document.getElementById('source');
const sourceHighlight = document.getElementById('source-highlight');
const output = document.getElementById('output');
const run = document.getElementById('run');
const status = document.getElementById('status');
source.value = starter;
updateHighlight();
function updateHighlight() {
sourceHighlight.innerHTML = `${highlightDecodal(source.value)}\n`;
}
function syncHighlightScroll() {
sourceHighlight.scrollTop = source.scrollTop;
sourceHighlight.scrollLeft = source.scrollLeft;
}
function execute() {
const result = JSON.parse(evaluate(source.value));
@ -36,6 +48,11 @@ try {
}
run.addEventListener('click', execute);
source.addEventListener('input', () => {
updateHighlight();
syncHighlightScroll();
});
source.addEventListener('scroll', syncHighlightScroll);
source.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') execute();
});

View File

@ -114,7 +114,7 @@ main.playground {
}
.markdown pre,
.pane pre {
.output-pane pre {
background: #0f172a;
border-radius: 10px;
color: #e5e7eb;
@ -133,6 +133,34 @@ main.playground {
padding: 1px 4px;
}
.tok-keyword {
color: #93c5fd;
font-weight: 700;
}
.tok-type {
color: #67e8f9;
}
.tok-literal,
.tok-number {
color: #fbbf24;
}
.tok-string,
.tok-regex {
color: #86efac;
}
.tok-comment {
color: #94a3b8;
font-style: italic;
}
.tok-operator {
color: #f9a8d4;
}
.playground-page {
height: calc(100vh - 104px);
}
@ -194,13 +222,43 @@ button:disabled {
text-transform: uppercase;
}
textarea {
border: 0;
.editor-wrap {
background: #0f172a;
flex: 1;
min-height: 0;
position: relative;
}
.editor-wrap pre,
.editor-wrap textarea {
border: 0;
box-sizing: border-box;
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
outline: none;
inset: 0;
margin: 0;
overflow: auto;
padding: 14px;
position: absolute;
tab-size: 2;
white-space: pre;
}
.editor-wrap pre {
color: #e5e7eb;
pointer-events: none;
}
.editor-wrap textarea {
background: transparent;
caret-color: #e5e7eb;
color: transparent;
outline: none;
resize: none;
width: 100%;
}
.editor-wrap textarea::selection {
background: rgb(59 130 246 / 0.35);
}
.output-pane pre {