Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 48 additions & 48 deletions dist/lite/markedit-preview.js

Large diffs are not rendered by default.

912 changes: 455 additions & 457 deletions dist/markedit-preview.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@
"vitest": "^4.0.18"
},
"dependencies": {
"dompurify": "^3.4.16",
"js-yaml": "^5.4.3",
"dompurify": "^3.4.17",
"js-yaml": "^5.5.0",
"katex": "^0.19.0",
"mark.js": "^8.11.1",
"markdown-it": "^15.0.3",
Expand Down
4 changes: 3 additions & 1 deletion src/hiddenSyntax/components/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { parser } from '@lezer/html';
import { MarkEdit } from 'markedit-api';
import { editorThemeCss } from '../../styling';
import { resolveImageURL } from '../../features/image';
import DOMPurify from 'dompurify';
import createDOMPurify from 'dompurify';

export function htmlFragments(state: EditorState, renderTables: boolean) {
const source = state.doc.toString();
Expand Down Expand Up @@ -226,6 +226,8 @@ export class HtmlWidget extends WidgetType {
}
}

// Mermaid installs hooks on the default instance; keep widget sanitization independent
const DOMPurify = createDOMPurify();
const disposables = new WeakMap<HTMLElement, () => void>();

const allowedInlineStyles = [
Expand Down
4 changes: 3 additions & 1 deletion src/hiddenSyntax/components/table.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EditorSelection, StateEffect, type Text } from '@codemirror/state';
import { WidgetType, EditorView } from '@codemirror/view';
import DOMPurify from 'dompurify';
import createDOMPurify from 'dompurify';
import { MarkEdit } from 'markedit-api';
import { editorThemeCss } from '../../styling';
import { resolveImageURL } from '../../features/image';
Expand Down Expand Up @@ -163,6 +163,8 @@ export class TableWidget extends WidgetType {
}
}

// Mermaid installs hooks on the default instance; keep widget sanitization independent
const DOMPurify = createDOMPurify();
const tableViews = new WeakMap<HTMLElement, {
body: HTMLElement;
mathStyle: HTMLStyleElement;
Expand Down
28 changes: 28 additions & 0 deletions tests/frontMatter.parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ describe('parseFrontMatter (validation)', () => {
it('returns undefined for malformed YAML', () => {
expect(parseFrontMatter(': : :\nnot valid')).toBeUndefined();
});

it.each([',value', ']value', '}value'])('requires quoting a leading flow indicator: %s', value => {
expect(parseFrontMatter(`title: ${value}`)).toBeUndefined();
expect(parseFrontMatter(`title: "${value}"`)).toEqual({ title: value });
expect(parseFrontMatter(`title: prefix${value}`)).toEqual({ title: `prefix${value}` });
});

it.each(['title: one\ntitle: two', 'title: !custom value', 'title: "bad\u0001value"'])(
'continues to reject invalid or unsupported YAML: %j',
source => {
expect(parseFrontMatter(source)).toBeUndefined();
},
);
});

describe('parseFrontMatter — type fidelity', () => {
Expand Down Expand Up @@ -89,4 +102,19 @@ describe('parseFrontMatter — CORE_SCHEMA (YAML 1.2) semantics', () => {
expect(typeof fm.date).toBe('string');
expect(typeof fm.created).toBe('string');
});

it.each(['!!str', '!!%73tr', '!<tag:yaml.org,2002:str>', '!<tag:yaml.org,2002:%73tr>'])(
'resolves explicit string tags: %s',
tag => {
expect(parseFrontMatter(`value: ${tag} 123`)).toEqual({ value: '123' });
},
);

it('preserves aliases and treats merge keys as ordinary CORE_SCHEMA keys', () => {
expect(parseFrontMatter('author: &author { name: Jane }\ncopy: *author\nmeta: { <<: *author }')).toEqual({
author: { name: 'Jane' },
copy: { name: 'Jane' },
meta: { '<<': { name: 'Jane' } },
});
});
});
7 changes: 7 additions & 0 deletions tests/frontMatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ describe('frontMatter rendering', () => {
});

describe('strings', () => {
it.each([',value', ']value', '}value'])('renders quoted flow indicators and preserves the body after invalid metadata: %s', async value => {
expect(await renderFm(`title: "${value}"`)).toContain(row('title', value));
const html = await renderMarkdown(`---\ntitle: ${value}\n---\n\n# Body`, false);
expect(html).not.toContain('markdown-frontMatter');
expect(html).toContain('<h1 id="body" tabindex="-1">Body</h1>');
});

it('strips single and double quotes', async () => {
const html = await renderFm('a: "double"\nb: \'single\'');
expect(html).toContain(row('a', 'double'));
Expand Down
23 changes: 23 additions & 0 deletions tests/hiddenSyntax/html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,4 +276,27 @@ describe('Hidden HTML', () => {
expect(body()?.querySelector('a')?.tabIndex).toBe(-1);
expect(body()?.querySelector('strong')?.textContent).toBe('Safe');
});

test('preserves safe HTML attributes while isolating named properties', () => {
setUp('<div id="note" class="notice" data-kind="note" aria-label="Note"><a name="target" href="https://example.com" title="Visit">Link</a><code>&lt;script&gt;literal&lt;/script&gt;</code></div>');
const note = body()?.querySelector('div');
expect(note?.id).toBe('user-content-note');
expect(note?.className).toBe('notice');
expect(note?.getAttribute('data-kind')).toBe('note');
expect(note?.getAttribute('aria-label')).toBe('Note');

const link = note?.querySelector('a');
expect(link?.getAttribute('name')).toBe('user-content-target');
expect(link?.getAttribute('href')).toBe('https://example.com');
expect(link?.title).toBe('Visit');
expect(link?.tabIndex).toBe(-1);
expect(note?.querySelector('code')?.textContent).toBe('<script>literal</script>');
expect(note?.querySelector('script')).toBeNull();
});

test('keeps the HTML-only profile when foreign namespaces are present', () => {
setUp('<div><svg><circle cx="5" cy="5" r="4"></circle></svg><math><mi>x</mi></math><strong>HTML</strong></div>');
expect(body()?.querySelector('svg, circle, math, mi')).toBeNull();
expect(body()?.querySelector('strong')?.textContent).toBe('HTML');
});
});
31 changes: 31 additions & 0 deletions tests/hiddenSyntax/table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,37 @@ describe('Hidden tables', () => {
expect(renderedTable()?.querySelector('a')?.tabIndex).toBe(-1);
});

test('preserves KaTeX MathML and SVG through fragment sanitization', async () => {
setUp('| Math |\n| --- |\n| $\\sqrt{x} + \\frac{1}{2}$ |\n\nAfter');
await vi.waitFor(() => expect(renderedTable()?.querySelector('.katex')).toBeTruthy());

const cell = renderedTable()!.querySelector('td')!;
expect(cell.querySelector('math')?.namespaceURI).toBe('http://www.w3.org/1998/Math/MathML');
expect(cell.querySelector('msqrt mi')?.textContent).toBe('x');
expect([...cell.querySelectorAll('mfrac mn')].map(node => node.textContent)).toEqual(['1', '2']);
expect(cell.querySelector('annotation')).toBeNull();
expect(cell.querySelector('svg')?.namespaceURI).toBe('http://www.w3.org/2000/svg');
expect(cell.querySelector('svg path')?.getAttribute('d')).toBeTruthy();
expect(cell.querySelector('.katex-html')?.getAttribute('aria-hidden')).toBe('true');
expect(cell.querySelector('.katex-error')).toBeNull();
});

test('preserves safe table content while isolating named properties', async () => {
setUp('| Content |\n| --- |\n| <span id="note" class="notice" data-kind="note" aria-label="Note">Text</span><a name="target" href="https://example.com">Link</a> |\n\nAfter');
await vi.waitFor(() => expect(renderedTable()).toBeTruthy());

const note = renderedTable()?.querySelector('span');
expect(note?.id).toBe('user-content-note');
expect(note?.className).toBe('notice');
expect(note?.getAttribute('data-kind')).toBe('note');
expect(note?.getAttribute('aria-label')).toBe('Note');

const link = renderedTable()?.querySelector('a');
expect(link?.getAttribute('name')).toBe('user-content-target');
expect(link?.getAttribute('href')).toBe('https://example.com');
expect(link?.tabIndex).toBe(-1);
});

test.each([
'> | Name |\n> | --- |\n> | Value |',
'- | Name |\n | --- |\n | Value |',
Expand Down
45 changes: 44 additions & 1 deletion tests/mermaid.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi
import mermaid from 'mermaid';
import type * as Settings from '../src/support/settings';
import { loadMermaid } from '../src/features/mermaid';
import { renderMermaidSVG } from '../src/render';
import { renderMermaidSVG, renderTableBlocks } from '../src/render';
import { HtmlWidget } from '../src/hiddenSyntax/components/html';
import { TableWidget } from '../src/hiddenSyntax/components/table';
import * as editor from './support/editor';

const themes = vi.hoisted<{ light: typeof Settings.mermaidLightTheme; dark: typeof Settings.mermaidDarkTheme }>(() => ({
light: 'default',
Expand Down Expand Up @@ -52,6 +55,46 @@ function diagramStyles(svg: string) {
}

describe('Mermaid 12 renderer', () => {
test('does not change HTML or table widget sanitization after rendering a diagram', async () => {
vi.stubGlobal('matchMedia', () => ({ matches: false, addListener: vi.fn(), removeListener: vi.fn() }));
const html = '<a href="https://example.com" target="_blank">Link</a>';
const source = `| Link |\n| --- |\n| ${html} |`;
editor.setUp(source, []);

const renderWidgets = async () => {
const view = window.editor;
const widgets = [
HtmlWidget.create(html)!,
new TableWidget(view.state.doc, 0, source.length, () => renderTableBlocks(source)),
];

const containers = widgets.map(widget => widget.toDOM(view));
document.body.append(...containers);

try {
await vi.waitFor(() => expect(containers[1].shadowRoot!.querySelector('table')).not.toBeNull());
return containers.map(container => {
const body = container.shadowRoot!.querySelector('.markdown-body')!;
expect(body.querySelector('a')?.hasAttribute('target')).toBe(false);
return body.innerHTML;
});
} finally {
widgets.forEach((widget, index) => {
widget.destroy(containers[index]);
containers[index].remove();
});
}
};

try {
const before = await renderWidgets();
await renderMermaidSVG('flowchart TD\nA[Start] --> B[End]');
expect(await renderWidgets()).toEqual(before);
} finally {
window.editor.destroy();
}
});

test.each([
['flowchart', 'flowchart TD\nA[Start] --> B[End]'],
['class', 'classDiagram\nAnimal <|-- Duck'],
Expand Down
19 changes: 6 additions & 13 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1863,20 +1863,13 @@ detect-libc@^2.0.3:
resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==

dompurify@^3.4.12:
dompurify@^3.4.12, dompurify@^3.4.17:
version "3.4.17"
resolved "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.17.tgz#074bbb24eaa419535ac19ffe7f663104b0e1543d"
integrity sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==
optionalDependencies:
"@types/trusted-types" "^2.0.7"

dompurify@^3.4.16:
version "3.4.16"
resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz#c51b3709ea15ee9abb07e804f2ef30ea9b953128"
integrity sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==
optionalDependencies:
"@types/trusted-types" "^2.0.7"

elkjs@^0.9.3:
version "0.9.3"
resolved "https://registry.npmmirror.com/elkjs/-/elkjs-0.9.3.tgz#16711f8ceb09f1b12b99e971b138a8384a529161"
Expand Down Expand Up @@ -2280,12 +2273,12 @@ js-yaml@^4.1.1:
dependencies:
argparse "^2.0.1"

js-yaml@^5.4.3:
version "5.4.3"
resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.3.tgz#4ae09e2145682daea9c91b4b1f0610de4c80c941"
integrity sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==
js-yaml@^5.5.0:
version "5.5.0"
resolved "https://registry.npmmirror.com/js-yaml/-/js-yaml-5.5.0.tgz#ec843f09ffb2a54b6758a41eee109a1815d16442"
integrity sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==
dependencies:
argparse "^2.0.1"
argparse "^3.0.3"

jsdom@^26.1.0:
version "26.1.0"
Expand Down
Loading