mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
chore: bump deps (#15124)
This commit is contained in:
@@ -92,7 +92,7 @@
|
||||
"nanoid": "^5.1.6",
|
||||
"nest-winston": "^1.9.7",
|
||||
"nestjs-cls": "^6.0.0",
|
||||
"nodemailer": "^8.0.4",
|
||||
"nodemailer": "^8.0.11",
|
||||
"on-headers": "^1.1.0",
|
||||
"piscina": "^5.1.4",
|
||||
"prisma": "^6.6.0",
|
||||
@@ -102,7 +102,7 @@
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.7.4",
|
||||
"ses": "^1.15.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"stripe": "^17.7.0",
|
||||
"tldts": "^7.0.19",
|
||||
"winston": "^3.17.0",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"react-dom": "^19.2.1",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.7",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-router-dom": "^6.30.3"
|
||||
"react-router-dom": "^6.30.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^7.6.5",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-squirrel-startup": "1.0.1",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild": "^0.25.12",
|
||||
"fs-extra": "^11.2.0",
|
||||
"glob": "^11.0.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"next-themes": "^0.4.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-router-dom": "^6.30.3"
|
||||
"react-router-dom": "^6.30.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine-tools/cli": "workspace:*",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@toeverything/infra": "workspace:*",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-router-dom": "^6.30.3"
|
||||
"react-router-dom": "^6.30.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.1",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@toeverything/infra": "workspace:*",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-router-dom": "^6.30.3"
|
||||
"react-router-dom": "^6.30.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.1",
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-paginate": "^8.3.0",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"react-transition-state": "^2.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.7",
|
||||
@@ -81,7 +81,7 @@
|
||||
"storybook": "^10.2.14",
|
||||
"typescript": "^5.9.3",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vite": "^7.2.7",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"version": "0.26.3"
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"cmdk": "^1.0.4",
|
||||
"core-js": "^3.39.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dompurify": "^3.3.0",
|
||||
"dompurify": "^3.4.11",
|
||||
"eventemitter2": "^6.4.9",
|
||||
"file-type": "^21.0.0",
|
||||
"filesize": "^10.1.6",
|
||||
@@ -82,7 +82,7 @@
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-error-boundary": "^6.0.0",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"react-transition-state": "^2.2.0",
|
||||
"react-virtuoso": "^4.12.3",
|
||||
"recharts": "^2.15.4",
|
||||
@@ -98,6 +98,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@playwright/test": "=1.58.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/bytes": "^3.1.5",
|
||||
@@ -107,6 +108,7 @@
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"happy-dom": "^20.3.0",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,12 +41,12 @@ describe('preview render bridge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('uses worker renderers and only sanitizes mermaid output', async () => {
|
||||
test('uses worker renderers and sanitizes preview svg output', async () => {
|
||||
mermaidRender.mockResolvedValue({
|
||||
svg: '<svg><script>alert(1)</script><text>mermaid</text></svg>',
|
||||
});
|
||||
typstRender.mockResolvedValue({
|
||||
svg: '<div><script>window.__xss__=1</script><svg><text>typst</text></svg></div>',
|
||||
svg: '<svg><script>window.__xss__=1</script><text>typst</text></svg>',
|
||||
});
|
||||
|
||||
const mermaid = await renderMermaidSvg({ code: 'flowchart TD;A-->B' });
|
||||
@@ -57,9 +57,9 @@ describe('preview render bridge', () => {
|
||||
expect(mermaid.svg).toContain('<svg');
|
||||
expect(mermaid.svg).toContain('mermaid');
|
||||
expect(mermaid.svg).not.toContain('<script');
|
||||
expect(typst.svg).toBe(
|
||||
'<div><script>window.__xss__=1</script><svg><text>typst</text></svg></div>'
|
||||
);
|
||||
expect(typst.svg).toContain('<svg');
|
||||
expect(typst.svg).toContain('typst');
|
||||
expect(typst.svg).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('sanitizeSvg keeps svg text nodes', () => {
|
||||
@@ -101,6 +101,37 @@ describe('preview render bridge', () => {
|
||||
expect(sanitized).not.toContain('<script');
|
||||
});
|
||||
|
||||
test('sanitizeSvg wraps sanitized svg fragments back into root svg', () => {
|
||||
if (typeof DOMParser === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
domPurifySanitize.mockImplementation((value: unknown) => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return '<rect width="100" height="100"></rect>';
|
||||
});
|
||||
|
||||
const sanitized = sanitizeSvg(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100"></rect></svg>'
|
||||
);
|
||||
|
||||
expect(sanitized).toContain('<svg');
|
||||
expect(sanitized).toContain('width="100"');
|
||||
expect(sanitized).toContain('<rect');
|
||||
});
|
||||
|
||||
test('throws when sanitized typst svg is empty', async () => {
|
||||
typstRender.mockResolvedValue({
|
||||
svg: '<div><text>invalid</text></div>',
|
||||
});
|
||||
|
||||
await expect(renderTypstSvg({ code: '= Title' })).rejects.toThrow(
|
||||
'Preview renderer returned invalid SVG.'
|
||||
);
|
||||
});
|
||||
|
||||
test('throws when sanitized svg is empty', async () => {
|
||||
mermaidRender.mockResolvedValue({
|
||||
svg: '<div><text>invalid</text></div>',
|
||||
|
||||
@@ -10,56 +10,10 @@ import type {
|
||||
TypstRenderRequest,
|
||||
TypstRenderResult,
|
||||
} from '@affine/core/modules/typst/renderer';
|
||||
import type { Config } from 'dompurify';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/** Mermaid SVG uses `<use>`, `<style>`, and sometimes `<foreignObject>` for labels. */
|
||||
const MERMAID_SVG_SANITIZE_CONFIG: Config = {
|
||||
USE_PROFILES: { svg: true },
|
||||
ADD_TAGS: ['use'],
|
||||
ADD_ATTR: ['href', 'xlink:href', 'class', 'style', 'id'],
|
||||
};
|
||||
|
||||
const FOREIGN_OBJECT_HTML_SANITIZE_CONFIG: Config = {
|
||||
USE_PROFILES: { html: true },
|
||||
};
|
||||
|
||||
function sanitizeForeignObjects(root: ParentNode) {
|
||||
root.querySelectorAll('foreignObject, foreignobject').forEach(element => {
|
||||
element.innerHTML = DOMPurify.sanitize(
|
||||
element.innerHTML,
|
||||
FOREIGN_OBJECT_HTML_SANITIZE_CONFIG
|
||||
);
|
||||
});
|
||||
}
|
||||
import { sanitizeSvg as sanitizeSvgDocument } from '@blocksuite/affine-shared/utils';
|
||||
|
||||
export function sanitizeSvg(svg: string): string {
|
||||
if (
|
||||
typeof DOMParser === 'undefined' ||
|
||||
typeof XMLSerializer === 'undefined'
|
||||
) {
|
||||
const sanitized = DOMPurify.sanitize(svg, MERMAID_SVG_SANITIZE_CONFIG);
|
||||
if (typeof sanitized !== 'string' || !/^\s*<svg[\s>]/i.test(sanitized)) {
|
||||
return '';
|
||||
}
|
||||
return sanitized.trim();
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const parsed = parser.parseFromString(svg, 'image/svg+xml');
|
||||
const root = parsed.documentElement;
|
||||
if (!root || root.tagName.toLowerCase() !== 'svg') return '';
|
||||
|
||||
const sanitized = DOMPurify.sanitize(root, MERMAID_SVG_SANITIZE_CONFIG);
|
||||
if (typeof sanitized !== 'string') return '';
|
||||
|
||||
const sanitizedDoc = parser.parseFromString(sanitized, 'image/svg+xml');
|
||||
const sanitizedRoot = sanitizedDoc.documentElement;
|
||||
if (!sanitizedRoot || sanitizedRoot.tagName.toLowerCase() !== 'svg')
|
||||
return '';
|
||||
|
||||
sanitizeForeignObjects(sanitizedRoot);
|
||||
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
|
||||
return sanitizeSvgDocument(svg);
|
||||
}
|
||||
|
||||
export async function renderMermaidSvg(
|
||||
@@ -79,5 +33,9 @@ export async function renderTypstSvg(
|
||||
): Promise<TypstRenderResult> {
|
||||
const rendered = await renderTypstSvgBackend(request);
|
||||
|
||||
return { svg: rendered.svg };
|
||||
const sanitizedSvg = sanitizeSvgDocument(rendered.svg);
|
||||
if (!sanitizedSvg) {
|
||||
throw new Error('Preview renderer returned invalid SVG.');
|
||||
}
|
||||
return { svg: sanitizedSvg };
|
||||
}
|
||||
|
||||
+10
@@ -90,4 +90,14 @@ describe('renderClassicMermaidSvg', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps mermaid classic renderer in strict security mode', async () => {
|
||||
render.mockResolvedValue({ svg: '<svg>ok</svg>' });
|
||||
|
||||
await renderClassicMermaidSvg({ code: 'flowchart TD;A-->B' });
|
||||
|
||||
expect(initialize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ securityLevel: 'strict' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+166
-26
@@ -1,39 +1,179 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { extname, join, resolve } from 'node:path';
|
||||
|
||||
import { sanitizeSvg } from './bridge';
|
||||
import { renderClassicMermaidSvg } from './classic-mermaid';
|
||||
import { type Browser, chromium } from '@playwright/test';
|
||||
import type DOMPurifyDefault from 'dompurify';
|
||||
import type { Mermaid } from 'mermaid';
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
||||
|
||||
const canRunDomIntegration =
|
||||
typeof document !== 'undefined' &&
|
||||
typeof DOMParser !== 'undefined' &&
|
||||
typeof XMLSerializer !== 'undefined';
|
||||
const workspaceRoot = resolve('.');
|
||||
|
||||
describe.skipIf(!canRunDomIntegration)('mermaid preview integration', () => {
|
||||
test('flowchart labels survive classic render and svg sanitization', async () => {
|
||||
const { svg: raw } = await renderClassicMermaidSvg({
|
||||
code: 'flowchart TD; A-->B',
|
||||
options: { theme: 'default' },
|
||||
});
|
||||
let server: Server;
|
||||
let serverUrl: string;
|
||||
let browser: Browser;
|
||||
|
||||
expect(raw).toMatch(/<svg[\s>]/i);
|
||||
function contentType(path: string) {
|
||||
switch (extname(path)) {
|
||||
case '.js':
|
||||
case '.mjs':
|
||||
return 'text/javascript';
|
||||
case '.json':
|
||||
return 'application/json';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
const sanitized = sanitizeSvg(raw);
|
||||
expect(sanitized).toMatch(/<svg[\s>]/i);
|
||||
|
||||
// happy-dom cannot lay out Mermaid (CSSStyleSheet); skip empty output.
|
||||
if (!/<(?:rect|path|circle|polygon)\b/i.test(sanitized)) {
|
||||
beforeAll(async () => {
|
||||
server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? '/', 'http://localhost');
|
||||
if (url.pathname === '/') {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end('<!doctype html><html><body></body></html>');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLabelText =
|
||||
/>\s*A\s*</i.test(sanitized) ||
|
||||
/>\s*B\s*</i.test(sanitized) ||
|
||||
/foreignObject[\s\S]*>\s*A\s*</i.test(sanitized) ||
|
||||
/<tspan[^>]*>\s*A\s*</i.test(sanitized);
|
||||
const filePath = resolve(
|
||||
join(workspaceRoot, decodeURIComponent(url.pathname))
|
||||
);
|
||||
|
||||
expect(hasLabelText).toBe(true);
|
||||
if (!filePath.startsWith(workspaceRoot)) {
|
||||
response.writeHead(403);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
response.setHeader('Content-Type', contentType(filePath));
|
||||
response.writeHead(200);
|
||||
createReadStream(filePath)
|
||||
.on('error', () => {
|
||||
if (!response.headersSent) {
|
||||
response.writeHead(404);
|
||||
}
|
||||
response.end();
|
||||
})
|
||||
.pipe(response);
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to start mermaid preview test server.');
|
||||
}
|
||||
serverUrl = `http://127.0.0.1:${address.port}`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe('mermaid preview integration', () => {
|
||||
test('flowchart labels survive strict classic render and svg sanitization in browser', async () => {
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(serverUrl);
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const browserImport = new Function('url', 'return import(url)') as <T>(
|
||||
url: string
|
||||
) => Promise<T>;
|
||||
const mermaid = (
|
||||
await browserImport<{ default: Mermaid }>(
|
||||
'/node_modules/mermaid/dist/mermaid.esm.mjs'
|
||||
)
|
||||
).default;
|
||||
const DOMPurify = (
|
||||
await browserImport<{ default: typeof DOMPurifyDefault }>(
|
||||
'/node_modules/dompurify/dist/purify.es.mjs'
|
||||
)
|
||||
).default;
|
||||
|
||||
const sanitizeSvg = (svg: string) => {
|
||||
const sanitizeConfig = {
|
||||
USE_PROFILES: { svg: true },
|
||||
ADD_TAGS: ['use'],
|
||||
ADD_ATTR: ['href', 'xlink:href', 'class', 'style', 'id'],
|
||||
};
|
||||
const foreignObjectConfig = {
|
||||
USE_PROFILES: { html: true },
|
||||
};
|
||||
const parser = new DOMParser();
|
||||
const parsed = parser.parseFromString(svg, 'image/svg+xml');
|
||||
const root = parsed.documentElement;
|
||||
|
||||
if (!root || root.tagName.toLowerCase() !== 'svg') return '';
|
||||
|
||||
const sanitized = DOMPurify.sanitize(root, sanitizeConfig);
|
||||
if (typeof sanitized !== 'string') return '';
|
||||
|
||||
const sanitizedDoc = parser.parseFromString(
|
||||
sanitized,
|
||||
'image/svg+xml'
|
||||
);
|
||||
const sanitizedRoot = sanitizedDoc.documentElement;
|
||||
if (!sanitizedRoot || sanitizedRoot.tagName.toLowerCase() !== 'svg') {
|
||||
return '';
|
||||
}
|
||||
|
||||
sanitizedRoot
|
||||
.querySelectorAll('foreignObject, foreignobject')
|
||||
.forEach(element => {
|
||||
element.innerHTML = DOMPurify.sanitize(
|
||||
element.innerHTML,
|
||||
foreignObjectConfig
|
||||
);
|
||||
});
|
||||
|
||||
return new XMLSerializer().serializeToString(sanitizedRoot).trim();
|
||||
};
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'default',
|
||||
securityLevel: 'strict',
|
||||
htmlLabels: false,
|
||||
fontFamily: 'IBM Plex Mono',
|
||||
flowchart: { useMaxWidth: true },
|
||||
sequence: { useMaxWidth: true },
|
||||
gantt: { useMaxWidth: true },
|
||||
pie: { useMaxWidth: true },
|
||||
journey: { useMaxWidth: true },
|
||||
gitGraph: { useMaxWidth: true },
|
||||
});
|
||||
|
||||
const { svg: raw } = await mermaid.render(
|
||||
`mermaid-diagram-${Date.now()}`,
|
||||
'flowchart TD; A-->B'
|
||||
);
|
||||
const sanitized = sanitizeSvg(raw);
|
||||
|
||||
return {
|
||||
raw,
|
||||
sanitized,
|
||||
hasLabelText:
|
||||
/>\s*A\s*</i.test(sanitized) ||
|
||||
/>\s*B\s*</i.test(sanitized) ||
|
||||
/foreignObject[\s\S]*>\s*A\s*</i.test(sanitized) ||
|
||||
/<tspan[^>]*>\s*A\s*</i.test(sanitized),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.raw).toMatch(/<svg[\s>]/i);
|
||||
expect(result.sanitized).toMatch(/<svg[\s>]/i);
|
||||
expect(result.sanitized).toMatch(/<(?:rect|path|circle|polygon)\b/i);
|
||||
expect(result.hasLabelText).toBe(true);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { type Browser, chromium } from '@playwright/test';
|
||||
import { createServer, type ViteDevServer } from 'vite';
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
||||
|
||||
const typstDemo = String.raw`#set page(paper: "a5")
|
||||
#set heading(numbering: "1.")
|
||||
|
||||
#show link: set text(fill: blue, weight: 700)
|
||||
#show link: underline
|
||||
|
||||
= The Typst Playground
|
||||
|
||||
Welcome to the Typst Playground! This is a sandbox where you can experiment with Typst.
|
||||
|
||||
= Basics <basics>
|
||||
|
||||
Typst is a _markup_ language. You use it to express not just the content, but also the structure and formatting of your document.
|
||||
|
||||
- *Strongly emphasize* some text
|
||||
- Refer to @basics
|
||||
- Typeset math: $a, b in { 1/2, sqrt(4 a b) }$
|
||||
|
||||
= Next steps
|
||||
|
||||
To learn more about Typst, check out https://typst.app/docs/tutorial.`;
|
||||
|
||||
let server: ViteDevServer;
|
||||
let serverUrl: string;
|
||||
let browser: Browser;
|
||||
|
||||
const typstRuntimeUrl = `/@fs/${resolve(
|
||||
'packages/frontend/core/src/modules/typst/renderer/runtime.ts'
|
||||
)}`;
|
||||
const svgUtilsUrl = `/@fs/${resolve(
|
||||
'blocksuite/affine/shared/src/utils/svg.ts'
|
||||
)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createServer({
|
||||
logLevel: 'silent',
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
},
|
||||
});
|
||||
await server.listen();
|
||||
|
||||
const address = server.httpServer?.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to start typst preview test server.');
|
||||
}
|
||||
serverUrl = `http://127.0.0.1:${address.port}`;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
describe('typst preview integration', () => {
|
||||
test('sanitization preserves rendered glyphs, styles, and link colors', async () => {
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.goto(serverUrl);
|
||||
|
||||
const result = await page.evaluate(
|
||||
async ({ code, svgUtilsUrl, typstRuntimeUrl }) => {
|
||||
const browserImport = new Function('url', 'return import(url)') as <
|
||||
T,
|
||||
>(
|
||||
url: string
|
||||
) => Promise<T>;
|
||||
const [{ renderTypstSvgWithOptions }, { sanitizeSvg }] =
|
||||
await Promise.all([
|
||||
browserImport<{
|
||||
renderTypstSvgWithOptions: (
|
||||
code: string,
|
||||
options: { fontUrls: string[] }
|
||||
) => Promise<{ svg: string }>;
|
||||
}>(typstRuntimeUrl),
|
||||
browserImport<{
|
||||
sanitizeSvg: (svg: string) => string;
|
||||
}>(svgUtilsUrl),
|
||||
]);
|
||||
const { svg: raw } = await renderTypstSvgWithOptions(code, {
|
||||
fontUrls: [],
|
||||
});
|
||||
const sanitized = sanitizeSvg(raw);
|
||||
const parse = (svg: string) =>
|
||||
new DOMParser().parseFromString(svg, 'image/svg+xml');
|
||||
const rawDoc = parse(raw);
|
||||
const sanitizedDoc = parse(sanitized);
|
||||
|
||||
return {
|
||||
linkHrefs: Array.from(sanitizedDoc.querySelectorAll('a')).map(
|
||||
element =>
|
||||
element.getAttribute('href') ??
|
||||
element.getAttribute('xlink:href') ??
|
||||
''
|
||||
),
|
||||
rawUseCount: rawDoc.querySelectorAll('use').length,
|
||||
sanitized,
|
||||
sanitizedRoot: sanitizedDoc.documentElement.tagName,
|
||||
sanitizedUseCount: sanitizedDoc.querySelectorAll('use').length,
|
||||
hasParserError: !!sanitizedDoc.querySelector('parsererror'),
|
||||
};
|
||||
},
|
||||
{ code: typstDemo, svgUtilsUrl, typstRuntimeUrl }
|
||||
);
|
||||
|
||||
expect(result.sanitizedRoot).toBe('svg');
|
||||
expect(result.hasParserError).toBe(false);
|
||||
expect(result.rawUseCount).toBeGreaterThan(0);
|
||||
expect(result.sanitizedUseCount).toBe(result.rawUseCount);
|
||||
expect(result.linkHrefs).toContain('https://typst.app/docs/tutorial');
|
||||
expect(result.sanitized).toContain('typst-text');
|
||||
expect(result.sanitized).toContain('#0074d9');
|
||||
expect(result.sanitized).not.toContain('<script');
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -25,7 +25,7 @@
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"socket.io": "^4.7.4",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"swr": "^2.3.7",
|
||||
"tailwindcss": "^4.1.17"
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.1",
|
||||
"react-router-dom": "^7.12.0"
|
||||
"react-router-dom": "^7.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"@affine/debug": "workspace:*",
|
||||
"@sentry/react": "^10.53.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"react-router-dom": "^6.30.3"
|
||||
"react-router-dom": "^6.30.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.1",
|
||||
|
||||
Reference in New Issue
Block a user