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...