mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
`_renderMarkdown` wrote dir="auto" into its string, before the code pins and the linkifier. An element carrying dir="auto" re-resolves its direction on every insertion under it, and a block whose letters all sit in `dir` children walks its whole subtree each time, so a code-only or path-only document went quadratic, English ones included. Measured in real Chromium through the real File Viewer, before and after: 9000 inline-code items 8.7 s to 0.35 s, 9000 bare paths 5.6 s to 0.23 s, a 209 KB out-of-workspace list 41.9 s to 1.4 s. All three now match master. The pass is now `_applyBlockDirection()`, run last on the DOM by both consumers. The direction results are unchanged. Tests pin that the rendered string holds no dir="auto" and that the pass runs after the linkifier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
10 KiB
TypeScript
224 lines
10 KiB
TypeScript
/**
|
||
* @fileoverview Right-to-left text in rendered markdown (`_applyBlockDirection`, issue #715).
|
||
*
|
||
* The Response Viewer and the File Viewer's markdown view rendered every block left to
|
||
* right, so a Persian, Arabic or Hebrew answer was left-aligned, its punctuation landed on
|
||
* the wrong end and its list markers sat on the left. `_applyBlockDirection` now gives each
|
||
* OUTERMOST prose block `dir="auto"` (the browser picks the direction from the block's own
|
||
* first letter), and `_renderMarkdown` pins code to `dir="ltr"`, which also keeps a code
|
||
* span from deciding a sentence's direction, since `dir="auto"` skips descendants that
|
||
* carry a `dir`.
|
||
*
|
||
* The pass runs LAST, on the DOM, after the linkifier: an element that already carries
|
||
* `dir="auto"` re-resolves its direction on every insertion under it, which made a
|
||
* code-only or path-only document quadratic. Two tests below pin that order.
|
||
*
|
||
* jsdom does not resolve `dir="auto"`, so this pins the attributes the browser acts on and
|
||
* the order, never the cost; what Chromium makes of them was measured when the change was
|
||
* made.
|
||
*
|
||
* Drives the SHIPPING artifacts (vendored `marked`, vendored DOMPurify + `sanitize-html.js`,
|
||
* `constants.js` and `app.js`) in a `vm` with a jsdom document, the technique from
|
||
* response-viewer-external-links.test.ts. No port / server needed.
|
||
*/
|
||
import { readFileSync } from 'node:fs';
|
||
import { performance } from 'node:perf_hooks';
|
||
import { resolve } from 'node:path';
|
||
import vm from 'node:vm';
|
||
import { JSDOM } from 'jsdom';
|
||
import { describe, expect, it, vi } from 'vitest';
|
||
|
||
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
|
||
|
||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
|
||
const { document, NodeFilter } = dom.window;
|
||
|
||
function loadShippingSanitizer(): (html: string) => string {
|
||
const dpModule: { exports: unknown } = { exports: {} };
|
||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||
new Function('module', 'exports', publicFile('vendor/dompurify.min.js'))(dpModule, dpModule.exports);
|
||
const DOMPurify = (dpModule.exports as (win: unknown) => unknown)(jsdomWindow);
|
||
|
||
const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = {
|
||
exports: {},
|
||
};
|
||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||
new Function('module', 'exports', publicFile('sanitize-html.js'))(sanModule, sanModule.exports);
|
||
const create = sanModule.exports.createMarkdownSanitizer;
|
||
if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported');
|
||
return create(DOMPurify);
|
||
}
|
||
|
||
function loadShippingMarked(): { parse: (src: string, opts?: unknown) => string } {
|
||
const module: { exports: unknown } = { exports: {} };
|
||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||
new Function('module', 'exports', publicFile('vendor/marked.min.js'))(module, module.exports);
|
||
return module.exports as { parse: (src: string, opts?: unknown) => string };
|
||
}
|
||
|
||
type RenderApp = {
|
||
_renderMarkdown(text: string, opts?: { breaks?: boolean }): string;
|
||
_linkifyFilePaths(root: unknown): void;
|
||
_applyBlockDirection(root: unknown): void;
|
||
_buildResponseViewerMessage(text: string, role: string, agentLabel: string): HTMLElement;
|
||
};
|
||
|
||
function loadCodemanAppClass(): { prototype: RenderApp } {
|
||
const context = vm.createContext({
|
||
console,
|
||
performance,
|
||
setInterval: vi.fn(),
|
||
clearInterval: vi.fn(),
|
||
setTimeout,
|
||
clearTimeout,
|
||
requestAnimationFrame: vi.fn(),
|
||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||
fetch: vi.fn(),
|
||
document,
|
||
NodeFilter,
|
||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||
// _sanitizeHtml fails closed without the sanitizer, which would make every
|
||
// assertion below vacuous.
|
||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), sanitizeMarkdownHtml: loadShippingSanitizer() },
|
||
marked: loadShippingMarked(),
|
||
MobileDetection: {},
|
||
});
|
||
vm.runInContext(
|
||
`${publicFile('constants.js')}\n${publicFile('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||
context
|
||
);
|
||
return (context as { __CodemanApp: { prototype: RenderApp } }).__CodemanApp;
|
||
}
|
||
|
||
const CodemanApp = loadCodemanAppClass();
|
||
const app = Object.create(CodemanApp.prototype) as RenderApp;
|
||
|
||
/** Render markdown the way both viewers do (pipeline, linkifier, direction pass) and return the element. */
|
||
function render(markdown: string, opts?: { breaks?: boolean }): HTMLElement {
|
||
const root = document.createElement('div');
|
||
root.className = 'rv-text';
|
||
root.innerHTML = app._renderMarkdown(markdown, opts);
|
||
app._linkifyFilePaths(root);
|
||
app._applyBlockDirection(root);
|
||
return root as unknown as HTMLElement;
|
||
}
|
||
|
||
const dirOf = (el: Element | null) => el?.getAttribute('dir') ?? null;
|
||
|
||
describe('response viewer right-to-left text (#715)', () => {
|
||
it('lets every top-level prose block pick its own direction', () => {
|
||
const root = render(
|
||
[
|
||
'# عنوان',
|
||
'',
|
||
'سلام، این یک تست است.',
|
||
'',
|
||
'An English paragraph.',
|
||
'',
|
||
'- مورد اول',
|
||
'- مورد دوم',
|
||
'',
|
||
'1. one',
|
||
'',
|
||
'> نقل قول',
|
||
].join('\n')
|
||
);
|
||
|
||
expect(dirOf(root.querySelector('h1'))).toBe('auto');
|
||
const paragraphs = Array.from(root.querySelectorAll(':scope > p'));
|
||
expect(paragraphs).toHaveLength(2);
|
||
for (const p of paragraphs) expect(dirOf(p)).toBe('auto');
|
||
expect(dirOf(root.querySelector('ul'))).toBe('auto');
|
||
expect(dirOf(root.querySelector('ol'))).toBe('auto');
|
||
expect(dirOf(root.querySelector('blockquote'))).toBe('auto');
|
||
});
|
||
|
||
it('leaves nested blocks to inherit, so a list keeps its markers and indent on one side', () => {
|
||
// A `dir="auto"` per item would resolve each item on its own while the list's
|
||
// padding stays on the list's side, which hung RTL markers outside the box.
|
||
const root = render(['- مورد اول', ' - تو در تو', '', '> پاراگراف اول', '>', '> پاراگراف دوم'].join('\n'));
|
||
|
||
for (const li of Array.from(root.querySelectorAll('li'))) expect(li.hasAttribute('dir')).toBe(false);
|
||
expect(root.querySelector('ul ul')?.hasAttribute('dir')).toBe(false);
|
||
for (const p of Array.from(root.querySelectorAll('blockquote p'))) expect(p.hasAttribute('dir')).toBe(false);
|
||
});
|
||
|
||
it('gives a table its direction on the scroll wrapper, so an RTL table scrolls from its first column', () => {
|
||
const root = render('| ستون | مقدار |\n| --- | --- |\n| الف | ۱ |');
|
||
|
||
const wrap = root.querySelector('.rv-table-wrap');
|
||
expect(wrap, 'tables are still wrapped').not.toBeNull();
|
||
expect(dirOf(wrap)).toBe('auto');
|
||
expect(root.querySelector('table')?.hasAttribute('dir')).toBe(false);
|
||
for (const cell of Array.from(root.querySelectorAll('th, td'))) expect(cell.hasAttribute('dir')).toBe(false);
|
||
});
|
||
|
||
it('pins code blocks and inline code left to right', () => {
|
||
const root = render('`npm install` را اجرا کن.\n\n```\nconst a = 1;\n```');
|
||
|
||
expect(dirOf(root.querySelector('p > code'))).toBe('ltr');
|
||
expect(dirOf(root.querySelector('pre'))).toBe('ltr');
|
||
// The code-block toolbar shares the template walk; it must survive the new pass.
|
||
expect(root.querySelector('.rv-code-wrap .rv-copy-btn')).not.toBeNull();
|
||
expect(root.querySelector('.rv-code-wrap')?.hasAttribute('dir')).toBe(false);
|
||
});
|
||
|
||
it('keeps a dir the agent wrote, and what it covers', () => {
|
||
// The sanitizer allows `dir`; an explicit choice must not be replaced by a guess.
|
||
const root = render('<div dir="rtl"><p>inherits</p></div>\n\n<p dir="ltr">explicit</p>');
|
||
|
||
expect(dirOf(root.querySelector('div[dir]'))).toBe('rtl');
|
||
expect(root.querySelector('div[dir] > p')?.hasAttribute('dir')).toBe(false);
|
||
expect(dirOf(Array.from(root.querySelectorAll('p')).find((p) => p.textContent === 'explicit') ?? null)).toBe('ltr');
|
||
});
|
||
|
||
it('does the same for the File Viewer markdown view (breaks: false)', () => {
|
||
const root = render('پاراگراف اول\nادامه همان پاراگراف', { breaks: false });
|
||
|
||
expect(dirOf(root.querySelector('p'))).toBe('auto');
|
||
});
|
||
|
||
it('pins a linkified file path left to right, so it does not decide the sentence direction', () => {
|
||
const root = render('فایل /home/user/project/config.json را باز کن.');
|
||
|
||
const link = root.querySelector('a.rv-path');
|
||
expect(link?.textContent).toBe('/home/user/project/config.json');
|
||
expect(dirOf(link)).toBe('ltr');
|
||
});
|
||
|
||
it('does not pin a markdown link label, which is prose in the sentence language', () => {
|
||
const root = render('[گزارش حادثه](/tmp/report.json)');
|
||
|
||
const link = root.querySelector('a.rv-path-link');
|
||
expect(link, 'a path-target link takes the rv-path shape').not.toBeNull();
|
||
expect(link?.hasAttribute('dir')).toBe(false);
|
||
});
|
||
|
||
it('never puts dir="auto" in the rendered string, where it would make the parse and the linkifier quadratic', () => {
|
||
// An element carrying dir="auto" re-resolves its direction on every insertion
|
||
// under it. A document whose letters all sit in `dir` children walks the whole
|
||
// block each time, which froze the tab for seconds on an all-code list.
|
||
const html = app._renderMarkdown('- `f1`\n- `f2`\n\nسلام\n\n| ستون |\n| --- |\n| الف |');
|
||
|
||
expect(html).not.toContain('dir="auto"');
|
||
expect(html).toContain('<code dir="ltr">f1</code>');
|
||
});
|
||
|
||
it('gives a Response Viewer message its direction only after the linkifier finished', () => {
|
||
const probe = Object.create(app) as RenderApp;
|
||
let autoDuringLinkify = -1;
|
||
probe._linkifyFilePaths = (root) => {
|
||
autoDuringLinkify = (root as Element).querySelectorAll('[dir="auto"]').length;
|
||
app._linkifyFilePaths(root);
|
||
};
|
||
|
||
const message = probe._buildResponseViewerMessage('/home/user/project/config.json را ببین.', 'assistant', 'Claude');
|
||
|
||
expect(autoDuringLinkify).toBe(0);
|
||
const para = message.querySelector('.rv-text p');
|
||
expect(dirOf(para)).toBe('auto');
|
||
expect(dirOf(para?.querySelector('a.rv-path') ?? null)).toBe('ltr');
|
||
});
|
||
});
|