diff --git a/site/decodal-site/src/lib/docs.js b/site/decodal-site/src/lib/docs.js index 18c9864..177cefd 100644 --- a/site/decodal-site/src/lib/docs.js +++ b/site/decodal-site/src/lib/docs.js @@ -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 `
${highlightCode(code, normalizedLanguage)}
`; +}; + +renderer.codespan = (code) => `${escapeHtml(code)}`; + +marked.setOptions({ gfm: true, renderer }); export function allDocSlugs() { return Object.keys(docs).filter((slug) => slug !== 'index'); diff --git a/site/decodal-site/src/lib/highlight.js b/site/decodal-site/src/lib/highlight.js new file mode 100644 index 0000000..3fd7b02 --- /dev/null +++ b/site/decodal-site/src/lib/highlight.js @@ -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, '$1') + .replace(/(^|\s)(--?[A-Za-z0-9][A-Za-z0-9-]*)/g, '$1$2'); +} + +function token(kind, value) { + return `${escapeHtml(value)}`; +} + +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; +} diff --git a/site/decodal-site/src/pages/playground.astro b/site/decodal-site/src/pages/playground.astro index f1bdcc6..46259aa 100644 --- a/site/decodal-site/src/pages/playground.astro +++ b/site/decodal-site/src/pages/playground.astro @@ -12,9 +12,12 @@ import ManualLayout from '../layouts/ManualLayout.astro';

Loading WASM...

-
- + diff --git a/site/decodal-site/src/scripts/playground.js b/site/decodal-site/src/scripts/playground.js index 4576e76..4941d8e 100644 --- a/site/decodal-site/src/scripts/playground.js +++ b/site/decodal-site/src/scripts/playground.js @@ -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(); }); diff --git a/site/decodal-site/src/style.css b/site/decodal-site/src/style.css index 82e9ace..98ff322 100644 --- a/site/decodal-site/src/style.css +++ b/site/decodal-site/src/style.css @@ -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 {