import fs from 'node:fs'; import vm from 'node:vm'; // ---- Regression guard for the fenced-code renderer ------------------------- // Chat markdown is rendered through marked with the custom renderer in // window.MARKED_OPTIONS (src/js/helpers.js). marked >= 13 hands renderer methods // the TOKEN as their single argument, so the renderer's old positional // `language` parameter was always undefined. Two consequences, both visible on // every code block the model streams (handleMessageStream.js re-parses the whole // message on every delta): // // * the fence's declared language was ignored, so every block went through // hljs.highlightAuto() — labelling ```js as "cpp" and ```html as // "php-template", relabelling itself as the block grew, and costing a full // multi-grammar detection pass each time; // * `code.text || code` fell through to the token OBJECT for an EMPTY block, // rendering a literal "[object Object]" — which is exactly what a fence // looks like for the moment between "```js" arriving and its first // character. // // Runs the REAL renderer from helpers.js against the REAL vendored marked + // highlight.js (zero drift). let failures = 0; function check(name, cond) { if (cond) console.log('ok - ' + name); else { console.error('FAIL - ' + name); failures++; } } const url = (p) => new URL(p, import.meta.url); const sandbox = vm.createContext({ console, out: {} }); vm.runInContext('var window = this; var self = this;', sandbox); vm.runInContext(fs.readFileSync(url('../src/vendor/marked.umd.min.js'), 'utf8'), sandbox); vm.runInContext(fs.readFileSync(url('../src/vendor/highlight.min.js'), 'utf8'), sandbox); // Pull MARKED_OPTIONS (plus the escape helpers it calls) straight out of helpers.js. const src = fs.readFileSync(url('../src/js/helpers.js'), 'utf8'); // From the syntax-highlight memo (which the renderer calls) through the end of // MARKED_OPTIONS, so the real renderer runs against its real helper. const a = src.indexOf('// ---- Syntax-highlight memo'); const b = src.indexOf('function prepareHistoryForAI('); if (a < 0 || b < 0 || b <= a) throw new Error('could not extract MARKED_OPTIONS'); const e0 = src.indexOf('function htmlEscape('); const e1 = src.indexOf('function generateChatId('); if (e0 < 0 || e1 < 0 || e1 <= e0) throw new Error('could not extract htmlEscape/htmlUnescape'); vm.runInContext(src.slice(e0, e1), sandbox); vm.runInContext(src.slice(a, b), sandbox); vm.runInContext('marked.use(MARKED_OPTIONS);', sandbox); // Render markdown the way the app does: escape first, then parse. function render(md) { sandbox.out.md = md; vm.runInContext('out.html = marked.parse(htmlEscape(out.md));', sandbox); return sandbox.out.html; } const fence = (lang, body) => '```' + lang + '\n' + body + '\n```'; const labelOf = (html) => (html.match(/class="code-language">([^<]*)hi'))) === 'html'); check('```css is labelled css', labelOf(render(fence('css', '.a { color: red }'))) === 'css'); check('```python is labelled python', labelOf(render(fence('python', 'def f():\n return 1'))) === 'python'); check('```JS is normalised to js', labelOf(render(fence('JS', 'const x = 1;'))) === 'js'); check('info string keeps only the language', labelOf(render(fence('js title=app.js', 'const x = 1;'))) === 'js'); // A declared-but-unregistered language keeps ITS OWN label rather than a guess. check('```vue keeps its label', labelOf(render(fence('vue', ''))) === 'vue'); // === Auto-detection still covers a bare fence ============================= { const html = render(fence('', 'def greet(name):\n return "hi " + name')); check('bare fence still gets a detected label', !!labelOf(html) && labelOf(html) !== ''); check('bare fence is still highlighted', /<\/code>/.test(render('```js\n'))); // === Content is still preserved and still escaped ========================= { const html = render(fence('html', '')); check('markup in a block is not live', html.indexOf('