From a712b3d61cc8b10208f991e97e499a87ea41b913 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 26 Jul 2026 15:26:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20WebUI=20modernization=20PR6=20?= =?UTF-8?q?=E2=80=94=20streaming-safe=20markdown=20tokenizer,=20golden=20t?= =?UTF-8?q?ests,=20strict=20CSP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the regex-pipeline markdownToHtml with a hand-written line-based block parser + inline tokenizer. Unterminated fenced code blocks render their collected lines as a code block (EOF = fence close); unterminated inline constructs (**, backtick, ~~) render literally, so partial markdown no longer flashes broken markup while streaming. DOM contract preserved (h1-h4, hr, code-block/cb-header/ cb-lang/cb-copy, ul/ol, p/br); .cb-copy is now a ' + + '' + + '
' + escapeHtml(code) + '
' + + ''; +} + +// Inline tokenizer: scans left to right, emitting literal runs through +// escapeHtml. Every construct requires a closing delimiter; otherwise the +// opener is emitted literally and scanning continues after it. +function inlineHtml(text) { + let out = ''; + let lit = ''; + const flush = () => { if (lit) { out += escapeHtml(lit); lit = ''; } }; + let i = 0; + + while (i < text.length) { + const ch = text[i]; + + // Inline code — content is escaped, never re-processed. + if (ch === '`') { + const end = text.indexOf('`', i + 1); + if (end > i + 1) { + flush(); + out += '' + escapeHtml(text.slice(i + 1, end)) + ''; + i = end + 1; + continue; + } + lit += ch; i++; continue; + } + + // Bold. + if (ch === '*' && text[i + 1] === '*') { + const end = text.indexOf('**', i + 2); + if (end > i + 2) { + flush(); + out += '' + inlineHtml(text.slice(i + 2, end)) + ''; + i = end + 2; + continue; + } + lit += '**'; i += 2; continue; + } + + // Strikethrough. + if (ch === '~' && text[i + 1] === '~') { + const end = text.indexOf('~~', i + 2); + if (end > i + 2) { + flush(); + out += '' + inlineHtml(text.slice(i + 2, end)) + ''; + i = end + 2; + continue; + } + lit += '~~'; i += 2; continue; + } + + // Italic — not inside words (a*b*c stays literal): the opener must not + // follow a word char, the closer must not precede one. + if (ch === '*') { + const prev = i > 0 ? text[i - 1] : ''; + if (!WORD_CHAR.test(prev)) { + let end = -1; + for (let j = i + 1; j < text.length; j++) { + if (text[j] !== '*' || text[j + 1] === '*') continue; + if (j === i + 1) continue; // empty span + const after = j + 1 < text.length ? text[j + 1] : ''; + if (WORD_CHAR.test(after)) continue; + end = j; + break; + } + if (end > 0) { + flush(); + out += '' + inlineHtml(text.slice(i + 1, end)) + ''; + i = end + 1; + continue; + } + } + lit += ch; i++; continue; + } + + // Links — [text](url) with a scheme allowlist. + if (ch === '[') { + const close = text.indexOf('](', i + 1); + if (close > i + 1) { + const end = text.indexOf(')', close + 2); + if (end > close + 2) { + const label = text.slice(i + 1, close); + const url = text.slice(close + 2, end).trim(); + flush(); + if (SAFE_URL.test(url)) { + out += '' + inlineHtml(label) + ''; + } else { + out += escapeHtml(label); + } + i = end + 1; + continue; + } + } + lit += ch; i++; continue; + } + + lit += ch; i++; + } + + flush(); + return out; } diff --git a/cmd/odek/ui/js/markdown.test.js b/cmd/odek/ui/js/markdown.test.js new file mode 100644 index 0000000..2e78c8f --- /dev/null +++ b/cmd/odek/ui/js/markdown.test.js @@ -0,0 +1,200 @@ +// Golden tests for the streaming-safe markdown tokenizer. +// Run: node --test cmd/odek/ui/js/ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { markdownToHtml } from './markdown.js'; + +// ── Blocks ── + +test('headers h1–h4', () => { + assert.equal(markdownToHtml('# T'), '

T

'); + assert.equal(markdownToHtml('## T'), '

T

'); + assert.equal(markdownToHtml('### T'), '

T

'); + assert.equal(markdownToHtml('#### T'), '

T

'); + // Five hashes is not a header + assert.equal(markdownToHtml('##### T'), '

##### T

'); +}); + +test('horizontal rules', () => { + assert.equal(markdownToHtml('---'), '
'); + assert.equal(markdownToHtml('***'), '
'); + assert.equal(markdownToHtml('___'), '
'); +}); + +test('fenced code block with language', () => { + assert.equal( + markdownToHtml('```go\nfmt.Println("hi")\n```'), + '
go' + + '
' + + '
fmt.Println("hi")\n
' + ); +}); + +test('fenced code block without language defaults to "code"', () => { + const html = markdownToHtml('```\nx = 1\n```'); + assert.ok(html.includes('code')); + assert.ok(html.includes('
x = 1\n
')); +}); + +test('unterminated fence renders collected lines as a code block', () => { + const html = markdownToHtml('```js\nconsole.log(1)\nconsole.log(2)'); + assert.equal( + html, + '
js' + + '
' + + '
console.log(1)\nconsole.log(2)\n
' + ); +}); + +test('code fence content is escaped, never re-processed', () => { + const html = markdownToHtml('```\n**not bold** x\n```'); + assert.ok(html.includes('
**not bold** <b>x</b>\n
')); + assert.ok(!html.includes('')); +}); + +test('unordered list', () => { + assert.equal( + markdownToHtml('- one\n- two'), + '' + ); + assert.equal( + markdownToHtml('* one\n* two'), + '' + ); +}); + +test('ordered list', () => { + assert.equal( + markdownToHtml('1. one\n2. two'), + '
  1. one
  2. two
' + ); +}); + +test('list items support inline markup', () => { + assert.equal( + markdownToHtml('- a **b** c'), + '' + ); +}); + +// ── Inline spans ── + +test('bold', () => { + assert.equal(markdownToHtml('a **b** c'), '

a b c

'); +}); + +test('italic', () => { + assert.equal(markdownToHtml('a *b* c'), '

a b c

'); +}); + +test('italic not inside words', () => { + assert.equal(markdownToHtml('a*b*c'), '

a*b*c

'); +}); + +test('strikethrough', () => { + assert.equal(markdownToHtml('a ~~b~~ c'), '

a b c

'); +}); + +test('inline code', () => { + assert.equal(markdownToHtml('use `npm test` now'), '

use npm test now

'); +}); + +test('inline code content is never re-processed', () => { + assert.equal(markdownToHtml('`**x**`'), '

**x**

'); +}); + +test('unterminated bold renders literally', () => { + assert.equal(markdownToHtml('**bold'), '

**bold

'); +}); + +test('unterminated inline code renders literally', () => { + assert.equal(markdownToHtml('some `code'), '

some `code

'); +}); + +test('unterminated strikethrough renders literally', () => { + assert.equal(markdownToHtml('~~strike'), '

~~strike

'); +}); + +// ── Links ── + +test('http link renders anchor with safe attributes', () => { + assert.equal( + markdownToHtml('[site](https://example.com)'), + '

site

' + ); +}); + +test('relative and anchor links are allowed', () => { + for (const url of ['/abs/path', './rel', '../up', '#frag', 'mailto:a@b.c']) { + const html = markdownToHtml('[x](' + url + ')'); + assert.ok(html.includes(' { + const html = markdownToHtml('[click](javascript:alert)'); + assert.ok(!html.includes('click

'); + // URL with parens: parsing stops at the first ')' (same as the old + // regex pipeline), leaving the trailing ')' as literal text. + const html2 = markdownToHtml('[click](javascript:alert(1))'); + assert.ok(!html2.includes('click)

'); +}); + +test('data: link renders as plain text', () => { + const html = markdownToHtml('[click](data:text/html;base64,xxxx)'); + assert.ok(!html.includes('click

'); +}); + +// ── Escaping & paragraphs ── + +test('raw HTML in input is escaped', () => { + assert.equal( + markdownToHtml(''), + '

<script>alert(1)</script>

' + ); +}); + +test('paragraphs split on blank lines, single newline becomes
', () => { + assert.equal( + markdownToHtml('one\ntwo\n\nthree'), + '

one
two

\n

three

' + ); +}); + +test('empty input', () => { + assert.equal(markdownToHtml(''), ''); +}); + +// ── Golden multi-feature document ── + +test('golden document', () => { + const doc = [ + '# Title', + '', + 'Some **bold** and *italic* text with `code`.', + '', + '- one', + '- two', + '', + '```js', + 'console.log("hi");', + '```', + '', + 'Check [link](https://example.com) out.', + ].join('\n'); + + const expected = [ + '

Title

', + '

Some bold and italic text with code.

', + '
  • one
  • two
', + '
js' + + '
' + + '
console.log("hi");\n
', + '

Check link out.

', + ].join('\n'); + + assert.equal(markdownToHtml(doc), expected); +}); diff --git a/cmd/odek/ui/js/utils.js b/cmd/odek/ui/js/utils.js index ac99f2f..b31f5fd 100644 --- a/cmd/odek/ui/js/utils.js +++ b/cmd/odek/ui/js/utils.js @@ -3,19 +3,9 @@ import { S } from './state.js'; import { messagesEl, scrollBottomBtn, cancelBtn } from './dom.js'; -// ── Escape helpers ── -export function escapeHtml(s) { - if (!s) return ''; - return s.replace(/&/g,'&').replace(//g,'>'); -} - -export function escapeAttr(s) { - if (!s) return ''; - // & must be replaced first — doing it last double-escapes the entities - // introduced by the quote replacements (" → &quot;). - return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''') - .replace(//g,'>'); -} +// ── Escape helpers (implemented in escape.js so markdown.js can use them +// without importing the browser-dependent modules) ── +export { escapeHtml, escapeAttr } from './escape.js'; // ── Number formatting ── export function formatNum(n) { diff --git a/cmd/odek/ui/style.css b/cmd/odek/ui/style.css index 8243035..91ab7c9 100644 --- a/cmd/odek/ui/style.css +++ b/cmd/odek/ui/style.css @@ -863,7 +863,7 @@ body.light { text-transform: uppercase; letter-spacing: .1em; } -.cb-copy { cursor: pointer; opacity: .5; transition: opacity .12s; padding: 2px 6px; border-radius: 2px; } +.cb-copy { cursor: pointer; opacity: .5; transition: opacity .12s; padding: 2px 6px; border-radius: 2px; background: none; border: 0; font: inherit; color: inherit; } .cb-copy:hover { opacity: 1; background: rgba(255,255,255,.05); } .cb-copy.copied { opacity: 1; color: var(--green); } .code-block pre { diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d8fad07..ddb424a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -119,8 +119,10 @@ cmd/odek/ ui/ index.html Single-page web UI (vanilla JS + CSS) app.js ES-module entry point (imports ./js/main.js) - js/ Native ES modules (state, dom, utils, markdown, render, - approvals, sessions, input, ws, main, net) — no build step + js/ Native ES modules (state, dom, utils, escape, markdown, + render, approvals, sessions, input, ws, main, net) — no build step. + UI unit tests (node:test golden tests for markdown.js, etc.): + node --test "cmd/odek/ui/js/**/*.test.js" style.css Stylesheet docs/ Documentation CLI.md CLI reference