The old renderer rewrote the text: it found "[n]" with regexes, renumbered them, and swapped the result back in — which broke inside `arr[2][1]`, inside HTML attributes, and whenever two turns disagreed about what "[3]" meant. It also had a fallback markdown renderer of its own for when the rewrite produced something markdown-it would not parse. Now "[n]" is an inline rule registered on the same markdown-it instance that renders everything else. The parser decides what is prose and what is code, a link, or a URL, so the rule never sees "[1]" inside a code span, and it steps aside for "[1](url)". Math is two more rules on the same parser instead of a regex pre-pass, so "$" inside a URL is no longer math. Identity vs display: the stored "[n]" and each card's id are the source's identity (sourceNumber) and are never rewritten. The number a reader sees is the order of first appearance, computed at render time from the token stream (orderSourcesByCitation), so "one, then seven" cannot happen and a saved chat re-opens pointing at the same cards it was saved with. Stored messages and sources are untouched; export and the modal resolve by identity. Translated HTML gets the same links through a TreeWalker over text nodes (linkCitationsInHtml) rather than a regex over markup. Deleted: renderCitationLinks, normalizeAdjacentCitationClusters, the fallback renderer (fallbackMarkdown/renderMixedList/renderFallbackTable), renderLatexText, CITATION_SCAN. Tests that asserted rewritten text now assert token output; harnesses that render for real are given a parser. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
46 lines
2.1 KiB
JavaScript
46 lines
2.1 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const path = require('node:path');
|
|
globalThis.markdownit = require('markdown-it'); // the renderer finds its parser here, as the browser's window
|
|
|
|
let modulePromise;
|
|
|
|
async function loadCitationModule() {
|
|
if (!modulePromise) {
|
|
modulePromise = fs.readFile(path.join(__dirname, '..', 'public', 'js', 'assistant', 'citations.js'), 'utf8')
|
|
.then((source) => import('data:text/javascript;charset=utf-8,' + encodeURIComponent(source)));
|
|
}
|
|
return modulePromise;
|
|
}
|
|
|
|
test('mhchem inline delimiters render as KaTeX without eating ordinary backslash prose', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const expressions = [];
|
|
const katex = {
|
|
renderToString(expr, opts) {
|
|
expressions.push({ expr, display: opts.displayMode });
|
|
return '<span class="katex">' + expr + '</span>';
|
|
}
|
|
};
|
|
const html = renderAssistantMarkdown('Base deficit \\ce{HCO3-} and \\pu{37 C}; a plain \\ce without braces stays text.', [], { katex });
|
|
assert.deepEqual(expressions, [{ expr: '\\ce{HCO3-}', display: false }, { expr: '\\pu{37 C}', display: false }]);
|
|
assert.match(html, /<span class="katex">\\ce{HCO3-}<\/span>/);
|
|
assert.match(html, /<span class="katex">\\pu{37 C}<\/span>/);
|
|
assert.match(html, /without braces stays text/);
|
|
});
|
|
|
|
test('without KaTeX the mhchem source stays literal and escaped', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Base deficit \\ce{HCO3-}.', []);
|
|
assert.match(html, /\\ce\{HCO3-\}/);
|
|
assert.doesNotMatch(html, /katex/);
|
|
});
|
|
|
|
test('mhchem inside fenced code blocks is never rendered', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const katex = { renderToString() { throw new Error('code block sent to KaTeX'); } };
|
|
const html = renderAssistantMarkdown('```md\n\\ce{H2O} stays literal\n```', [], { katex });
|
|
assert.match(html, /\\ce\{H2O\}/);
|
|
assert.doesNotMatch(html, /katex/);
|
|
});
|