diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index fc4a521e..c6db5524 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -16,7 +16,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} - BUN_VERSION: 1.3.12 + BUN_VERSION: 1.3.13 jobs: build-and-push: diff --git a/Dockerfile b/Dockerfile index b29f78ab..ecf0f1ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:1.3.12-debian AS builder +FROM oven/bun:1.3.13-debian AS builder WORKDIR /app # Skip the Chromium auto-download triggered by Playwright's postinstall — @@ -35,14 +35,14 @@ RUN bun run build # No copyleft. See THIRD_PARTY_LICENSES.md at the repo root for # attribution. # --------------------------------------------------------------------- -FROM rust:1.94-slim-bookworm AS mermaid-builder +FROM rust:1.95-slim-bookworm AS mermaid-builder # `--locked` consumes the Cargo.lock published with the crate so the # binary is reproducible; bumping ${MMDR_VERSION} is the only knob # that should change the resulting bytes. ARG MMDR_VERSION=0.2.2 RUN cargo install mermaid-rs-renderer --version ${MMDR_VERSION} --locked --root /out -FROM oven/bun:1.3.12-debian AS runner +FROM oven/bun:1.3.13-debian AS runner WORKDIR /app ENV NODE_ENV=production diff --git a/apps/server/package.json b/apps/server/package.json index 73163949..9a8dfb91 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,9 +10,9 @@ "dependencies": { "@marginalia/renderer": "workspace:*", "@marginalia/themes": "workspace:*", - "hono": "^4.6.14", - "isomorphic-git": "^1.27.1", - "mermaid": "^11.4.1", + "hono": "^4.12.18", + "isomorphic-git": "^1.37.6", + "mermaid": "^11.14.0", "playwright": "^1.59.1" }, "devDependencies": { diff --git a/apps/server/src/anchoring.ts b/apps/server/src/anchoring.ts index 0c653d0b..1ca1e23f 100644 --- a/apps/server/src/anchoring.ts +++ b/apps/server/src/anchoring.ts @@ -83,8 +83,7 @@ export function reanchor(comment: CommentRow, blocks: BlockInfo[]): AnchorUpdate // 3. prefix + quote + suffix fuzzy search — again, prefer the structurally // closest candidate. - const context = - (comment.anchor_prefix ?? '') + quote + (comment.anchor_suffix ?? ''); + const context = (comment.anchor_prefix ?? '') + quote + (comment.anchor_suffix ?? ''); if (context.length > quote.length) { const ctxMatches = blocks .filter((b) => b.text.includes(context)) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index e8c910b6..1e94be52 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,15 +1,15 @@ -import { extname, isAbsolute, join, normalize, relative } from 'node:path'; import type { Database } from 'bun:sqlite'; +import { extname, isAbsolute, join, normalize, relative } from 'node:path'; import type { ServerWebSocket } from 'bun'; import { Hono } from 'hono'; import { createBunWebSocket } from 'hono/bun'; import { createBlobStore } from './blob-store.js'; import type { ServerConfig } from './config.js'; import { dropLegacyProposalColumns, openDatabase } from './db.js'; +import { closeExportBrowser } from './export/pdf.js'; import { GitStore } from './git-store.js'; import { backfillProposalBranches } from './proposal-branch-backfill.js'; import { Realtime } from './realtime.js'; -import { closeExportBrowser } from './export/pdf.js'; import { assetsRouter } from './routes/assets.js'; import { documentsRouter } from './routes/documents.js'; import { eventsRouter } from './routes/events.js'; diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index 3c5f376e..dd2bec9f 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -167,7 +167,7 @@ export function authorize( const invSession = inviteSessionToken ? readSession(db, inviteSessionToken) : null; const validInviteSession = invSession?.doc_uid === doc.uid ? invSession : null; - const isInviteSession = !!(validInviteSession?.invite_role); + const isInviteSession = !!validInviteSession?.invite_role; // Password gate is unconditional — invites (including admin) and invite // sessions never bypass it. Only a password-type session satisfies it. @@ -219,7 +219,13 @@ export function authorize( } } const identity = clientId && resolvedName ? { clientId, displayName: resolvedName } : null; - return recordAndReturn(db, doc.uid, { ok: true, role, identity, invite: null, isInviteSession: true }); + return recordAndReturn(db, doc.uid, { + ok: true, + role, + identity, + invite: null, + isInviteSession: true, + }); } if (invite) { diff --git a/apps/server/src/concurrency.ts b/apps/server/src/concurrency.ts index 147e654a..b439141c 100644 --- a/apps/server/src/concurrency.ts +++ b/apps/server/src/concurrency.ts @@ -16,16 +16,13 @@ export async function mapWithConcurrency( } const out: R[] = new Array(items.length); let next = 0; - const workers = Array.from( - { length: Math.min(Math.floor(limit), items.length) }, - async () => { - while (true) { - const i = next++; - if (i >= items.length) return; - out[i] = await fn(items[i] as T, i); - } - }, - ); + const workers = Array.from({ length: Math.min(Math.floor(limit), items.length) }, async () => { + while (true) { + const i = next++; + if (i >= items.length) return; + out[i] = await fn(items[i] as T, i); + } + }); await Promise.all(workers); return out; } diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index a00d66be..a1536b87 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -74,9 +74,7 @@ function parseBlobStorageEnv(): 'fs' | 's3' { const normalized = raw.trim().toLowerCase(); if (normalized === '' || normalized === 'fs') return 'fs'; if (normalized === 's3') return 's3'; - throw new Error( - `MARGINALIA_BLOB_STORAGE must be "fs" or "s3" (got: ${JSON.stringify(raw)}).`, - ); + throw new Error(`MARGINALIA_BLOB_STORAGE must be "fs" or "s3" (got: ${JSON.stringify(raw)}).`); } /** @@ -100,9 +98,7 @@ function loadS3ConfigFromEnv(): S3StorageConfig { function requireEnv(name: string): string { const v = process.env[name]; if (!v) { - throw new Error( - `MARGINALIA_BLOB_STORAGE=s3 requires ${name} (see README for S3 config).`, - ); + throw new Error(`MARGINALIA_BLOB_STORAGE=s3 requires ${name} (see README for S3 config).`); } return v; } diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index 8a7085dd..33ab3eba 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -1,6 +1,6 @@ import { Database } from 'bun:sqlite'; -import { dirname } from 'node:path'; import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; const SCHEMA = ` CREATE TABLE IF NOT EXISTS documents ( diff --git a/apps/server/src/export/html-envelope.ts b/apps/server/src/export/html-envelope.ts index 6eac32d7..64414b6b 100644 --- a/apps/server/src/export/html-envelope.ts +++ b/apps/server/src/export/html-envelope.ts @@ -191,7 +191,8 @@ export async function prerasterizeMermaid( // `[\s\S]*?` lets the inner source span newlines without needing // the `s` flag. The capture groups are: (1) the data-mermaid-index // value, (2) the inner (escaped) source text. - const re = /]*\bclass="mermaid"[^>]*\bdata-mermaid-index="(\d+)"[^>]*>([\s\S]*?)<\/div>/g; + const re = + /]*\bclass="mermaid"[^>]*\bdata-mermaid-index="(\d+)"[^>]*>([\s\S]*?)<\/div>/g; interface Hit { start: number; end: number; @@ -293,7 +294,8 @@ export function countLiveMermaidBlocks(html: string): number { // attribute somewhere in the tag. The lookahead doesn't anchor // attribute order — both upstream plugins emit different orderings // and we shouldn't depend on either. - const re = /]*\bclass="[^"]*\bmermaid\b[^"]*"(?=[^>]*\bdata-mermaid-(?:index|mode)=)[^>]*>/g; + const re = + /]*\bclass="[^"]*\bmermaid\b[^"]*"(?=[^>]*\bdata-mermaid-(?:index|mode)=)[^>]*>/g; return (html.match(re) ?? []).length; } @@ -369,7 +371,7 @@ export async function inlineImageAssets( // (e.g. `alt="logo.png image"` with `src="logo.png"` would target // the alt text). const hits: Hit[] = []; - const imgRe = /]*\bsrc="([^"]+)"[^>]*>/gd; + const imgRe = /]*\bsrc="([^"]+)"[^>]*>/dg; for (let m: RegExpExecArray | null; (m = imgRe.exec(html)); ) { const src = m[1]!; if (isAbsoluteUrl(src)) continue; @@ -426,7 +428,7 @@ function toDataUrl(bytes: Uint8Array, mime: string): string { function escapeMime(mime: string): string { // Defensive: the attached.mime comes from the DB, but make sure a // rogue value can't escape the data-URL grammar. - return mime.replace(/[^a-zA-Z0-9/+.\-]/g, ''); + return mime.replace(/[^a-zA-Z0-9/+.-]/g, ''); } function isAbsoluteUrl(src: string): boolean { diff --git a/apps/server/src/export/mermaid-chromium.ts b/apps/server/src/export/mermaid-chromium.ts index bec54a37..7f26b42d 100644 --- a/apps/server/src/export/mermaid-chromium.ts +++ b/apps/server/src/export/mermaid-chromium.ts @@ -13,12 +13,6 @@ * `BrowserContext` so state never leaks across diagrams. */ import type { BrowserContext } from 'playwright'; - -import { - ALLOWED_EXPORT_HOSTS, - ExportEngineMissingError as PdfEngineMissingError, - getBrowser, -} from './pdf.js'; import { loadMermaidUmd } from './html-envelope.js'; import { type MermaidImageFormat, @@ -27,6 +21,11 @@ import { MermaidRenderTimeoutError, type RenderedMermaidImage, } from './mermaid-rust.js'; +import { + ALLOWED_EXPORT_HOSTS, + getBrowser, + ExportEngineMissingError as PdfEngineMissingError, +} from './pdf.js'; // --------------------------------------------------------------------- // Configuration @@ -63,8 +62,7 @@ let config: Config = readConfigFromEnv(); function readConfigFromEnv(): Config { const t = process.env.MARGINALIA_MERMAID_CHROMIUM_TIMEOUT_MS; - const timeoutMs = - t && Number.isInteger(Number(t)) && Number(t) >= 100 ? Number(t) : 15_000; + const timeoutMs = t && Number.isInteger(Number(t)) && Number(t) >= 100 ? Number(t) : 15_000; const s = process.env.MARGINALIA_MERMAID_CHROMIUM_PNG_SCALE; const pngScale = s && Number.isFinite(Number(s)) && Number(s) > 0 ? Number(s) : 4; return { timeoutMs, pngScale }; @@ -225,7 +223,9 @@ export async function renderMermaidWithChromium( // instead of an opaque playwright one. try { await page.waitForFunction( - () => (window as unknown as { __marginaliaMermaidReady?: boolean }).__marginaliaMermaidReady === true, + () => + (window as unknown as { __marginaliaMermaidReady?: boolean }).__marginaliaMermaidReady === + true, undefined, { timeout: config.timeoutMs }, ); diff --git a/apps/server/src/export/mermaid-rust.ts b/apps/server/src/export/mermaid-rust.ts index 7ee0d355..02714f71 100644 --- a/apps/server/src/export/mermaid-rust.ts +++ b/apps/server/src/export/mermaid-rust.ts @@ -241,11 +241,7 @@ function buildArgv(outPath: string, format: MermaidImageFormat): string[] { return ['-i', '-', '-o', outPath, '-e', format]; } -function runRenderer( - source: string, - outPath: string, - format: MermaidImageFormat, -): Promise { +function runRenderer(source: string, outPath: string, format: MermaidImageFormat): Promise { return new Promise((resolve, reject) => { const argv = buildArgv(outPath, format); const child = spawn(config.bin, argv, { @@ -295,10 +291,7 @@ function runRenderer( return; } reject( - new MermaidRenderError( - `Mermaid renderer exited with code ${code ?? '?'}`, - stderr.trim(), - ), + new MermaidRenderError(`Mermaid renderer exited with code ${code ?? '?'}`, stderr.trim()), ); }); diff --git a/apps/server/src/export/pdf.ts b/apps/server/src/export/pdf.ts index f1360993..582c3ca9 100644 --- a/apps/server/src/export/pdf.ts +++ b/apps/server/src/export/pdf.ts @@ -112,9 +112,7 @@ function parsePositiveIntEnv(name: string, defaultValue: number): number { if (raw === undefined || raw.trim() === '') return defaultValue; const parsed = Number(raw); if (!Number.isInteger(parsed) || parsed < 1) { - throw new Error( - `${name} must be a positive integer; got ${JSON.stringify(raw)}`, - ); + throw new Error(`${name} must be a positive integer; got ${JSON.stringify(raw)}`); } return parsed; } @@ -225,8 +223,7 @@ export async function getBrowser(): Promise { // `'chromium'` when debugging against a local full-Chrome // install, or to `''` to let Playwright pick its default. const channelOverride = process.env.MARGINALIA_PDF_CHANNEL; - const channel = - channelOverride === undefined ? 'chromium-headless-shell' : channelOverride; + const channel = channelOverride === undefined ? 'chromium-headless-shell' : channelOverride; return await chromium.launch({ headless: true, ...(channel ? { channel } : {}), @@ -463,26 +460,28 @@ export async function exportPdf(opts: ExportPdfOptions): Promise { // Local asset refs never reach this layer — they were already // inlined as `data:` URLs by inlineImageAssets() in the HTML // envelope. - await race(page.route('**/*', (route) => { - const url = route.request().url(); - if (url.startsWith('data:') || url.startsWith('about:')) { - return route.continue(); - } - try { - const { protocol, hostname } = new URL(url); - // `https:` only — no cleartext HTTP even for allowlisted hosts. - // Protects against MITM-triggered content swaps and stops - // anyone with network-path access from coaxing the worker - // into a downgrade attack via a user-authored `http://…` - // asset that happens to resolve to an allowlist hostname. - if (protocol === 'https:' && ALLOWED_EXPORT_HOSTS.has(hostname)) { + await race( + page.route('**/*', (route) => { + const url = route.request().url(); + if (url.startsWith('data:') || url.startsWith('about:')) { return route.continue(); } - } catch { - // Unparseable URL — fall through to abort. - } - return route.abort('blockedbyclient'); - })); + try { + const { protocol, hostname } = new URL(url); + // `https:` only — no cleartext HTTP even for allowlisted hosts. + // Protects against MITM-triggered content swaps and stops + // anyone with network-path access from coaxing the worker + // into a downgrade attack via a user-authored `http://…` + // asset that happens to resolve to an allowlist hostname. + if (protocol === 'https:' && ALLOWED_EXPORT_HOSTS.has(hostname)) { + return route.continue(); + } + } catch { + // Unparseable URL — fall through to abort. + } + return route.abort('blockedbyclient'); + }), + ); // Conditional spread — `mermaidUmd` is only present on the object // when we actually loaded it, so `exactOptionalPropertyTypes` diff --git a/apps/server/src/export/theme-css.ts b/apps/server/src/export/theme-css.ts index dd2822b4..06cd1281 100644 --- a/apps/server/src/export/theme-css.ts +++ b/apps/server/src/export/theme-css.ts @@ -30,7 +30,7 @@ import { fileURLToPath } from 'node:url'; * `.exec()` loops would read / reset each other's `lastIndex` and * miss imports). Constructing per call is the defensive fix. */ -const RELATIVE_IMPORT_PATTERN = "@import\\s+(['\"])((?:\\.{1,2}/)[^'\"]+?)\\1\\s*;"; +const RELATIVE_IMPORT_PATTERN = '@import\\s+([\'"])((?:\\.{1,2}/)[^\'"]+?)\\1\\s*;'; /** In-memory cache of resolved theme CSS keyed by theme name. Flipped * per-process: the themes package only changes on deploy. */ diff --git a/apps/server/src/git-store.ts b/apps/server/src/git-store.ts index 54fef485..16df49b4 100644 --- a/apps/server/src/git-store.ts +++ b/apps/server/src/git-store.ts @@ -1,7 +1,6 @@ import { execFile } from 'node:child_process'; import { randomBytes } from 'node:crypto'; -import fs from 'node:fs'; -import { +import fs, { existsSync, mkdirSync, mkdtempSync, diff --git a/apps/server/src/proposal-branch-backfill.ts b/apps/server/src/proposal-branch-backfill.ts index f139440e..63f4cb3a 100644 --- a/apps/server/src/proposal-branch-backfill.ts +++ b/apps/server/src/proposal-branch-backfill.ts @@ -26,8 +26,9 @@ export async function backfillProposalBranches( } const rows = ( docUid === undefined - ? db.prepare( - `SELECT cep.comment_id AS id, + ? db + .prepare( + `SELECT cep.comment_id AS id, c.doc_uid AS doc_uid, c.anchor_block_id AS anchor_block_id, c.author_client_id AS author_client_id, @@ -45,7 +46,8 @@ export async function backfillProposalBranches( AND cep.branch_ref IS NULL AND c.deleted_at IS NULL AND c.anchor_block_id IS NOT NULL`, - ).all() + ) + .all() : db .prepare( `SELECT cep.comment_id AS id, @@ -195,4 +197,3 @@ export async function backfillProposalBranches( return { migrated, skipped }; } - diff --git a/apps/server/src/routes/assets.ts b/apps/server/src/routes/assets.ts index b51cabba..c5c7adf8 100644 --- a/apps/server/src/routes/assets.ts +++ b/apps/server/src/routes/assets.ts @@ -1,7 +1,7 @@ import type { Database } from 'bun:sqlite'; -import { Hono } from 'hono'; import type { Context } from 'hono'; -import { INVITE_SESSION_COOKIE, SESSION_COOKIE, authorize, canEdit, parseCookie } from '../auth.js'; +import { Hono } from 'hono'; +import { authorize, canEdit, INVITE_SESSION_COOKIE, parseCookie, SESSION_COOKIE } from '../auth.js'; import type { BlobStore } from '../blob-store.js'; import type { ServerConfig } from '../config.js'; import type { AssetKind, AssetRow, DocumentAssetRow, DocumentRow } from '../db.js'; @@ -103,9 +103,7 @@ async function uploadAsset(c: Context, { db, blobs, config }: AssetsDeps) { ).run(assetId, mime, file.size, now); const existing = db - .prepare( - 'SELECT asset_id FROM document_assets WHERE doc_uid = ? AND ref_name = ?', - ) + .prepare('SELECT asset_id FROM document_assets WHERE doc_uid = ? AND ref_name = ?') .get(doc.uid, refName) as { asset_id: string } | undefined; db.prepare( @@ -248,15 +246,14 @@ async function deleteAsset(c: Context, { db, blobs }: AssetsDeps) { if (!refName) return c.json({ error: 'not-found' }, 404); const row = db - .prepare( - 'SELECT asset_id FROM document_assets WHERE doc_uid = ? AND ref_name = ?', - ) + .prepare('SELECT asset_id FROM document_assets WHERE doc_uid = ? AND ref_name = ?') .get(doc.uid, refName) as { asset_id: string } | undefined; if (!row) return c.json({ error: 'not-found' }, 404); - db.prepare( - 'DELETE FROM document_assets WHERE doc_uid = ? AND ref_name = ?', - ).run(doc.uid, refName); + db.prepare('DELETE FROM document_assets WHERE doc_uid = ? AND ref_name = ?').run( + doc.uid, + refName, + ); await gcAssetIfOrphan(db, blobs, row.asset_id); return c.body(null, 204); } diff --git a/apps/server/src/routes/documents.ts b/apps/server/src/routes/documents.ts index 75ec952c..8370f940 100644 --- a/apps/server/src/routes/documents.ts +++ b/apps/server/src/routes/documents.ts @@ -1,5 +1,6 @@ import type { Database } from 'bun:sqlite'; import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import type { BlockSourceRange, ReviewExportData, ReviewThread } from '@marginalia/renderer'; import { exportDocx, extractDocumentTitle, @@ -9,23 +10,22 @@ import { rewriteAssetReferences, sanitizeDocumentFilename, } from '@marginalia/renderer'; -import type { BlockSourceRange, ReviewExportData, ReviewThread } from '@marginalia/renderer'; -import { Hono } from 'hono'; import type { Context } from 'hono'; +import { Hono } from 'hono'; import { reanchor } from '../anchoring.js'; import { - INVITE_SESSION_COOKIE, - type Identity, - SESSION_COOKIE, authorize, canEdit, createSession, deleteSession, hashPassword, + type Identity, + INVITE_SESSION_COOKIE, parseCookie, readIdentity, readInvite, readSession, + SESSION_COOKIE, verifyPassword, } from '../auth.js'; import type { BlobStore } from '../blob-store.js'; @@ -39,13 +39,13 @@ import type { InviteKind, InviteRole, InviteRow, + MermaidRenderer, } from '../db.js'; import { isDocumentFormat, isInviteKind, isInviteRole, isMermaidRenderer } from '../db.js'; -import type { MermaidRenderer } from '../db.js'; import { - type MermaidPrerasterResolver, countLiveMermaidBlocks, inlineImageAssets, + type MermaidPrerasterResolver, prerasterizeMermaid, } from '../export/html-envelope.js'; import { renderMermaidWithChromium } from '../export/mermaid-chromium.js'; diff --git a/apps/server/src/routes/edit-proposals.ts b/apps/server/src/routes/edit-proposals.ts index 0897c4db..5c7f6eaf 100644 --- a/apps/server/src/routes/edit-proposals.ts +++ b/apps/server/src/routes/edit-proposals.ts @@ -1,6 +1,6 @@ import type { Database } from 'bun:sqlite'; -import { canMergeMultiBlock, locateAllBlocks, locateAllBlocksAsciidoc } from '@marginalia/renderer'; import type { BlockSourceRange } from '@marginalia/renderer'; +import { canMergeMultiBlock, locateAllBlocks, locateAllBlocksAsciidoc } from '@marginalia/renderer'; import type { DocumentRow, EditProposalThreadRow } from '../db.js'; import type { GitStore } from '../git-store.js'; import { toWire as toCommentWire } from './comments.js'; diff --git a/apps/server/src/routes/events.ts b/apps/server/src/routes/events.ts index fb460834..c9d3056e 100644 --- a/apps/server/src/routes/events.ts +++ b/apps/server/src/routes/events.ts @@ -1,8 +1,8 @@ +import type { Database } from 'bun:sqlite'; import { Hono } from 'hono'; import type { UpgradeWebSocket } from 'hono/ws'; -import type { Database } from 'bun:sqlite'; +import { authorize, INVITE_SESSION_COOKIE, parseCookie, SESSION_COOKIE } from '../auth.js'; import type { DocumentRow } from '../db.js'; -import { INVITE_SESSION_COOKIE, authorize, parseCookie, SESSION_COOKIE } from '../auth.js'; import type { Realtime } from '../realtime.js'; export interface EventsDeps { diff --git a/apps/server/src/routes/threads.ts b/apps/server/src/routes/threads.ts index 4b8d911f..1387a665 100644 --- a/apps/server/src/routes/threads.ts +++ b/apps/server/src/routes/threads.ts @@ -1,19 +1,19 @@ import type { Database } from 'bun:sqlite'; import { randomBytes } from 'node:crypto'; -import { canMergeMultiBlock, renderDocument } from '@marginalia/renderer'; import type { BlockInfo, BlockSourceRange } from '@marginalia/renderer'; -import { Hono } from 'hono'; +import { canMergeMultiBlock, renderDocument } from '@marginalia/renderer'; import type { Context } from 'hono'; +import { Hono } from 'hono'; import { reanchor } from '../anchoring.js'; import { - INVITE_SESSION_COOKIE, - type Identity, - SESSION_COOKIE, authorize, canComment, canEdit, canPropose, + type Identity, + INVITE_SESSION_COOKIE, parseCookie, + SESSION_COOKIE, } from '../auth.js'; import { mapWithConcurrency } from '../concurrency.js'; import type { @@ -102,9 +102,7 @@ export function threadsRouter(deps: AppDeps): Hono { r.patch('/:uid/threads/:tid/comments/:cid', async (c) => editThreadReply(c, deps)); r.delete('/:uid/threads/:tid/comments/:cid', async (c) => deleteThreadReply(c, deps)); r.post('/:uid/threads/:tid/respond', async (c) => respondToThread(c, deps)); - r.post('/:uid/threads/:tid/comments/:cid/reactions', async (c) => - toggleCommentReaction(c, deps), - ); + r.post('/:uid/threads/:tid/comments/:cid/reactions', async (c) => toggleCommentReaction(c, deps)); return r; } @@ -749,7 +747,15 @@ async function editThreadReply(c: Context, deps: AppDeps) { const replies = loadReplies(db, doc.uid, tid); const reopenableAccepted = await loadReopenableAcceptedThreadIds(doc, deps, [updatedThread]); return c.json({ - thread: await toThreadWire(db, store, doc, updatedThread, replies, decision, reopenableAccepted), + thread: await toThreadWire( + db, + store, + doc, + updatedThread, + replies, + decision, + reopenableAccepted, + ), }); } @@ -1175,7 +1181,15 @@ async function respondToThread(c: Context, deps: AppDeps) { } return c.json({ - thread: await toThreadWire(deps.db, deps.store, doc, updated, replies, decision, reopenableAccepted), + thread: await toThreadWire( + deps.db, + deps.store, + doc, + updated, + replies, + decision, + reopenableAccepted, + ), created_reply_id: createdReplyId, }); } diff --git a/apps/server/src/users.ts b/apps/server/src/users.ts index 4d5f22e7..fe1db43a 100644 --- a/apps/server/src/users.ts +++ b/apps/server/src/users.ts @@ -14,11 +14,7 @@ export interface UpsertResult { oldName: string | null; } -export function upsertDocUser( - db: Database, - docUid: string, - identity: Identity, -): UpsertResult { +export function upsertDocUser(db: Database, docUid: string, identity: Identity): UpsertResult { const now = Date.now(); // bun:sqlite's `.get()` returns null (not undefined) when no row matches. const prev = db diff --git a/apps/server/test/assets.test.ts b/apps/server/test/assets.test.ts index c57114ce..82d33508 100644 --- a/apps/server/test/assets.test.ts +++ b/apps/server/test/assets.test.ts @@ -212,9 +212,7 @@ describe('assets API', () => { const doc = await upload(); const bytes = new Uint8Array([42, 42, 42, 42]); await putAsset(doc.uid, doc.admin_invite.token, 'cat.png', bytes); - expect( - (app.db.prepare('SELECT COUNT(*) as c FROM assets').get() as { c: number }).c, - ).toBe(1); + expect((app.db.prepare('SELECT COUNT(*) as c FROM assets').get() as { c: number }).c).toBe(1); const del = await app.hono.fetch( new Request(`http://test/api/documents/${doc.uid}/assets/cat.png`, { @@ -223,24 +221,18 @@ describe('assets API', () => { }), ); expect(del.status).toBe(204); - expect( - (app.db.prepare('SELECT COUNT(*) as c FROM assets').get() as { c: number }).c, - ).toBe(0); + expect((app.db.prepare('SELECT COUNT(*) as c FROM assets').get() as { c: number }).c).toBe(0); }); test('replace upload GCs the previous blob if nothing else points to it', async () => { const doc = await upload(); await putAsset(doc.uid, doc.admin_invite.token, 'cat.png', new Uint8Array([1])); const beforeIds = new Set( - (app.db.prepare('SELECT id FROM assets').all() as Array<{ id: string }>).map( - (r) => r.id, - ), + (app.db.prepare('SELECT id FROM assets').all() as Array<{ id: string }>).map((r) => r.id), ); await putAsset(doc.uid, doc.admin_invite.token, 'cat.png', new Uint8Array([2, 2])); const afterIds = new Set( - (app.db.prepare('SELECT id FROM assets').all() as Array<{ id: string }>).map( - (r) => r.id, - ), + (app.db.prepare('SELECT id FROM assets').all() as Array<{ id: string }>).map((r) => r.id), ); // Exactly one row; the old id was swept. expect(afterIds.size).toBe(1); @@ -260,9 +252,7 @@ describe('assets API', () => { expect( (app.db.prepare('SELECT COUNT(*) as c FROM document_assets').get() as { c: number }).c, ).toBe(0); - expect( - (app.db.prepare('SELECT COUNT(*) as c FROM assets').get() as { c: number }).c, - ).toBe(0); + expect((app.db.prepare('SELECT COUNT(*) as c FROM assets').get() as { c: number }).c).toBe(0); }); test('stored mime is derived from ref_name, ignoring the client-sent type', async () => { diff --git a/apps/server/test/blob-store.test.ts b/apps/server/test/blob-store.test.ts index c98d2ebe..02a016da 100644 --- a/apps/server/test/blob-store.test.ts +++ b/apps/server/test/blob-store.test.ts @@ -2,12 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { - FsBlobStore, - S3BlobStore, - createBlobStore, - sha256Hex, -} from '../src/blob-store.js'; +import { createBlobStore, FsBlobStore, S3BlobStore, sha256Hex } from '../src/blob-store.js'; describe('FsBlobStore', () => { let dir: string; @@ -80,8 +75,6 @@ describe('createBlobStore factory', () => { }); test('blobStorage=s3 with no s3 block throws a clear error', () => { - expect(() => createBlobStore({ blobStorage: 's3', blobDir: dir })).toThrow( - /blobStorage=s3/, - ); + expect(() => createBlobStore({ blobStorage: 's3', blobDir: dir })).toThrow(/blobStorage=s3/); }); }); diff --git a/apps/server/test/db.test.ts b/apps/server/test/db.test.ts index a7eae5b1..a37cfc87 100644 --- a/apps/server/test/db.test.ts +++ b/apps/server/test/db.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { Database } from 'bun:sqlite'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -78,9 +78,10 @@ describe('openDatabase migrations', () => { // Idempotent: a second open is a no-op (same shape, same rows). const db2 = openDatabase(dbPath); - const rows2 = db2 - .prepare('SELECT token, kind FROM invites ORDER BY token') - .all() as Array<{ token: string; kind: string }>; + const rows2 = db2.prepare('SELECT token, kind FROM invites ORDER BY token').all() as Array<{ + token: string; + kind: string; + }>; expect(rows2).toEqual([ { token: 'adm', kind: 'admin' }, { token: 'nmd', kind: 'named' }, @@ -107,22 +108,27 @@ describe('openDatabase migrations', () => { ); `); const now = Date.now(); - seed.prepare( - `INSERT INTO invites (token, doc_uid, display_name, role, note, created_at, created_by_name) + seed + .prepare( + `INSERT INTO invites (token, doc_uid, display_name, role, note, created_at, created_by_name) VALUES (?, ?, ?, ?, NULL, ?, ?)`, - ).run('tok-1', 'doc-1', 'Bob', 'commentor', now, 'Alice'); - seed.prepare( - `INSERT INTO invites (token, doc_uid, display_name, role, note, created_at, created_by_name) + ) + .run('tok-1', 'doc-1', 'Bob', 'commentor', now, 'Alice'); + seed + .prepare( + `INSERT INTO invites (token, doc_uid, display_name, role, note, created_at, created_by_name) VALUES (?, ?, ?, ?, NULL, ?, ?)`, - ).run('tok-2', 'doc-1', 'Carol', 'collaborator', now, 'Alice'); + ) + .run('tok-2', 'doc-1', 'Carol', 'collaborator', now, 'Alice'); seed.close(); } // Open via the production path → migration runs. const db = openDatabase(dbPath); - const rows = db - .prepare('SELECT token, role FROM invites ORDER BY token') - .all() as Array<{ token: string; role: string }>; + const rows = db.prepare('SELECT token, role FROM invites ORDER BY token').all() as Array<{ + token: string; + role: string; + }>; expect(rows).toEqual([ { token: 'tok-1', role: 'collaborator' }, // migrated from commentor { token: 'tok-2', role: 'collaborator' }, // unchanged @@ -245,37 +251,39 @@ describe('openDatabase migrations', () => { ); `); const now = Date.now(); - seed.prepare( - `INSERT INTO edit_proposals + seed + .prepare( + `INSERT INTO edit_proposals (id, doc_uid, anchor_block_id, anchor_quote, anchor_kind, proposed_text, rationale, author_client_id, author_display_name, status, decided_at, decided_by_name, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - 'prop-1', - 'doc-1', - 'block-1', - 'Original block', - 'paragraph', - 'Edited block', - 'Why this should change', - 'client-1', - 'Alice', - 'pending', - null, - null, - now, - now, - null, - ); + ) + .run( + 'prop-1', + 'doc-1', + 'block-1', + 'Original block', + 'paragraph', + 'Edited block', + 'Why this should change', + 'client-1', + 'Alice', + 'pending', + null, + null, + now, + now, + null, + ); seed.close(); } const db = openDatabase(dbPath); - const oldTable = db.prepare( - `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'edit_proposals'`, - ).get() as { name: string } | null; + const oldTable = db + .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'edit_proposals'`) + .get() as { name: string } | null; expect(oldTable).toBeNull(); const proposalCols = db.prepare('PRAGMA table_info(comments_edit_proposals)').all() as Array<{ @@ -284,11 +292,13 @@ describe('openDatabase migrations', () => { expect(proposalCols.some((col) => col.name === 'decided_at')).toBe(false); expect(proposalCols.some((col) => col.name === 'decided_by_name')).toBe(false); - const migratedComment = db.prepare( - `SELECT id, doc_uid, anchor_block_id, anchor_quote, body, link_status, author_client_id, author_display_name + const migratedComment = db + .prepare( + `SELECT id, doc_uid, anchor_block_id, anchor_quote, body, link_status, author_client_id, author_display_name FROM comments WHERE id = ?`, - ).get('prop-1') as { + ) + .get('prop-1') as { id: string; doc_uid: string; anchor_block_id: string | null; @@ -309,11 +319,13 @@ describe('openDatabase migrations', () => { author_display_name: 'Alice', }); - const migratedProposal = db.prepare( - `SELECT comment_id, anchor_kind, source_snapshot, proposed_text, status, accepted_oid + const migratedProposal = db + .prepare( + `SELECT comment_id, anchor_kind, source_snapshot, proposed_text, status, accepted_oid FROM comments_edit_proposals WHERE comment_id = ?`, - ).get('prop-1') as { + ) + .get('prop-1') as { comment_id: string; anchor_kind: string | null; source_snapshot: string | null; @@ -373,8 +385,9 @@ describe('openDatabase migrations', () => { decided_by_name TEXT ); `); - seed.prepare( - `INSERT INTO comments + seed + .prepare( + `INSERT INTO comments (id, doc_uid, parent_id, parent_proposal_id, anchor_block_id, anchor_quote, anchor_prefix, anchor_suffix, anchor_start_offset, anchor_end_offset, @@ -382,31 +395,34 @@ describe('openDatabase migrations', () => { author_client_id, author_display_name, body, link_status, resolved_at, resolved_by_name, created_at, updated_at, deleted_at) VALUES (?, ?, NULL, NULL, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, 'linked', NULL, NULL, ?, ?, NULL)`, - ).run( - 'prop-accepted', - 'doc-1', - 'block-1', - 'Original block', - 'client-1', - 'Alice', - 'Why this should change', - now, - now, - ); - seed.prepare( - `INSERT INTO comments_edit_proposals + ) + .run( + 'prop-accepted', + 'doc-1', + 'block-1', + 'Original block', + 'client-1', + 'Alice', + 'Why this should change', + now, + now, + ); + seed + .prepare( + `INSERT INTO comments_edit_proposals (comment_id, anchor_kind, source_snapshot, proposed_text, status, accepted_oid, decided_at, decided_by_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - 'prop-accepted', - 'paragraph', - 'Original block', - 'Edited block', - 'accepted', - 'oid-1', - now, - 'Alice', - ); + ) + .run( + 'prop-accepted', + 'paragraph', + 'Original block', + 'Edited block', + 'accepted', + 'oid-1', + now, + 'Alice', + ); seed.close(); } @@ -418,11 +434,13 @@ describe('openDatabase migrations', () => { expect(proposalCols.some((col) => col.name === 'decided_at')).toBe(false); expect(proposalCols.some((col) => col.name === 'decided_by_name')).toBe(false); - const migratedComment = db.prepare( - `SELECT resolved_at, resolved_by_name + const migratedComment = db + .prepare( + `SELECT resolved_at, resolved_by_name FROM comments WHERE id = ?`, - ).get('prop-accepted') as { + ) + .get('prop-accepted') as { resolved_at: number | null; resolved_by_name: string | null; }; @@ -431,11 +449,13 @@ describe('openDatabase migrations', () => { resolved_by_name: 'Alice', }); - const migratedProposal = db.prepare( - `SELECT status, accepted_oid + const migratedProposal = db + .prepare( + `SELECT status, accepted_oid FROM comments_edit_proposals WHERE comment_id = ?`, - ).get('prop-accepted') as { + ) + .get('prop-accepted') as { status: string; accepted_oid: string | null; }; diff --git a/apps/server/test/export-pdf.test.ts b/apps/server/test/export-pdf.test.ts index d5fc881d..447a78f2 100644 --- a/apps/server/test/export-pdf.test.ts +++ b/apps/server/test/export-pdf.test.ts @@ -103,60 +103,72 @@ describe('PDF export', () => { return (await res.json()) as CreateResponse; } - chromiumTest('GET /:uid/export.pdf returns a PDF with the expected headers', async () => { - const created = await upload(CLIENT_A, { - markdown: '# Export me\n\nA paragraph with **bold** text.\n', - name: 'PDF fixture', - default_theme: 'default', - }); - - const res = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf`, { - headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), - }), - ); - expect(res.status).toBe(200); - expect(res.headers.get('content-type')).toBe('application/pdf'); - expect(res.headers.get('content-disposition')).toMatch(/filename="PDF_fixture\.pdf"/); - expect(res.headers.get('x-content-type-options')).toBe('nosniff'); - expect(res.headers.get('cache-control')).toBe('private, no-store'); - - const buf = Buffer.from(await res.arrayBuffer()); - // PDF magic: %PDF- - expect(buf.subarray(0, 5).toString('utf8')).toBe('%PDF-'); - // A minimum-sized bolded-paragraph doc is well under a KB of text - // but the PDF structure (xref table, catalog, fonts) brings the - // smallest real exports above ~1 KB. Sanity lower bound. - expect(buf.length).toBeGreaterThan(1000); - }, 30_000); - - chromiumTest('GET /:uid/export.pdf filename derives from the document title when name is unset', async () => { - // Mirrors the DOCX test of the same name — the two downloads must - // agree on the filename for the same doc. - const created = await upload(CLIENT_A, { - markdown: '---\ntitle: My Great Doc\n---\n\n# A Body Heading\n\nBody.\n', - }); - const res = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf`, { - headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), - }), - ); - expect(res.status).toBe(200); - expect(res.headers.get('content-disposition')).toMatch(/filename="My_Great_Doc\.pdf"/); - }, 30_000); + chromiumTest( + 'GET /:uid/export.pdf returns a PDF with the expected headers', + async () => { + const created = await upload(CLIENT_A, { + markdown: '# Export me\n\nA paragraph with **bold** text.\n', + name: 'PDF fixture', + default_theme: 'default', + }); - chromiumTest('GET /:uid/export.pdf falls back to uid when no title is derivable', async () => { - const created = await upload(CLIENT_A, { - markdown: 'Just a paragraph, no heading, no frontmatter.\n', - }); - const res = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf`, { - headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), - }), - ); - expect(res.status).toBe(200); - expect(res.headers.get('content-disposition')).toContain(`filename="${created.uid}.pdf"`); - }, 30_000); + const res = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/export.pdf`, { + headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('application/pdf'); + expect(res.headers.get('content-disposition')).toMatch(/filename="PDF_fixture\.pdf"/); + expect(res.headers.get('x-content-type-options')).toBe('nosniff'); + expect(res.headers.get('cache-control')).toBe('private, no-store'); + + const buf = Buffer.from(await res.arrayBuffer()); + // PDF magic: %PDF- + expect(buf.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + // A minimum-sized bolded-paragraph doc is well under a KB of text + // but the PDF structure (xref table, catalog, fonts) brings the + // smallest real exports above ~1 KB. Sanity lower bound. + expect(buf.length).toBeGreaterThan(1000); + }, + 30_000, + ); + + chromiumTest( + 'GET /:uid/export.pdf filename derives from the document title when name is unset', + async () => { + // Mirrors the DOCX test of the same name — the two downloads must + // agree on the filename for the same doc. + const created = await upload(CLIENT_A, { + markdown: '---\ntitle: My Great Doc\n---\n\n# A Body Heading\n\nBody.\n', + }); + const res = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/export.pdf`, { + headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get('content-disposition')).toMatch(/filename="My_Great_Doc\.pdf"/); + }, + 30_000, + ); + + chromiumTest( + 'GET /:uid/export.pdf falls back to uid when no title is derivable', + async () => { + const created = await upload(CLIENT_A, { + markdown: 'Just a paragraph, no heading, no frontmatter.\n', + }); + const res = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/export.pdf`, { + headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get('content-disposition')).toContain(`filename="${created.uid}.pdf"`); + }, + 30_000, + ); chromiumTest('GET /:uid/export.pdf rejects unknown UID with 404', async () => { const res = await app.hono.fetch( @@ -182,348 +194,382 @@ describe('PDF export', () => { expect(res.status).toBe(401); }); - chromiumTest('GET /:uid/export.pdf respects ?theme= and produces different bytes per theme', async () => { - // Two very different themes (default vs. serif-print) should not - // produce byte-identical PDFs. Ensures the theme CSS actually - // threads through to `page.pdf()`. - const created = await upload(CLIENT_A, { - markdown: '# Theme check\n\nParagraph for theme sanity.\n', - name: 'Theme test', - }); - const common = withInvite(headersFor(CLIENT_A), created.admin_invite.token); - // Sequential on purpose: the test is about bytes differing per - // theme, not about concurrent export behavior. Running in - // parallel would add flakiness from the 2-slot semaphore if a - // previous test left an in-flight slot dangling for any reason. - const resA = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf?theme=default`, { - headers: common, - }), - ); - const resB = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf?theme=serif-print`, { - headers: common, - }), - ); - expect(resA.status).toBe(200); - expect(resB.status).toBe(200); - const a = Buffer.from(await resA.arrayBuffer()); - const b = Buffer.from(await resB.arrayBuffer()); - expect(a.equals(b)).toBe(false); - }, 60_000); - - chromiumTest('GET /:uid/export.pdf embeds attached image assets as data URLs', async () => { - const created = await upload(CLIENT_A, { - markdown: '# Doc\n\n![logo](logo.png)\n', - name: 'With logo', - }); + chromiumTest( + 'GET /:uid/export.pdf respects ?theme= and produces different bytes per theme', + async () => { + // Two very different themes (default vs. serif-print) should not + // produce byte-identical PDFs. Ensures the theme CSS actually + // threads through to `page.pdf()`. + const created = await upload(CLIENT_A, { + markdown: '# Theme check\n\nParagraph for theme sanity.\n', + name: 'Theme test', + }); + const common = withInvite(headersFor(CLIENT_A), created.admin_invite.token); + // Sequential on purpose: the test is about bytes differing per + // theme, not about concurrent export behavior. Running in + // parallel would add flakiness from the 2-slot semaphore if a + // previous test left an in-flight slot dangling for any reason. + const resA = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/export.pdf?theme=default`, { + headers: common, + }), + ); + const resB = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/export.pdf?theme=serif-print`, { + headers: common, + }), + ); + expect(resA.status).toBe(200); + expect(resB.status).toBe(200); + const a = Buffer.from(await resA.arrayBuffer()); + const b = Buffer.from(await resB.arrayBuffer()); + expect(a.equals(b)).toBe(false); + }, + 60_000, + ); + + chromiumTest( + 'GET /:uid/export.pdf embeds attached image assets as data URLs', + async () => { + const created = await upload(CLIENT_A, { + markdown: '# Doc\n\n![logo](logo.png)\n', + name: 'With logo', + }); - // Same 1x1 PNG used by the DOCX embedding test. - const PNG_BYTES = Uint8Array.from( - atob( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=', - ), - (c) => c.charCodeAt(0), - ); - const form = new FormData(); - form.append('file', new Blob([PNG_BYTES], { type: 'image/png' }), 'logo.png'); - form.append('ref_name', 'logo.png'); - const uploadRes = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/assets`, { - method: 'POST', - headers: withInvite( - new Headers({ - [CLIENT_HEADER]: CLIENT_A.id, - [CLIENT_NAME_HEADER]: CLIENT_A.name, - }), - created.admin_invite.token, + // Same 1x1 PNG used by the DOCX embedding test. + const PNG_BYTES = Uint8Array.from( + atob( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=', ), - body: form, - }), - ); - expect(uploadRes.status).toBe(201); - - const res = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf`, { - headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), - }), - ); - expect(res.status).toBe(200); - const buf = Buffer.from(await res.arrayBuffer()); - // The PNG's header bytes, in hex, appear verbatim inside the PDF - // stream when Chromium embeds a raster image. Checking for the - // specific 1x1 PNG's magic (`\x89PNG\r\n\x1a\n`) inside the PDF - // text is brittle across Chromium versions, so instead just - // assert that a PDF image object (`/Image` / `/DCTDecode` / - // `/FlateDecode`) is present — signals the inliner fed bytes - // through. - const body = buf.toString('latin1'); - expect(body).toMatch(/\/Subtype\s*\/Image/); - }, 30_000); - - chromiumTest('GET /:uid/export.pdf renders a mermaid diagram to SVG on the page', async () => { - // Document with one mermaid block → hasMermaid is true → the - // envelope inlines the mermaid runtime → the export page runs - // `mermaid.run()` before `page.pdf()`. We can't easily verify the - // SVG inside the PDF binary, but we CAN verify that: - // - the response is a PDF (engine didn't blow up on the runtime), - // - it's materially larger than a no-mermaid export of the same - // surrounding text (the embedded SVG + fonts add bytes). - const mermaidMd = [ - '# Diagram', - '', - '```mermaid', - 'graph TD', - ' A[Start] --> B[End]', - '```', - '', - 'Aftermath paragraph.', - '', - ].join('\n'); - const plainMd = [ - '# Diagram', - '', - 'graph TD A[Start] --> B[End]', - '', - 'Aftermath paragraph.', - '', - ].join('\n'); - - const withDiagram = await upload(CLIENT_A, { - markdown: mermaidMd, - name: 'With mermaid', - }); - const withoutDiagram = await upload(CLIENT_A, { - markdown: plainMd, - name: 'Without mermaid', - }); + (c) => c.charCodeAt(0), + ); + const form = new FormData(); + form.append('file', new Blob([PNG_BYTES], { type: 'image/png' }), 'logo.png'); + form.append('ref_name', 'logo.png'); + const uploadRes = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/assets`, { + method: 'POST', + headers: withInvite( + new Headers({ + [CLIENT_HEADER]: CLIENT_A.id, + [CLIENT_NAME_HEADER]: CLIENT_A.name, + }), + created.admin_invite.token, + ), + body: form, + }), + ); + expect(uploadRes.status).toBe(201); - const fetchPdf = (uid: string, token: string) => - app.hono.fetch( - new Request(`http://test/api/documents/${uid}/export.pdf`, { - headers: withInvite(headersFor(CLIENT_A), token), + const res = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/export.pdf`, { + headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), }), ); - const resDiagram = await fetchPdf(withDiagram.uid, withDiagram.admin_invite.token); - const resPlain = await fetchPdf(withoutDiagram.uid, withoutDiagram.admin_invite.token); - expect(resDiagram.status).toBe(200); - expect(resPlain.status).toBe(200); - const diagram = Buffer.from(await resDiagram.arrayBuffer()); - const plain = Buffer.from(await resPlain.arrayBuffer()); - expect(diagram.subarray(0, 5).toString('utf8')).toBe('%PDF-'); - // Exact size gap varies by Chromium version, but a rendered - // mermaid SVG is always at least a few KB heavier than a plain - // paragraph. 2 KB is a conservative lower bound. - expect(diagram.length - plain.length).toBeGreaterThan(2000); - }, 45_000); - - chromiumTest('GET /:uid/export.pdf?mermaid=mmdr pre-rasterizes diagrams without the mermaid runtime', async () => { - // Same fixture as the chromium-renderer test above. With `?mermaid=mmdr` - // the route runs the pre-rasterizer over the body and replaces every - // `
` with the SVG mmdr returned. The PDF should - // still be valid and the diagram still visible. Distinguishing from - // the chromium path by byte size is unreliable (both flows embed - // approximately-the-same SVG), so we just assert the export succeeded - // and produced a non-empty PDF. - // - // Skipped if mmdr isn't available — same gating as the dedicated - // mermaid-rust suite. `spawnSync` lets `node:child_process` - // resolve `mmdr` against PATH for us, and ENOENT is the - // canonical "binary not found" signal across platforms (no need - // to hand-split PATH ourselves). - const { spawnSync } = await import('node:child_process'); - const probe = spawnSync('mmdr', ['--version'], { encoding: 'utf8' }); - if (probe.error && (probe.error as NodeJS.ErrnoException).code === 'ENOENT') return; - - const md = [ - '# Diagram', - '', - '```mermaid', - 'graph TD', - ' A[Start] --> B[End]', - '```', - '', - 'Aftermath paragraph.', - '', - ].join('\n'); - const created = await upload(CLIENT_A, { markdown: md, name: 'mmdr-pdf' }); - const res = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf?mermaid=mmdr`, { - headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), - }), - ); - expect(res.status).toBe(200); - const buf = Buffer.from(await res.arrayBuffer()); - expect(buf.subarray(0, 5).toString('utf8')).toBe('%PDF-'); - // Sanity floor: a one-paragraph + one-diagram PDF is several - // KB minimum (fonts + content streams). Catches a regression - // where the page rendered empty. - expect(buf.length).toBeGreaterThan(5_000); - }, 45_000); - - chromiumTest('GET /:uid/export.pdf rejects path-traversal-shaped theme names cleanly', async () => { - // An unknown theme name should fall back to default, not 500. The - // CSS loader's `isValidThemeName` plus the ENOENT fallback cover - // both "looks fine but doesn't exist" and "obvious attack". - const created = await upload(CLIENT_A, { - markdown: '# Safe\n\nBody.\n', - name: 'Safe doc', - }); - const res = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf?theme=../../etc/passwd`, { - headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), - }), - ); - expect(res.status).toBe(200); - const buf = Buffer.from(await res.arrayBuffer()); - expect(buf.subarray(0, 5).toString('utf8')).toBe('%PDF-'); - }, 30_000); - - chromiumTest('GET /:uid/export.pdf blocks outbound requests to disallowed hosts (SSRF)', async () => { - // A doc authored with an absolute `` pointed - // at an unroutable host. Without the request-routing firewall, - // Chromium would try to fetch this during export and either hang - // for 30 s (timeout) or potentially probe the server's internal - // network. With the firewall, the request is aborted at - // route.abort() and the export completes in <1 s. - const created = await upload(CLIENT_A, { - // 192.0.2.0/24 is RFC 5737 TEST-NET-1 — guaranteed unroutable. - // Using it means the test proves request abort (not DNS or - // connection refusal) is what lets the export finish quickly. - markdown: '# SSRF smoke\n\n![external](http://192.0.2.1/internal.png)\n\nBody.\n', - name: 'SSRF fixture', - }); - const started = Date.now(); - const res = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf`, { - headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), - }), - ); - const elapsed = Date.now() - started; - expect(res.status).toBe(200); - const buf = Buffer.from(await res.arrayBuffer()); - expect(buf.subarray(0, 5).toString('utf8')).toBe('%PDF-'); - // If the firewall were off, this would be at or near the 20 s - // test-level timeout waiting for 192.0.2.1 to fail. Cap at 10 s - // to leave generous headroom for a loaded CI box while still - // catching regressions that remove the block. - expect(elapsed).toBeLessThan(10_000); - }, 30_000); - - chromiumTest('firewall blocks http:// even to an allowlisted host', async () => { - // Defense-in-depth: the firewall only lets `https:` through, - // even for names on ALLOWED_EXPORT_HOSTS. Proves the policy - // isn't just hostname-based — a user-authored - // `` can't ride the - // Google Fonts allowance to trigger cleartext traffic from - // the worker's network position. - // - // We point at fonts.googleapis.com over http so the hostname - // match would succeed under a naïve allowlist; only the - // protocol check stops the request. No actual network traffic - // reaches Google — Playwright aborts at route.abort() first. - const created = await upload(CLIENT_A, { - markdown: - '# Downgrade attempt\n\n![font](http://fonts.googleapis.com/evil.png)\n\nBody.\n', - name: 'Downgrade fixture', - }); - const started = Date.now(); - const res = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf`, { - headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), - }), - ); - const elapsed = Date.now() - started; - expect(res.status).toBe(200); - const buf = Buffer.from(await res.arrayBuffer()); - expect(buf.subarray(0, 5).toString('utf8')).toBe('%PDF-'); - expect(elapsed).toBeLessThan(10_000); - }, 30_000); - - chromiumTest('inlineImageAssets targets the src attribute even when alt text shares the ref name', async () => { - // Regression test for the pre-`d`-flag indexOf-based inliner, - // which would have targeted the FIRST occurrence of "logo.png" - // in the `` tag — i.e. the alt text — leaving the real - // `src` intact. Result: the blob was never inlined, and - // Chromium saw an unresolved relative path. - // - // The renderer produces `ALT` in that - // attribute order (verified by rehype-stringify), so we exploit - // alt text containing the ref name to hit the collision path. - const created = await upload(CLIENT_A, { - markdown: '# Alt collision\n\n![logo.png has a preview](logo.png)\n', - name: 'Alt collision', - }); - const PNG_BYTES = Uint8Array.from( - atob( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=', - ), - (c) => c.charCodeAt(0), - ); - const form = new FormData(); - form.append('file', new Blob([PNG_BYTES], { type: 'image/png' }), 'logo.png'); - form.append('ref_name', 'logo.png'); - const uploadRes = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/assets`, { - method: 'POST', - headers: withInvite( - new Headers({ - [CLIENT_HEADER]: CLIENT_A.id, - [CLIENT_NAME_HEADER]: CLIENT_A.name, - }), - created.admin_invite.token, - ), - body: form, - }), - ); - expect(uploadRes.status).toBe(201); + expect(res.status).toBe(200); + const buf = Buffer.from(await res.arrayBuffer()); + // The PNG's header bytes, in hex, appear verbatim inside the PDF + // stream when Chromium embeds a raster image. Checking for the + // specific 1x1 PNG's magic (`\x89PNG\r\n\x1a\n`) inside the PDF + // text is brittle across Chromium versions, so instead just + // assert that a PDF image object (`/Image` / `/DCTDecode` / + // `/FlateDecode`) is present — signals the inliner fed bytes + // through. + const body = buf.toString('latin1'); + expect(body).toMatch(/\/Subtype\s*\/Image/); + }, + 30_000, + ); + + chromiumTest( + 'GET /:uid/export.pdf renders a mermaid diagram to SVG on the page', + async () => { + // Document with one mermaid block → hasMermaid is true → the + // envelope inlines the mermaid runtime → the export page runs + // `mermaid.run()` before `page.pdf()`. We can't easily verify the + // SVG inside the PDF binary, but we CAN verify that: + // - the response is a PDF (engine didn't blow up on the runtime), + // - it's materially larger than a no-mermaid export of the same + // surrounding text (the embedded SVG + fonts add bytes). + const mermaidMd = [ + '# Diagram', + '', + '```mermaid', + 'graph TD', + ' A[Start] --> B[End]', + '```', + '', + 'Aftermath paragraph.', + '', + ].join('\n'); + const plainMd = [ + '# Diagram', + '', + 'graph TD A[Start] --> B[End]', + '', + 'Aftermath paragraph.', + '', + ].join('\n'); + + const withDiagram = await upload(CLIENT_A, { + markdown: mermaidMd, + name: 'With mermaid', + }); + const withoutDiagram = await upload(CLIENT_A, { + markdown: plainMd, + name: 'Without mermaid', + }); - const res = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf`, { - headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), - }), - ); - expect(res.status).toBe(200); - const buf = Buffer.from(await res.arrayBuffer()); - // PNG actually landed as a PDF image object — same assertion as - // the plain embedded-asset test. - expect(buf.toString('latin1')).toMatch(/\/Subtype\s*\/Image/); - }, 30_000); - - chromiumTest('mermaid-wait timeout is reported as 504 export-timeout, not 500', async () => { - // Regression: before pdf.ts distinguished Playwright's own - // `TimeoutError` from unexpected errors, a mermaid bootstrap that - // never resolved `__marginaliaMermaidReady` within `mermaidWaitMs` - // would surface to the client as a generic 500 instead of the - // documented 504 export-timeout contract. - // - // We force the condition by configuring a hilariously-short - // mermaid budget (1 ms) — mermaid.initialize() alone takes - // several ms on any hardware, so Playwright's `waitForFunction` - // always hits its timeout before the sentinel flips. Other - // budgets stay at the test defaults so the failure is localised - // to the mermaid path. - configureExport({ mermaidWaitMs: 1 }); - try { + const fetchPdf = (uid: string, token: string) => + app.hono.fetch( + new Request(`http://test/api/documents/${uid}/export.pdf`, { + headers: withInvite(headersFor(CLIENT_A), token), + }), + ); + const resDiagram = await fetchPdf(withDiagram.uid, withDiagram.admin_invite.token); + const resPlain = await fetchPdf(withoutDiagram.uid, withoutDiagram.admin_invite.token); + expect(resDiagram.status).toBe(200); + expect(resPlain.status).toBe(200); + const diagram = Buffer.from(await resDiagram.arrayBuffer()); + const plain = Buffer.from(await resPlain.arrayBuffer()); + expect(diagram.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + // Exact size gap varies by Chromium version, but a rendered + // mermaid SVG is always at least a few KB heavier than a plain + // paragraph. 2 KB is a conservative lower bound. + expect(diagram.length - plain.length).toBeGreaterThan(2000); + }, + 45_000, + ); + + chromiumTest( + 'GET /:uid/export.pdf?mermaid=mmdr pre-rasterizes diagrams without the mermaid runtime', + async () => { + // Same fixture as the chromium-renderer test above. With `?mermaid=mmdr` + // the route runs the pre-rasterizer over the body and replaces every + // `
` with the SVG mmdr returned. The PDF should + // still be valid and the diagram still visible. Distinguishing from + // the chromium path by byte size is unreliable (both flows embed + // approximately-the-same SVG), so we just assert the export succeeded + // and produced a non-empty PDF. + // + // Skipped if mmdr isn't available — same gating as the dedicated + // mermaid-rust suite. `spawnSync` lets `node:child_process` + // resolve `mmdr` against PATH for us, and ENOENT is the + // canonical "binary not found" signal across platforms (no need + // to hand-split PATH ourselves). + const { spawnSync } = await import('node:child_process'); + const probe = spawnSync('mmdr', ['--version'], { encoding: 'utf8' }); + if (probe.error && (probe.error as NodeJS.ErrnoException).code === 'ENOENT') return; + + const md = [ + '# Diagram', + '', + '```mermaid', + 'graph TD', + ' A[Start] --> B[End]', + '```', + '', + 'Aftermath paragraph.', + '', + ].join('\n'); + const created = await upload(CLIENT_A, { markdown: md, name: 'mmdr-pdf' }); + const res = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/export.pdf?mermaid=mmdr`, { + headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), + }), + ); + expect(res.status).toBe(200); + const buf = Buffer.from(await res.arrayBuffer()); + expect(buf.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + // Sanity floor: a one-paragraph + one-diagram PDF is several + // KB minimum (fonts + content streams). Catches a regression + // where the page rendered empty. + expect(buf.length).toBeGreaterThan(5_000); + }, + 45_000, + ); + + chromiumTest( + 'GET /:uid/export.pdf rejects path-traversal-shaped theme names cleanly', + async () => { + // An unknown theme name should fall back to default, not 500. The + // CSS loader's `isValidThemeName` plus the ENOENT fallback cover + // both "looks fine but doesn't exist" and "obvious attack". const created = await upload(CLIENT_A, { - markdown: - '# Timeout fixture\n\n```mermaid\ngraph TD\n A --> B\n```\n', - name: 'Timeout fixture', + markdown: '# Safe\n\nBody.\n', + name: 'Safe doc', }); const res = await app.hono.fetch( - new Request(`http://test/api/documents/${created.uid}/export.pdf?mermaid=chromium`, { + new Request(`http://test/api/documents/${created.uid}/export.pdf?theme=../../etc/passwd`, { headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), }), ); - expect(res.status).toBe(504); - const body = (await res.json()) as { error: string; elapsed_ms?: number }; - expect(body.error).toBe('export-timeout'); - expect(typeof body.elapsed_ms).toBe('number'); - } finally { - // Restore the suite-level defaults so downstream tests aren't - // starved. configureExport() validates positive integers now - // (see export-config.test.ts). - configureExport({ mermaidWaitMs: 10_000 }); - } - }, 30_000); + expect(res.status).toBe(200); + const buf = Buffer.from(await res.arrayBuffer()); + expect(buf.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + }, + 30_000, + ); + + chromiumTest( + 'GET /:uid/export.pdf blocks outbound requests to disallowed hosts (SSRF)', + async () => { + // A doc authored with an absolute `` pointed + // at an unroutable host. Without the request-routing firewall, + // Chromium would try to fetch this during export and either hang + // for 30 s (timeout) or potentially probe the server's internal + // network. With the firewall, the request is aborted at + // route.abort() and the export completes in <1 s. + const created = await upload(CLIENT_A, { + // 192.0.2.0/24 is RFC 5737 TEST-NET-1 — guaranteed unroutable. + // Using it means the test proves request abort (not DNS or + // connection refusal) is what lets the export finish quickly. + markdown: '# SSRF smoke\n\n![external](http://192.0.2.1/internal.png)\n\nBody.\n', + name: 'SSRF fixture', + }); + const started = Date.now(); + const res = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/export.pdf`, { + headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), + }), + ); + const elapsed = Date.now() - started; + expect(res.status).toBe(200); + const buf = Buffer.from(await res.arrayBuffer()); + expect(buf.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + // If the firewall were off, this would be at or near the 20 s + // test-level timeout waiting for 192.0.2.1 to fail. Cap at 10 s + // to leave generous headroom for a loaded CI box while still + // catching regressions that remove the block. + expect(elapsed).toBeLessThan(10_000); + }, + 30_000, + ); + + chromiumTest( + 'firewall blocks http:// even to an allowlisted host', + async () => { + // Defense-in-depth: the firewall only lets `https:` through, + // even for names on ALLOWED_EXPORT_HOSTS. Proves the policy + // isn't just hostname-based — a user-authored + // `` can't ride the + // Google Fonts allowance to trigger cleartext traffic from + // the worker's network position. + // + // We point at fonts.googleapis.com over http so the hostname + // match would succeed under a naïve allowlist; only the + // protocol check stops the request. No actual network traffic + // reaches Google — Playwright aborts at route.abort() first. + const created = await upload(CLIENT_A, { + markdown: '# Downgrade attempt\n\n![font](http://fonts.googleapis.com/evil.png)\n\nBody.\n', + name: 'Downgrade fixture', + }); + const started = Date.now(); + const res = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/export.pdf`, { + headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), + }), + ); + const elapsed = Date.now() - started; + expect(res.status).toBe(200); + const buf = Buffer.from(await res.arrayBuffer()); + expect(buf.subarray(0, 5).toString('utf8')).toBe('%PDF-'); + expect(elapsed).toBeLessThan(10_000); + }, + 30_000, + ); + + chromiumTest( + 'inlineImageAssets targets the src attribute even when alt text shares the ref name', + async () => { + // Regression test for the pre-`d`-flag indexOf-based inliner, + // which would have targeted the FIRST occurrence of "logo.png" + // in the `` tag — i.e. the alt text — leaving the real + // `src` intact. Result: the blob was never inlined, and + // Chromium saw an unresolved relative path. + // + // The renderer produces `ALT` in that + // attribute order (verified by rehype-stringify), so we exploit + // alt text containing the ref name to hit the collision path. + const created = await upload(CLIENT_A, { + markdown: '# Alt collision\n\n![logo.png has a preview](logo.png)\n', + name: 'Alt collision', + }); + const PNG_BYTES = Uint8Array.from( + atob( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=', + ), + (c) => c.charCodeAt(0), + ); + const form = new FormData(); + form.append('file', new Blob([PNG_BYTES], { type: 'image/png' }), 'logo.png'); + form.append('ref_name', 'logo.png'); + const uploadRes = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/assets`, { + method: 'POST', + headers: withInvite( + new Headers({ + [CLIENT_HEADER]: CLIENT_A.id, + [CLIENT_NAME_HEADER]: CLIENT_A.name, + }), + created.admin_invite.token, + ), + body: form, + }), + ); + expect(uploadRes.status).toBe(201); + + const res = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/export.pdf`, { + headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), + }), + ); + expect(res.status).toBe(200); + const buf = Buffer.from(await res.arrayBuffer()); + // PNG actually landed as a PDF image object — same assertion as + // the plain embedded-asset test. + expect(buf.toString('latin1')).toMatch(/\/Subtype\s*\/Image/); + }, + 30_000, + ); + + chromiumTest( + 'mermaid-wait timeout is reported as 504 export-timeout, not 500', + async () => { + // Regression: before pdf.ts distinguished Playwright's own + // `TimeoutError` from unexpected errors, a mermaid bootstrap that + // never resolved `__marginaliaMermaidReady` within `mermaidWaitMs` + // would surface to the client as a generic 500 instead of the + // documented 504 export-timeout contract. + // + // We force the condition by configuring a hilariously-short + // mermaid budget (1 ms) — mermaid.initialize() alone takes + // several ms on any hardware, so Playwright's `waitForFunction` + // always hits its timeout before the sentinel flips. Other + // budgets stay at the test defaults so the failure is localised + // to the mermaid path. + configureExport({ mermaidWaitMs: 1 }); + try { + const created = await upload(CLIENT_A, { + markdown: '# Timeout fixture\n\n```mermaid\ngraph TD\n A --> B\n```\n', + name: 'Timeout fixture', + }); + const res = await app.hono.fetch( + new Request(`http://test/api/documents/${created.uid}/export.pdf?mermaid=chromium`, { + headers: withInvite(headersFor(CLIENT_A), created.admin_invite.token), + }), + ); + expect(res.status).toBe(504); + const body = (await res.json()) as { error: string; elapsed_ms?: number }; + expect(body.error).toBe('export-timeout'); + expect(typeof body.elapsed_ms).toBe('number'); + } finally { + // Restore the suite-level defaults so downstream tests aren't + // starved. configureExport() validates positive integers now + // (see export-config.test.ts). + configureExport({ mermaidWaitMs: 10_000 }); + } + }, + 30_000, + ); }); diff --git a/apps/server/test/git-store-proposals.test.ts b/apps/server/test/git-store-proposals.test.ts index 817cb408..59ba5223 100644 --- a/apps/server/test/git-store-proposals.test.ts +++ b/apps/server/test/git-store-proposals.test.ts @@ -1,10 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import fs from 'node:fs'; -import { mkdtempSync, rmSync } from 'node:fs'; +import fs, { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import * as git from 'isomorphic-git'; -import { GitStore, type DocLocator } from '../src/git-store.js'; +import { type DocLocator, GitStore } from '../src/git-store.js'; /** * Tests for the proposal-branch primitives on GitStore. The lifecycle: diff --git a/apps/server/test/mermaid-chromium.test.ts b/apps/server/test/mermaid-chromium.test.ts index 837faa7d..86e0696e 100644 --- a/apps/server/test/mermaid-chromium.test.ts +++ b/apps/server/test/mermaid-chromium.test.ts @@ -8,12 +8,11 @@ */ import { afterAll, describe, expect, test } from 'bun:test'; import { existsSync } from 'node:fs'; - -import { closeExportBrowser } from '../src/export/pdf.js'; import { configureMermaidChromium, renderMermaidWithChromium, } from '../src/export/mermaid-chromium.js'; +import { closeExportBrowser } from '../src/export/pdf.js'; /** * Probe Playwright's browser registry for any directory that looks @@ -82,70 +81,83 @@ describe('renderMermaidWithChromium', () => { await closeExportBrowser(); }); - test.if(SHOULD_RUN_CHROMIUM_TESTS)('produces an SVG with diagram content', async () => { - const result = await renderMermaidWithChromium(SAMPLE, 'svg'); - expect(result).not.toBeNull(); - expect(result!.mime).toBe('image/svg+xml'); - expect(result!.format).toBe('svg'); - const text = new TextDecoder().decode(result!.bytes); - expect(text).toContain(' { + const result = await renderMermaidWithChromium(SAMPLE, 'svg'); + expect(result).not.toBeNull(); + expect(result!.mime).toBe('image/svg+xml'); + expect(result!.format).toBe('svg'); + const text = new TextDecoder().decode(result!.bytes); + expect(text).toContain(' { - const result = await renderMermaidWithChromium(SAMPLE, 'png'); - expect(result).not.toBeNull(); - expect(result!.mime).toBe('image/png'); - expect(result!.format).toBe('png'); - // PNG magic bytes. - expect(result!.bytes.length).toBeGreaterThan(1000); - expect(result!.bytes[0]).toBe(0x89); - expect(result!.bytes[1]).toBe(0x50); - expect(result!.bytes[2]).toBe(0x4e); - expect(result!.bytes[3]).toBe(0x47); - }, 60_000); + test.if(SHOULD_RUN_CHROMIUM_TESTS)( + 'produces a PNG screenshot', + async () => { + const result = await renderMermaidWithChromium(SAMPLE, 'png'); + expect(result).not.toBeNull(); + expect(result!.mime).toBe('image/png'); + expect(result!.format).toBe('png'); + // PNG magic bytes. + expect(result!.bytes.length).toBeGreaterThan(1000); + expect(result!.bytes[0]).toBe(0x89); + expect(result!.bytes[1]).toBe(0x50); + expect(result!.bytes[2]).toBe(0x4e); + expect(result!.bytes[3]).toBe(0x47); + }, + 60_000, + ); - test.if(SHOULD_RUN_CHROMIUM_TESTS)('PNG resolution scales with pngScale; natural dims stay fixed', async () => { - // Two renders of the same source at different scales: PNG actual - // pixel count grows with `pngScale`, but `naturalWidth/Height` - // (the CSS-px display size we hand back to docx) stays the same. - // Without this, a hi-res raster would inflate the diagram on the - // Word page — see `mermaid-chromium.ts` for the full rationale. - function pngDims(b: Uint8Array): { w: number; h: number } { - const dv = new DataView(b.buffer, b.byteOffset, b.byteLength); - return { w: dv.getUint32(16), h: dv.getUint32(20) }; - } + test.if(SHOULD_RUN_CHROMIUM_TESTS)( + 'PNG resolution scales with pngScale; natural dims stay fixed', + async () => { + // Two renders of the same source at different scales: PNG actual + // pixel count grows with `pngScale`, but `naturalWidth/Height` + // (the CSS-px display size we hand back to docx) stays the same. + // Without this, a hi-res raster would inflate the diagram on the + // Word page — see `mermaid-chromium.ts` for the full rationale. + function pngDims(b: Uint8Array): { w: number; h: number } { + const dv = new DataView(b.buffer, b.byteOffset, b.byteLength); + return { w: dv.getUint32(16), h: dv.getUint32(20) }; + } - configureMermaidChromium({ pngScale: 2, timeoutMs: 30_000 }); - const at2x = await renderMermaidWithChromium(SAMPLE, 'png'); - expect(at2x).not.toBeNull(); - const dims2 = pngDims(at2x!.bytes); + configureMermaidChromium({ pngScale: 2, timeoutMs: 30_000 }); + const at2x = await renderMermaidWithChromium(SAMPLE, 'png'); + expect(at2x).not.toBeNull(); + const dims2 = pngDims(at2x!.bytes); - configureMermaidChromium({ pngScale: 4, timeoutMs: 30_000 }); - const at4x = await renderMermaidWithChromium(SAMPLE, 'png'); - expect(at4x).not.toBeNull(); - const dims4 = pngDims(at4x!.bytes); + configureMermaidChromium({ pngScale: 4, timeoutMs: 30_000 }); + const at4x = await renderMermaidWithChromium(SAMPLE, 'png'); + expect(at4x).not.toBeNull(); + const dims4 = pngDims(at4x!.bytes); - // 4× actual pixels at twice the device scale. - expect(dims4.w).toBeCloseTo(dims2.w * 2, 0); - expect(dims4.h).toBeCloseTo(dims2.h * 2, 0); - // Same natural CSS-px display size at both scales. - expect(at4x!.naturalWidth).toBeCloseTo(at2x!.naturalWidth ?? 0, 0); - expect(at4x!.naturalHeight).toBeCloseTo(at2x!.naturalHeight ?? 0, 0); - // And natural is materially smaller than the 4× actual pixels. - expect(at4x!.naturalWidth).toBeLessThan(dims4.w); - }, 90_000); + // 4× actual pixels at twice the device scale. + expect(dims4.w).toBeCloseTo(dims2.w * 2, 0); + expect(dims4.h).toBeCloseTo(dims2.h * 2, 0); + // Same natural CSS-px display size at both scales. + expect(at4x!.naturalWidth).toBeCloseTo(at2x!.naturalWidth ?? 0, 0); + expect(at4x!.naturalHeight).toBeCloseTo(at2x!.naturalHeight ?? 0, 0); + // And natural is materially smaller than the 4× actual pixels. + expect(at4x!.naturalWidth).toBeLessThan(dims4.w); + }, + 90_000, + ); - test.if(SHOULD_RUN_CHROMIUM_TESTS)('returns null on parse failure (graceful degrade)', async () => { - // Garbage source — mermaid.render() rejects, the bootstrap - // sets `__marginaliaMermaidError`, and we return null so the - // caller can fall back to the placeholder. - const result = await renderMermaidWithChromium( - 'not a diagram, just words', - 'svg', - ); - expect(result).toBeNull(); - }, 60_000); + test.if(SHOULD_RUN_CHROMIUM_TESTS)( + 'returns null on parse failure (graceful degrade)', + async () => { + // Garbage source — mermaid.render() rejects, the bootstrap + // sets `__marginaliaMermaidError`, and we return null so the + // caller can fall back to the placeholder. + const result = await renderMermaidWithChromium('not a diagram, just words', 'svg'); + expect(result).toBeNull(); + }, + 60_000, + ); }); diff --git a/apps/server/test/mermaid-prerender.test.ts b/apps/server/test/mermaid-prerender.test.ts index 4758e1eb..d95650c8 100644 --- a/apps/server/test/mermaid-prerender.test.ts +++ b/apps/server/test/mermaid-prerender.test.ts @@ -5,10 +5,7 @@ */ import { describe, expect, test } from 'bun:test'; -import { - countLiveMermaidBlocks, - prerasterizeMermaid, -} from '../src/export/html-envelope.js'; +import { countLiveMermaidBlocks, prerasterizeMermaid } from '../src/export/html-envelope.js'; const MD_DIV = (idx: number, body: string): string => `
${body}
`; @@ -70,8 +67,7 @@ describe('prerasterizeMermaid', () => { test('handles asciidoc-style mermaid divs (extra attrs in any order)', async () => { // Asciidoc plugin adds a `data-block` attr; the helper should // still match and find the index attribute regardless of order. - const html = - `
graph TD\nA --> B
`; + const html = `
graph TD\nA --> B
`; let called = 0; await prerasterizeMermaid(html, async () => { called += 1; @@ -86,8 +82,7 @@ describe('prerasterizeMermaid', () => { test('mixes resolved and unresolved blocks correctly', async () => { // First block resolves, second returns null → should keep the // second untouched while replacing the first. - const html = - `${MD_DIV(0, 'graph TD\nA --> B')}\n${MD_DIV(1, 'graph LR\nX --> Y')}`; + const html = `${MD_DIV(0, 'graph TD\nA --> B')}\n${MD_DIV(1, 'graph LR\nX --> Y')}`; const out = await prerasterizeMermaid(html, async (_s, idx) => idx === 0 ? { bytes: new TextEncoder().encode(''), mime: 'image/svg+xml' } @@ -115,10 +110,7 @@ describe('prerasterizeMermaid', () => { test('decodes the small set of HTML entities (& < > " \\")', async () => { // Ampersands are tricky — they must come last during decode so // `&lt;` doesn't double-decode to `<`. - const html = MD_DIV( - 0, - 'sequenceDiagram\nA->>B: "hi &lt; bye"', - ); + const html = MD_DIV(0, 'sequenceDiagram\nA->>B: "hi &lt; bye"'); let captured = ''; await prerasterizeMermaid(html, async (s) => { captured = s; diff --git a/apps/server/test/proposal-branch-backfill.test.ts b/apps/server/test/proposal-branch-backfill.test.ts index f9c9aae4..0e46ce48 100644 --- a/apps/server/test/proposal-branch-backfill.test.ts +++ b/apps/server/test/proposal-branch-backfill.test.ts @@ -1,11 +1,10 @@ +import type { Database } from 'bun:sqlite'; import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import fs from 'node:fs'; -import { mkdtempSync, rmSync } from 'node:fs'; +import fs, { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { Database } from 'bun:sqlite'; -import * as git from 'isomorphic-git'; import { locateAllBlocks } from '@marginalia/renderer'; +import * as git from 'isomorphic-git'; import { openDatabase } from '../src/db.js'; import { GitStore } from '../src/git-store.js'; import { backfillProposalBranches } from '../src/proposal-branch-backfill.js'; @@ -123,10 +122,7 @@ describe('backfillProposalBranches', () => { // The branch tip must contain the spliced source (full doc, block // replaced) so accept can use git.merge against current main. - const tip = await store.readProposalTip( - { uid: 'doc-1', format: 'markdown' }, - 'prop-1', - ); + const tip = await store.readProposalTip({ uid: 'doc-1', format: 'markdown' }, 'prop-1'); expect(tip).toBe('# Title\n\nbeta'); // base_oid must equal main's tip at backfill time. @@ -253,9 +249,7 @@ describe('backfillProposalBranches', () => { expect(summary).toEqual({ migrated: 0, skipped: 0 }); const row = db - .prepare( - `SELECT branch_ref FROM comments_edit_proposals WHERE comment_id = 'prop-accepted'`, - ) + .prepare(`SELECT branch_ref FROM comments_edit_proposals WHERE comment_id = 'prop-accepted'`) .get() as { branch_ref: string | null }; expect(row.branch_ref).toBeNull(); diff --git a/apps/web/package.json b/apps/web/package.json index e2af8698..f5c4db52 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,28 +10,28 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@codemirror/lang-markdown": "^6.3.1", - "@codemirror/state": "^6.5.0", - "@codemirror/theme-one-dark": "^6.1.2", - "@codemirror/view": "^6.36.1", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/state": "^6.6.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.42.1", "@marginalia/renderer": "workspace:*", "@marginalia/themes": "workspace:*", "@radix-ui/react-icons": "^1.3.2", - "@radix-ui/themes": "^3.2.0", - "codemirror": "^6.0.1", + "@radix-ui/themes": "^3.3.0", + "codemirror": "^6.0.2", "frimousse": "^0.3.0", - "lucide-react": "^1.9.0", - "mermaid": "^11.4.1", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "lucide-react": "^1.14.0", + "mermaid": "^11.14.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", "react-markdown": "^10.1.0", - "react-router-dom": "^7.1.1", + "react-router-dom": "^7.15.0", "remark-gfm": "^4.0.1" }, "devDependencies": { - "@types/react": "^19.0.2", - "@types/react-dom": "^19.0.2", - "@vitejs/plugin-react": "^4.3.4", - "vite": "^6.0.5" + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.11" } } diff --git a/apps/web/src/components/AccessControlDialog.tsx b/apps/web/src/components/AccessControlDialog.tsx index f25b8091..4231a517 100644 --- a/apps/web/src/components/AccessControlDialog.tsx +++ b/apps/web/src/components/AccessControlDialog.tsx @@ -13,8 +13,8 @@ import { import { useEffect, useState } from 'react'; import type { Document } from '../lib/api.js'; import { - recoverCurrentPassword, type DocumentSettingsResponse, + recoverCurrentPassword, updateDocumentSettings, } from '../lib/api.js'; import { getClientId, getDisplayName } from '../lib/identity.js'; diff --git a/apps/web/src/components/ActivityList.tsx b/apps/web/src/components/ActivityList.tsx index a00d8c15..f323d9cb 100644 --- a/apps/web/src/components/ActivityList.tsx +++ b/apps/web/src/components/ActivityList.tsx @@ -1,20 +1,20 @@ import { Box, Code, Flex, Text } from '@radix-ui/themes'; import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { formatAnchorQuote } from '../lib/anchor-quote.js'; import { type Comment, + getHistory, + getHistoryDiff, type HistoryEntry, type Thread, type ThreadResolution, - getHistory, - getHistoryDiff, } from '../lib/api.js'; -import { formatAnchorQuote } from '../lib/anchor-quote.js'; import { formatTimestamp, formatTimestampLong } from '../lib/format-time.js'; import { describeEntry, historyActorLabel, shortOid } from '../lib/history-format.js'; import { reportError } from '../lib/log.js'; import { DiffDialog } from './DiffDialog.js'; -import { ShowDiffButton } from './ShowDiffButton.js'; import { InlineAvatar } from './inline-comments/InlineAvatar.js'; +import { ShowDiffButton } from './ShowDiffButton.js'; function buildRowProps( targetId: string | undefined, diff --git a/apps/web/src/components/AppBar.tsx b/apps/web/src/components/AppBar.tsx index 26caf032..396672d0 100644 --- a/apps/web/src/components/AppBar.tsx +++ b/apps/web/src/components/AppBar.tsx @@ -1,6 +1,6 @@ -import { useId, type ReactNode } from 'react'; -import { Link } from 'react-router-dom'; import { Flex, Separator, Text } from '@radix-ui/themes'; +import { type ReactNode, useId } from 'react'; +import { Link } from 'react-router-dom'; import type { DocumentFormat, Role } from '../lib/api.js'; import { AppearanceToggle } from './AppearanceToggle.js'; import { FormatBadge } from './FormatBadge.js'; diff --git a/apps/web/src/components/AppearanceToggle.tsx b/apps/web/src/components/AppearanceToggle.tsx index 0a2193a0..94f61b35 100644 --- a/apps/web/src/components/AppearanceToggle.tsx +++ b/apps/web/src/components/AppearanceToggle.tsx @@ -1,6 +1,6 @@ +import { DesktopIcon, MoonIcon, SunIcon } from '@radix-ui/react-icons'; import { IconButton, Tooltip } from '@radix-ui/themes'; -import { MoonIcon, SunIcon, DesktopIcon } from '@radix-ui/react-icons'; -import { useAppearance, type Appearance } from '../lib/appearance.js'; +import { type Appearance, useAppearance } from '../lib/appearance.js'; /** * Three-state toggle: light → dark → auto → light → … diff --git a/apps/web/src/components/AssetsPanel.tsx b/apps/web/src/components/AssetsPanel.tsx index 05f6129a..49092794 100644 --- a/apps/web/src/components/AssetsPanel.tsx +++ b/apps/web/src/components/AssetsPanel.tsx @@ -1,6 +1,6 @@ -import { useMemo, useRef } from 'react'; -import { IconButton, Text } from '@radix-ui/themes'; import { ChevronLeftIcon, ChevronRightIcon, Cross2Icon } from '@radix-ui/react-icons'; +import { IconButton, Text } from '@radix-ui/themes'; +import { useMemo, useRef } from 'react'; import { type AttachedAsset, assetProxyUrl } from '../lib/api.js'; import { ResizeHandle } from './ResizeHandle.js'; @@ -111,7 +111,16 @@ export function AssetsPanel({
)} - {open && } + {open && ( + + )} ); } diff --git a/apps/web/src/components/BlockActions.tsx b/apps/web/src/components/BlockActions.tsx index 4cd269e1..825fd18b 100644 --- a/apps/web/src/components/BlockActions.tsx +++ b/apps/web/src/components/BlockActions.tsx @@ -69,7 +69,11 @@ export function BlockActions({ rootRef, onPropose }: Props) { if (!block) { const btnRect = btnRef.current?.getBoundingClientRect() ?? null; const activeRect = hoveredTarget?.rect ?? renderedTarget?.rect ?? null; - if (btnRect && activeRect && isWithinHoverBridge(e.clientX, e.clientY, activeRect, btnRect)) { + if ( + btnRect && + activeRect && + isWithinHoverBridge(e.clientX, e.clientY, activeRect, btnRect) + ) { return; } setHoveredTarget((prev) => (prev === null ? prev : null)); @@ -198,7 +202,8 @@ function isWithinHoverBridge( const verticalReach = Math.min(34, Math.max(18, targetRect.height * 0.28)); const top = Math.min(buttonRect.top, targetRect.top) - 10; const bottom = Math.max(buttonRect.bottom, targetRect.top + verticalReach) + 10; - const left = Math.min(buttonRect.left, targetRect.right - Math.max(buttonRect.width + 24, 120)) - 10; + const left = + Math.min(buttonRect.left, targetRect.right - Math.max(buttonRect.width + 24, 120)) - 10; const right = Math.max(buttonRect.right, targetRect.right) + 10; return x >= left && x <= right && y >= top && y <= bottom; } diff --git a/apps/web/src/components/ConfirmButton.tsx b/apps/web/src/components/ConfirmButton.tsx index 87038faa..f08d45a0 100644 --- a/apps/web/src/components/ConfirmButton.tsx +++ b/apps/web/src/components/ConfirmButton.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Flex, IconButton, Tooltip } from '@radix-ui/themes'; import { CheckIcon, Cross2Icon, TrashIcon } from '@radix-ui/react-icons'; +import { Flex, IconButton, Tooltip } from '@radix-ui/themes'; +import { useCallback, useEffect, useRef, useState } from 'react'; interface Props { /** Label shown in the default state (e.g. "Delete"). */ diff --git a/apps/web/src/components/Copyable.tsx b/apps/web/src/components/Copyable.tsx index fc4b2d90..3b1133f3 100644 --- a/apps/web/src/components/Copyable.tsx +++ b/apps/web/src/components/Copyable.tsx @@ -1,6 +1,6 @@ -import { useRef, useState } from 'react'; -import { Code, IconButton, Tooltip } from '@radix-ui/themes'; import { CheckIcon, CopyIcon } from '@radix-ui/react-icons'; +import { Code, IconButton, Tooltip } from '@radix-ui/themes'; +import { useRef, useState } from 'react'; import { reportError } from '../lib/log.js'; interface Props { @@ -39,7 +39,9 @@ export function Copyable({ text, multiline = false, ariaLabel = 'Copy', size = ' return (
- {text} + + {text} + - - {oldLineNumber ?? ''} - - - {newLineNumber ?? ''} - + {oldLineNumber ?? ''} + {newLineNumber ?? ''} {line ? (line.op === 'add' ? '+' : line.op === 'remove' ? '−' : ' ') : '⋯'} diff --git a/apps/web/src/components/DocumentLayout.tsx b/apps/web/src/components/DocumentLayout.tsx index 777f0861..09476bf8 100644 --- a/apps/web/src/components/DocumentLayout.tsx +++ b/apps/web/src/components/DocumentLayout.tsx @@ -39,8 +39,6 @@ import type { } from '../lib/api.js'; import { ApiError, - type Comment, - type HistoryEntry, acceptEditProposal as apiAcceptProposal, createComment as apiCreate, createEditProposal as apiCreateProposal, @@ -53,8 +51,10 @@ import { revertHistoryVersion as apiRevertHistoryVersion, toggleCommentReaction as apiToggleReaction, updateComment as apiUpdate, + type Comment, getDocument, getHistoryDiff, + type HistoryEntry, isProposal, listThreads, uploadAsset, @@ -67,8 +67,8 @@ import { reportError } from '../lib/log.js'; import { savePendingNewDocumentDraft } from '../lib/new-document-draft.js'; import { ensureNotificationPermission, notify } from '../lib/notifications.js'; import { - BUILT_IN_THEMES, applyTheme, + BUILT_IN_THEMES, getUserThemeOverride, setUserThemeOverride, } from '../lib/themes.js'; @@ -84,14 +84,14 @@ import { import { DocumentSettingsDialog } from './DocumentSettingsDialog.js'; import { DownloadMenu } from './DownloadMenu.js'; import { HistoryList } from './HistoryList.js'; +import { InlineCommentsLayer } from './inline-comments/InlineCommentsLayer.js'; +import { InlineCommentsList } from './inline-comments/InlineCommentsList.js'; +import { COMMENT_FLASH_MS } from './inline-comments/inlineUtils.js'; import { type DocumentSearchOptions, RenderedDoc } from './RenderedDoc.js'; import { ResizeHandle } from './ResizeHandle.js'; import { type ProposalTarget, SelectionToolbar } from './SelectionToolbar.js'; import { ProposalComposer } from './ThreadComposer.js'; import { Toc } from './Toc.js'; -import { InlineCommentsLayer } from './inline-comments/InlineCommentsLayer.js'; -import { InlineCommentsList } from './inline-comments/InlineCommentsList.js'; -import { COMMENT_FLASH_MS } from './inline-comments/inlineUtils.js'; const MAX_WIDTH_KEY = 'marginalia.maxWidth'; const TEXT_ZOOM_KEY = 'marginalia.textZoom'; diff --git a/apps/web/src/components/DocumentSettingsDialog.tsx b/apps/web/src/components/DocumentSettingsDialog.tsx index 3bf9c118..da596b2e 100644 --- a/apps/web/src/components/DocumentSettingsDialog.tsx +++ b/apps/web/src/components/DocumentSettingsDialog.tsx @@ -43,9 +43,9 @@ export function DocumentSettingsDialog({ // Sentinel for "follow the server default" — Select needs a non-null // value, and translating at the API boundary is cleaner than // teaching the dropdown to render `null`. - const [mermaidChoice, setMermaidChoice] = useState( - doc.mermaid_renderer ?? DEFAULT_RENDERER_VALUE, - ); + const [mermaidChoice, setMermaidChoice] = useState< + MermaidRenderer | typeof DEFAULT_RENDERER_VALUE + >(doc.mermaid_renderer ?? DEFAULT_RENDERER_VALUE); const [saving, setSaving] = useState(false); const [exporting, setExporting] = useState(false); const [error, setError] = useState(null); @@ -65,8 +65,7 @@ export function DocumentSettingsDialog({ const patch: Parameters[1] = { name: docName.trim() ? docName.trim() : null, default_theme: defaultTheme, - mermaid_renderer: - mermaidChoice === DEFAULT_RENDERER_VALUE ? null : mermaidChoice, + mermaid_renderer: mermaidChoice === DEFAULT_RENDERER_VALUE ? null : mermaidChoice, }; const result = await updateDocumentSettings(doc.uid, patch, identity); onChange(result); @@ -107,7 +106,12 @@ export function DocumentSettingsDialog({ return ( - + @@ -164,9 +168,9 @@ export function DocumentSettingsDialog({ Mermaid renderer (exports only) - Affects PDF and Word downloads only — the viewer always uses - mermaid.js. "Default" follows the server setting. Pick "High - fidelity" if some diagrams render incorrectly under "Fast". + Affects PDF and Word downloads only — the viewer always uses mermaid.js. "Default" + follows the server setting. Pick "High fidelity" if some diagrams render incorrectly + under "Fast". Default Fast (native, lower fidelity) - - High fidelity (Chromium, slower) - + High fidelity (Chromium, slower) @@ -192,9 +194,9 @@ export function DocumentSettingsDialog({ JSON bundle - Versioned bundle with the source, comments, and renderer metadata for tooling or - later import. For day-to-day source or DOCX downloads, use the download icon - next to this gear instead. + Versioned bundle with the source, comments, and renderer metadata for tooling or later + import. For day-to-day source or DOCX downloads, use the download icon next to this + gear instead. @@ -89,7 +95,9 @@ export function EditToolbar({ : 'Add a rationale describing why this change should be made.'}