Add site syntax highlighting
This commit is contained in:
parent
0e873fbd51
commit
19c9de1601
|
|
@ -1,4 +1,5 @@
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
import { escapeAttribute, escapeHtml, highlightCode } from './highlight.js';
|
||||||
|
|
||||||
const modules = import.meta.glob('../../../../doc/manual/souce/**/*.md', {
|
const modules = import.meta.glob('../../../../doc/manual/souce/**/*.md', {
|
||||||
query: '?raw',
|
query: '?raw',
|
||||||
|
|
@ -79,7 +80,17 @@ export const nav = [
|
||||||
{ title: 'Open Issues', slug: 'open-issues' },
|
{ 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() {
|
export function allDocSlugs() {
|
||||||
return Object.keys(docs).filter((slug) => slug !== 'index');
|
return Object.keys(docs).filter((slug) => slug !== 'index');
|
||||||
|
|
|
||||||
196
site/decodal-site/src/lib/highlight.js
Normal file
196
site/decodal-site/src/lib/highlight.js
Normal 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 = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function escapeHtml(value) {
|
||||||
|
return String(value).replace(/[&<>"']/g, (char) => HTML_ESCAPE[char]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeAttribute(value) {
|
||||||
|
return escapeHtml(value).replace(/`/g, '`');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;)[^&]*)*"|'[^']*')/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;
|
||||||
|
}
|
||||||
|
|
@ -12,9 +12,12 @@ import ManualLayout from '../layouts/ManualLayout.astro';
|
||||||
</div>
|
</div>
|
||||||
<p id="status" class="status">Loading WASM...</p>
|
<p id="status" class="status">Loading WASM...</p>
|
||||||
<div class="playground-grid">
|
<div class="playground-grid">
|
||||||
<label class="pane">
|
<label class="pane input-pane">
|
||||||
<span>Input</span>
|
<span>Input</span>
|
||||||
|
<div class="editor-wrap">
|
||||||
|
<pre id="source-highlight" aria-hidden="true"></pre>
|
||||||
<textarea id="source" spellcheck="false"></textarea>
|
<textarea id="source" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<section class="pane output-pane">
|
<section class="pane output-pane">
|
||||||
<span>Output</span>
|
<span>Output</span>
|
||||||
|
|
@ -22,5 +25,7 @@ import ManualLayout from '../layouts/ManualLayout.astro';
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<script type="module" src="../scripts/playground.js"></script>
|
<script>
|
||||||
|
import '../scripts/playground.js';
|
||||||
|
</script>
|
||||||
</ManualLayout>
|
</ManualLayout>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import init, { evaluate } from '../wasm/decodal_wasm.js';
|
import init, { evaluate } from '../wasm/decodal_wasm.js';
|
||||||
|
import { highlightDecodal } from '../lib/highlight.js';
|
||||||
|
|
||||||
const starter = `let
|
const starter = `let
|
||||||
Service = {
|
Service = {
|
||||||
|
|
@ -14,11 +15,22 @@ in
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const source = document.getElementById('source');
|
const source = document.getElementById('source');
|
||||||
|
const sourceHighlight = document.getElementById('source-highlight');
|
||||||
const output = document.getElementById('output');
|
const output = document.getElementById('output');
|
||||||
const run = document.getElementById('run');
|
const run = document.getElementById('run');
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
|
|
||||||
source.value = starter;
|
source.value = starter;
|
||||||
|
updateHighlight();
|
||||||
|
|
||||||
|
function updateHighlight() {
|
||||||
|
sourceHighlight.innerHTML = `${highlightDecodal(source.value)}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHighlightScroll() {
|
||||||
|
sourceHighlight.scrollTop = source.scrollTop;
|
||||||
|
sourceHighlight.scrollLeft = source.scrollLeft;
|
||||||
|
}
|
||||||
|
|
||||||
function execute() {
|
function execute() {
|
||||||
const result = JSON.parse(evaluate(source.value));
|
const result = JSON.parse(evaluate(source.value));
|
||||||
|
|
@ -36,6 +48,11 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
run.addEventListener('click', execute);
|
run.addEventListener('click', execute);
|
||||||
|
source.addEventListener('input', () => {
|
||||||
|
updateHighlight();
|
||||||
|
syncHighlightScroll();
|
||||||
|
});
|
||||||
|
source.addEventListener('scroll', syncHighlightScroll);
|
||||||
source.addEventListener('keydown', (event) => {
|
source.addEventListener('keydown', (event) => {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') execute();
|
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') execute();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ main.playground {
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown pre,
|
.markdown pre,
|
||||||
.pane pre {
|
.output-pane pre {
|
||||||
background: #0f172a;
|
background: #0f172a;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #e5e7eb;
|
color: #e5e7eb;
|
||||||
|
|
@ -133,6 +133,34 @@ main.playground {
|
||||||
padding: 1px 4px;
|
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 {
|
.playground-page {
|
||||||
height: calc(100vh - 104px);
|
height: calc(100vh - 104px);
|
||||||
}
|
}
|
||||||
|
|
@ -194,13 +222,43 @@ button:disabled {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
.editor-wrap {
|
||||||
border: 0;
|
background: #0f172a;
|
||||||
flex: 1;
|
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;
|
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
outline: none;
|
inset: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
padding: 14px;
|
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;
|
resize: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrap textarea::selection {
|
||||||
|
background: rgb(59 130 246 / 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.output-pane pre {
|
.output-pane pre {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user