Add site syntax highlighting
This commit is contained in:
parent
0e873fbd51
commit
19c9de1601
|
|
@ -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');
|
||||
|
|
|
|||
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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user