From 8c296a3d2bdf1870a05e604f982428040544d70f Mon Sep 17 00:00:00 2001 From: Kerwin Zheng <1747269691@qq.com> Date: Fri, 1 May 2026 23:35:31 +0800 Subject: [PATCH 01/11] feat(chat): add markdown preview export menu - Add export dropdown to markdown preview with PDF and Markdown actions - Add markdown export helpers for blob download and printable PDF window - Cover markdown export helpers and preview menu interactions with tests - Mock markdown ESM plugins in round artifact test to keep Jest compatible Co-authored-by: Codex --- ...assistant-message-round-artifacts.test.tsx | 5 + .../markdown-preview-panel.test.tsx | 72 +++++++ .../components/markdown-preview-panel.tsx | 85 +++++++-- lib/markdown-export.test.ts | 54 ++++++ lib/markdown-export.ts | 177 ++++++++++++++++++ 5 files changed, 379 insertions(+), 14 deletions(-) create mode 100644 app/chat/components/markdown-preview-panel.test.tsx create mode 100644 lib/markdown-export.test.ts create mode 100644 lib/markdown-export.ts diff --git a/app/chat/components/assistant-message-round-artifacts.test.tsx b/app/chat/components/assistant-message-round-artifacts.test.tsx index 082c4f0..731507a 100644 --- a/app/chat/components/assistant-message-round-artifacts.test.tsx +++ b/app/chat/components/assistant-message-round-artifacts.test.tsx @@ -10,6 +10,11 @@ jest.mock("react-markdown", () => ({ default: ({ children }: { children?: ReactNode }) => <>{children}, })); +jest.mock("rehype-katex", () => jest.fn()); +jest.mock("rehype-raw", () => jest.fn()); +jest.mock("remark-gfm", () => jest.fn()); +jest.mock("remark-math", () => jest.fn()); + const assistantMessageWithRoundArtifacts = { id: "assistant-round-artifacts", role: "assistant", diff --git a/app/chat/components/markdown-preview-panel.test.tsx b/app/chat/components/markdown-preview-panel.test.tsx new file mode 100644 index 0000000..3123f3f --- /dev/null +++ b/app/chat/components/markdown-preview-panel.test.tsx @@ -0,0 +1,72 @@ +import jotaiStore, { workspaceMarkdownContentAtom } from "@/atoms"; +import { exportMarkdownFile, exportMarkdownPdf } from "@/lib/markdown-export"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import MarkdownPreview from "./markdown-preview-panel"; + +jest.mock("@/lib/markdown-export", () => ({ + exportMarkdownFile: jest.fn(), + exportMarkdownPdf: jest.fn(() => true), +})); + +jest.mock("react-markdown", () => ({ + __esModule: true, + default: ({ children }: { children: string }) =>
{children}
, +})); + +jest.mock("rehype-katex", () => jest.fn()); +jest.mock("rehype-raw", () => jest.fn()); +jest.mock("remark-gfm", () => jest.fn()); +jest.mock("remark-math", () => jest.fn()); + +jest.mock("./mermaid-chart", () => ({ + __esModule: true, + default: ({ chart }: { chart: string }) =>
{chart}
, +})); + +const exportMarkdownFileMock = exportMarkdownFile as jest.MockedFunction< + typeof exportMarkdownFile +>; +const exportMarkdownPdfMock = exportMarkdownPdf as jest.MockedFunction< + typeof exportMarkdownPdf +>; + +describe("MarkdownPreview", () => { + beforeEach(() => { + act(() => { + jotaiStore.set(workspaceMarkdownContentAtom, "# Report\n\nHello export"); + }); + }); + + afterEach(() => { + act(() => { + jotaiStore.set(workspaceMarkdownContentAtom, ""); + }); + jest.clearAllMocks(); + }); + + it("exports the raw markdown content from the export menu", async () => { + render(); + + fireEvent.keyDown(screen.getByRole("button", { name: /export/i }), { + key: "ArrowDown", + }); + fireEvent.click(await screen.findByRole("menuitem", { name: /markdown/i })); + + expect(exportMarkdownFileMock).toHaveBeenCalledWith( + "# Report\n\nHello export", + ); + }); + + it("exports the rendered markdown preview as a printable pdf", async () => { + render(); + + fireEvent.keyDown(screen.getByRole("button", { name: /export/i }), { + key: "ArrowDown", + }); + fireEvent.click(await screen.findByRole("menuitem", { name: /pdf/i })); + + expect(exportMarkdownPdfMock).toHaveBeenCalledWith({ + sourceElement: expect.any(HTMLElement), + }); + }); +}); diff --git a/app/chat/components/markdown-preview-panel.tsx b/app/chat/components/markdown-preview-panel.tsx index 7843402..06f609c 100644 --- a/app/chat/components/markdown-preview-panel.tsx +++ b/app/chat/components/markdown-preview-panel.tsx @@ -3,10 +3,19 @@ import "@/styles/markdown-preview.css"; import "katex/dist/katex.min.css"; import { workspaceMarkdownContentAtom } from "@/atoms"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { exportMarkdownFile, exportMarkdownPdf } from "@/lib/markdown-export"; import { useAtomValue } from "jotai"; +import { ChevronDown, Download, FileText } from "lucide-react"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; -import { memo } from "react"; +import { memo, useCallback, useRef } from "react"; import Markdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; @@ -50,22 +59,70 @@ const MarkdownPreBlock = ({ const MarkdownPreview = () => { const markdownContent = useAtomValue(workspaceMarkdownContentAtom); + const previewRef = useRef(null); + const hasMarkdownContent = markdownContent.trim().length > 0; + + const handleExportMarkdown = useCallback(() => { + exportMarkdownFile(markdownContent); + }, [markdownContent]); + + const handleExportPdf = useCallback(() => { + if (!previewRef.current) { + return; + } + + exportMarkdownPdf({ + sourceElement: previewRef.current, + }); + }, []); return ( - -
- +
+ + + + + + + + PDF + + + + Markdown + + + +
+ +
- {markdownContent} - -
-
+ + {markdownContent} + +
+
+ ); }; diff --git a/lib/markdown-export.test.ts b/lib/markdown-export.test.ts new file mode 100644 index 0000000..96ae564 --- /dev/null +++ b/lib/markdown-export.test.ts @@ -0,0 +1,54 @@ +import { + buildMarkdownPrintHtml, + createMarkdownBlob, + withFileExtension, +} from "./markdown-export"; + +const readBlobText = (blob: Blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.addEventListener("load", () => { + resolve(String(reader.result)); + }); + reader.addEventListener("error", () => { + reject(reader.error); + }); + reader.readAsText(blob); + }); + +describe("markdown export helpers", () => { + it("adds the requested extension when filename has no extension", () => { + expect(withFileExtension("analysis-report", "md")).toBe( + "analysis-report.md", + ); + }); + + it("replaces a different extension with the requested extension", () => { + expect(withFileExtension("analysis-report.pdf", "md")).toBe( + "analysis-report.md", + ); + }); + + it("creates a markdown blob with utf-8 content type", async () => { + const blob = createMarkdownBlob("# Report\n\nHello"); + + expect(blob.type).toBe("text/markdown;charset=utf-8"); + await expect(readBlobText(blob)).resolves.toBe("# Report\n\nHello"); + }); + + it("builds a print html document with escaped title and rendered body", () => { + const html = buildMarkdownPrintHtml({ + bodyHtml: "

Report

Ready

", + headMarkup: '', + title: 'Report ', + }); + + expect(html).toContain( + "Report <script>alert("x")</script>", + ); + expect(html).toContain(''); + expect(html).toContain('
'); + expect(html).toContain("

Report

Ready

"); + }); +}); diff --git a/lib/markdown-export.ts b/lib/markdown-export.ts new file mode 100644 index 0000000..5d2595a --- /dev/null +++ b/lib/markdown-export.ts @@ -0,0 +1,177 @@ +const MARKDOWN_EXPORT_BASENAME = "refract-markdown"; +const MARKDOWN_PRINT_TITLE = "Refract Markdown Export"; +const MARKDOWN_MIME_TYPE = "text/markdown;charset=utf-8"; + +type FileExtension = "md" | "pdf"; + +type DownloadBlobOptions = { + document?: Document; + url?: Pick; +}; + +type MarkdownFileExportOptions = DownloadBlobOptions & { + filename?: string; +}; + +type MarkdownPdfExportOptions = { + document?: Document; + sourceElement: HTMLElement; + title?: string; + window?: Window; +}; + +type MarkdownPrintHtmlOptions = { + bodyHtml: string; + headMarkup?: string; + title?: string; +}; + +const escapeHtml = (value: string) => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +const withFileExtension = (filename: string, extension: FileExtension) => { + const cleanFilename = filename.trim() || MARKDOWN_EXPORT_BASENAME; + const normalizedExtension = extension.replace(/^\./, ""); + const filenameWithoutExtension = cleanFilename.replace(/\.[^/.]+$/, ""); + + return `${filenameWithoutExtension}.${normalizedExtension}`; +}; + +const createMarkdownBlob = (content: string) => + new Blob([content], { type: MARKDOWN_MIME_TYPE }); + +const downloadBlob = ( + blob: Blob, + filename: string, + options: DownloadBlobOptions = {}, +) => { + const targetDocument = options.document ?? document; + const urlApi = options.url ?? URL; + const objectUrl = urlApi.createObjectURL(blob); + const link = targetDocument.createElement("a"); + + link.href = objectUrl; + link.download = filename; + link.rel = "noopener"; + + targetDocument.body.append(link); + link.click(); + link.remove(); + urlApi.revokeObjectURL(objectUrl); +}; + +const exportMarkdownFile = ( + content: string, + options: MarkdownFileExportOptions = {}, +) => { + const filename = withFileExtension( + options.filename ?? MARKDOWN_EXPORT_BASENAME, + "md", + ); + const blob = createMarkdownBlob(content); + + downloadBlob(blob, filename, options); + + return filename; +}; + +const collectPrintableHeadMarkup = (sourceDocument: Document) => { + const nodes = sourceDocument.querySelectorAll< + HTMLLinkElement | HTMLStyleElement + >('link[rel="stylesheet"], style'); + + return Array.from(nodes) + .map((node) => node.outerHTML) + .join("\n"); +}; + +const buildMarkdownPrintHtml = ({ + bodyHtml, + headMarkup = "", + title = MARKDOWN_PRINT_TITLE, +}: MarkdownPrintHtmlOptions) => ` + + + + +${escapeHtml(title)} +${headMarkup} + + + +
+${bodyHtml} +
+ +`; + +const exportMarkdownPdf = ({ + document: sourceDocument = document, + sourceElement, + title = MARKDOWN_PRINT_TITLE, + window: targetWindow = window, +}: MarkdownPdfExportOptions) => { + const bodyHtml = sourceElement.innerHTML; + + if (!bodyHtml.trim()) { + return false; + } + + const printWindow = targetWindow.open("", "_blank"); + + if (!printWindow) { + return false; + } + + const html = buildMarkdownPrintHtml({ + bodyHtml, + headMarkup: collectPrintableHeadMarkup(sourceDocument), + title, + }); + + printWindow.document.open(); + printWindow.document.write(html); + printWindow.document.close(); + printWindow.setTimeout(() => { + printWindow.focus(); + printWindow.print(); + }, 250); + + return true; +}; + +export { + buildMarkdownPrintHtml, + createMarkdownBlob, + exportMarkdownFile, + exportMarkdownPdf, + withFileExtension, +}; +export type { MarkdownFileExportOptions, MarkdownPdfExportOptions }; From 62239417ea08ed3d8f520fb5208a82772406c9f2 Mon Sep 17 00:00:00 2001 From: Kerwin Zheng <1747269691@qq.com> Date: Sat, 2 May 2026 11:36:04 +0800 Subject: [PATCH 02/11] fix(markdown): download styled preview as pdf - Replace print-window PDF export with html2canvas plus jsPDF direct save - Capture the rendered markdown preview DOM so markdown-preview.css styling is preserved visually - Paginate rendered canvases into A4 PDF pages - Disable repeated PDF export clicks while rendering - Add focused tests for canvas rendering, PDF pagination, and async preview export Co-authored-by: Codex --- .../markdown-preview-panel.test.tsx | 20 +- .../components/markdown-preview-panel.tsx | 26 +- lib/markdown-export.test.ts | 133 +++++++++- lib/markdown-export.ts | 238 +++++++++++------- package.json | 2 + pnpm-lock.yaml | 154 ++++++++++++ 6 files changed, 460 insertions(+), 113 deletions(-) diff --git a/app/chat/components/markdown-preview-panel.test.tsx b/app/chat/components/markdown-preview-panel.test.tsx index 3123f3f..24aeb9f 100644 --- a/app/chat/components/markdown-preview-panel.test.tsx +++ b/app/chat/components/markdown-preview-panel.test.tsx @@ -1,11 +1,17 @@ import jotaiStore, { workspaceMarkdownContentAtom } from "@/atoms"; import { exportMarkdownFile, exportMarkdownPdf } from "@/lib/markdown-export"; -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import MarkdownPreview from "./markdown-preview-panel"; jest.mock("@/lib/markdown-export", () => ({ exportMarkdownFile: jest.fn(), - exportMarkdownPdf: jest.fn(() => true), + exportMarkdownPdf: jest.fn(async () => "refract-markdown.pdf"), })); jest.mock("react-markdown", () => ({ @@ -57,7 +63,7 @@ describe("MarkdownPreview", () => { ); }); - it("exports the rendered markdown preview as a printable pdf", async () => { + it("downloads the rendered markdown preview as a pdf", async () => { render(); fireEvent.keyDown(screen.getByRole("button", { name: /export/i }), { @@ -65,8 +71,10 @@ describe("MarkdownPreview", () => { }); fireEvent.click(await screen.findByRole("menuitem", { name: /pdf/i })); - expect(exportMarkdownPdfMock).toHaveBeenCalledWith({ - sourceElement: expect.any(HTMLElement), - }); + await waitFor(() => + expect(exportMarkdownPdfMock).toHaveBeenCalledWith({ + sourceElement: expect.any(HTMLElement), + }), + ); }); }); diff --git a/app/chat/components/markdown-preview-panel.tsx b/app/chat/components/markdown-preview-panel.tsx index 06f609c..d9b5a8e 100644 --- a/app/chat/components/markdown-preview-panel.tsx +++ b/app/chat/components/markdown-preview-panel.tsx @@ -15,7 +15,7 @@ import { exportMarkdownFile, exportMarkdownPdf } from "@/lib/markdown-export"; import { useAtomValue } from "jotai"; import { ChevronDown, Download, FileText } from "lucide-react"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; -import { memo, useCallback, useRef } from "react"; +import { memo, useCallback, useRef, useState } from "react"; import Markdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; @@ -60,21 +60,28 @@ const MarkdownPreBlock = ({ const MarkdownPreview = () => { const markdownContent = useAtomValue(workspaceMarkdownContentAtom); const previewRef = useRef(null); + const [isExportingPdf, setIsExportingPdf] = useState(false); const hasMarkdownContent = markdownContent.trim().length > 0; const handleExportMarkdown = useCallback(() => { exportMarkdownFile(markdownContent); }, [markdownContent]); - const handleExportPdf = useCallback(() => { - if (!previewRef.current) { + const handleExportPdf = useCallback(async () => { + if (!previewRef.current || isExportingPdf) { return; } - exportMarkdownPdf({ - sourceElement: previewRef.current, - }); - }, []); + setIsExportingPdf(true); + + try { + await exportMarkdownPdf({ + sourceElement: previewRef.current, + }); + } finally { + setIsExportingPdf(false); + } + }, [isExportingPdf]); return (
@@ -94,7 +101,10 @@ const MarkdownPreview = () => { - + PDF diff --git a/lib/markdown-export.test.ts b/lib/markdown-export.test.ts index 96ae564..5ea916b 100644 --- a/lib/markdown-export.test.ts +++ b/lib/markdown-export.test.ts @@ -1,6 +1,23 @@ +jest.mock("html2canvas", () => jest.fn()); +jest.mock("jspdf", () => ({ + jsPDF: jest.fn(() => ({ + addImage: jest.fn(), + addPage: jest.fn(), + internal: { + pageSize: { + getHeight: () => 842, + getWidth: () => 595, + }, + }, + save: jest.fn(), + })), +})); + import { - buildMarkdownPrintHtml, + type PdfDocument, + addCanvasToPdf, createMarkdownBlob, + exportMarkdownPdf, withFileExtension, } from "./markdown-export"; @@ -37,18 +54,112 @@ describe("markdown export helpers", () => { await expect(readBlobText(blob)).resolves.toBe("# Report\n\nHello"); }); - it("builds a print html document with escaped title and rendered body", () => { - const html = buildMarkdownPrintHtml({ - bodyHtml: "

Report

Ready

", - headMarkup: '', - title: 'Report ', + it("adds a rendered markdown canvas to a pdf page", () => { + const canvas = document.createElement("canvas"); + const drawImage = jest.fn(); + const pdf: PdfDocument = { + addImage: jest.fn(), + addPage: jest.fn(), + internal: { + pageSize: { + getHeight: () => 200, + getWidth: () => 100, + }, + }, + save: jest.fn(), + }; + const getContextSpy = jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); + const toDataURLSpy = jest + .spyOn(HTMLCanvasElement.prototype, "toDataURL") + .mockReturnValue("data:image/png;base64,page"); + + canvas.width = 100; + canvas.height = 300; + + addCanvasToPdf(canvas, pdf); + + expect(pdf.addImage).toHaveBeenCalledTimes(2); + expect(pdf.addPage).toHaveBeenCalledTimes(1); + expect(pdf.addImage).toHaveBeenNthCalledWith( + 1, + "data:image/png;base64,page", + "PNG", + 0, + 0, + 100, + 200, + ); + + getContextSpy.mockRestore(); + toDataURLSpy.mockRestore(); + }); + + it("renders the styled markdown element to canvas and saves the pdf directly", async () => { + const sourceElement = document.createElement("article"); + const canvas = document.createElement("canvas"); + const drawImage = jest.fn(); + const canvasRenderer = jest.fn(async () => canvas); + const pdf: PdfDocument = { + addImage: jest.fn(), + addPage: jest.fn(), + internal: { + pageSize: { + getHeight: () => 842, + getWidth: () => 595, + }, + }, + save: jest.fn(), + }; + const openSpy = jest.spyOn(window, "open").mockReturnValue(null); + const getContextSpy = jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); + const toDataURLSpy = jest + .spyOn(HTMLCanvasElement.prototype, "toDataURL") + .mockReturnValue("data:image/png;base64,markdown"); + + sourceElement.innerHTML = "

Report

Ready

"; + Object.defineProperty(sourceElement, "scrollHeight", { + configurable: true, + value: 720, }); + Object.defineProperty(sourceElement, "scrollWidth", { + configurable: true, + value: 560, + }); + canvas.width = 560; + canvas.height = 720; - expect(html).toContain( - "Report <script>alert("x")</script>", + const filename = await exportMarkdownPdf({ + canvasRenderer, + filename: "analysis-report", + pdfFactory: () => pdf, + sourceElement, + }); + + expect(filename).toBe("analysis-report.pdf"); + expect(canvasRenderer).toHaveBeenCalledWith(sourceElement, { + backgroundColor: "#ffffff", + scale: 2, + useCORS: true, + windowHeight: 720, + windowWidth: 560, + }); + expect(openSpy).not.toHaveBeenCalled(); + expect(pdf.addImage).toHaveBeenCalledWith( + "data:image/png;base64,markdown", + "PNG", + 0, + 0, + 595, + expect.any(Number), ); - expect(html).toContain(''); - expect(html).toContain('
'); - expect(html).toContain("

Report

Ready

"); + expect(pdf.save).toHaveBeenCalledWith("analysis-report.pdf"); + + openSpy.mockRestore(); + getContextSpy.mockRestore(); + toDataURLSpy.mockRestore(); }); }); diff --git a/lib/markdown-export.ts b/lib/markdown-export.ts index 5d2595a..018ead1 100644 --- a/lib/markdown-export.ts +++ b/lib/markdown-export.ts @@ -1,6 +1,10 @@ +import html2canvas from "html2canvas"; +import { jsPDF } from "jspdf"; + const MARKDOWN_EXPORT_BASENAME = "refract-markdown"; -const MARKDOWN_PRINT_TITLE = "Refract Markdown Export"; const MARKDOWN_MIME_TYPE = "text/markdown;charset=utf-8"; +const PDF_PAGE_MARGIN = 0; +const PDF_RENDER_SCALE = 2; type FileExtension = "md" | "pdf"; @@ -13,26 +17,44 @@ type MarkdownFileExportOptions = DownloadBlobOptions & { filename?: string; }; -type MarkdownPdfExportOptions = { - document?: Document; - sourceElement: HTMLElement; - title?: string; - window?: Window; +type CanvasRenderOptions = { + backgroundColor: string; + scale: number; + useCORS: boolean; + windowHeight: number; + windowWidth: number; }; -type MarkdownPrintHtmlOptions = { - bodyHtml: string; - headMarkup?: string; - title?: string; +type CanvasRenderer = ( + sourceElement: HTMLElement, + options: CanvasRenderOptions, +) => Promise; + +type PdfDocument = { + addImage: ( + imageData: string, + format: "PNG", + x: number, + y: number, + width: number, + height: number, + ) => void; + addPage: () => void; + internal: { + pageSize: { + getHeight: () => number; + getWidth: () => number; + }; + }; + save: (filename: string) => void; }; -const escapeHtml = (value: string) => - value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); +type MarkdownPdfExportOptions = { + canvasRenderer?: CanvasRenderer; + filename?: string; + pdfFactory?: () => PdfDocument; + sourceElement: HTMLElement; +}; const withFileExtension = (filename: string, extension: FileExtension) => { const cleanFilename = filename.trim() || MARKDOWN_EXPORT_BASENAME; @@ -80,98 +102,138 @@ const exportMarkdownFile = ( return filename; }; -const collectPrintableHeadMarkup = (sourceDocument: Document) => { - const nodes = sourceDocument.querySelectorAll< - HTMLLinkElement | HTMLStyleElement - >('link[rel="stylesheet"], style'); +const createPdfDocument = (): PdfDocument => + new jsPDF({ + format: "a4", + orientation: "portrait", + unit: "pt", + }) as PdfDocument; - return Array.from(nodes) - .map((node) => node.outerHTML) - .join("\n"); +const renderElementToCanvas: CanvasRenderer = (sourceElement, options) => + html2canvas(sourceElement, options); + +const hasExportableContent = (sourceElement: HTMLElement) => { + const visibleText = + "innerText" in sourceElement && typeof sourceElement.innerText === "string" + ? sourceElement.innerText + : (sourceElement.textContent ?? ""); + + return Boolean(visibleText.trim() || sourceElement.innerHTML.trim()); }; -const buildMarkdownPrintHtml = ({ - bodyHtml, - headMarkup = "", - title = MARKDOWN_PRINT_TITLE, -}: MarkdownPrintHtmlOptions) => ` - - - - -${escapeHtml(title)} -${headMarkup} - - - -
-${bodyHtml} -
- -`; - -const exportMarkdownPdf = ({ - document: sourceDocument = document, - sourceElement, - title = MARKDOWN_PRINT_TITLE, - window: targetWindow = window, -}: MarkdownPdfExportOptions) => { - const bodyHtml = sourceElement.innerHTML; - if (!bodyHtml.trim()) { - return false; - } + context.drawImage( + sourceCanvas, + 0, + offsetY, + sourceCanvas.width, + sliceHeight, + 0, + 0, + sourceCanvas.width, + sliceHeight, + ); + + return sliceCanvas; +}; - const printWindow = targetWindow.open("", "_blank"); +const addCanvasToPdf = (canvas: HTMLCanvasElement, pdf: PdfDocument) => { + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const printableWidth = pageWidth - PDF_PAGE_MARGIN * 2; + const printableHeight = pageHeight - PDF_PAGE_MARGIN * 2; + const pageCanvasHeight = Math.floor( + (printableHeight * canvas.width) / printableWidth, + ); + + for (let offsetY = 0; offsetY < canvas.height; offsetY += pageCanvasHeight) { + if (offsetY > 0) { + pdf.addPage(); + } + + const sliceHeight = Math.min(pageCanvasHeight, canvas.height - offsetY); + const pageCanvas = createCanvasSlice(canvas, offsetY, sliceHeight); + const pageImageHeight = (sliceHeight * printableWidth) / canvas.width; + + pdf.addImage( + pageCanvas.toDataURL("image/png"), + "PNG", + PDF_PAGE_MARGIN, + PDF_PAGE_MARGIN, + printableWidth, + pageImageHeight, + ); + } +}; - if (!printWindow) { +const exportMarkdownPdf = async ({ + canvasRenderer = renderElementToCanvas, + filename = MARKDOWN_EXPORT_BASENAME, + pdfFactory = createPdfDocument, + sourceElement, +}: MarkdownPdfExportOptions) => { + if (!hasExportableContent(sourceElement)) { return false; } - const html = buildMarkdownPrintHtml({ - bodyHtml, - headMarkup: collectPrintableHeadMarkup(sourceDocument), - title, + const normalizedFilename = withFileExtension(filename, "pdf"); + const { windowHeight, windowWidth } = getCanvasWindowSize(sourceElement); + const canvas = await canvasRenderer(sourceElement, { + backgroundColor: "#ffffff", + scale: PDF_RENDER_SCALE, + useCORS: true, + windowHeight, + windowWidth, }); + const pdf = pdfFactory(); - printWindow.document.open(); - printWindow.document.write(html); - printWindow.document.close(); - printWindow.setTimeout(() => { - printWindow.focus(); - printWindow.print(); - }, 250); + addCanvasToPdf(canvas, pdf); + pdf.save(normalizedFilename); - return true; + return normalizedFilename; }; export { - buildMarkdownPrintHtml, + addCanvasToPdf, createMarkdownBlob, exportMarkdownFile, exportMarkdownPdf, withFileExtension, }; -export type { MarkdownFileExportOptions, MarkdownPdfExportOptions }; +export type { + CanvasRenderer, + MarkdownFileExportOptions, + MarkdownPdfExportOptions, + PdfDocument, +}; diff --git a/package.json b/package.json index da9494d..e89309c 100644 --- a/package.json +++ b/package.json @@ -49,9 +49,11 @@ "dexie": "^4.4.2", "drizzle-orm": "^0.45.2", "frimousse": "^0.3.0", + "html2canvas": "^1.4.1", "iconv-lite": "^0.7.2", "input-otp": "^1.4.2", "jotai": "^2.18.0", + "jspdf": "^4.2.1", "katex": "^0.16.45", "lucide-react": "^0.575.0", "mermaid": "^11.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3471ae..3a2ed36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: frimousse: specifier: ^0.3.0 version: 0.3.0(react@19.2.3)(typescript@5.9.3) + html2canvas: + specifier: ^1.4.1 + version: 1.4.1 iconv-lite: specifier: ^0.7.2 version: 0.7.2 @@ -101,6 +104,9 @@ importers: jotai: specifier: ^2.18.0 version: 2.18.0(@babel/core@7.28.5)(@babel/template@7.28.6)(@types/react@19.2.0)(react@19.2.3) + jspdf: + specifier: ^4.2.1 + version: 4.2.1 katex: specifier: ^0.16.45 version: 0.16.45 @@ -671,6 +677,10 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -3981,12 +3991,18 @@ packages: '@types/node@20.19.19': resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/pg-pool@2.0.6': resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} '@types/pg@8.6.1': resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/react-dom@19.2.0': resolution: {integrity: sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==} peerDependencies: @@ -4320,6 +4336,10 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + baseline-browser-mapping@2.9.11: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true @@ -4434,6 +4454,10 @@ packages: caniuse-lite@1.0.30001762: resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4653,6 +4677,9 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -5245,6 +5272,9 @@ packages: fast-json-stringify@5.16.1: resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} @@ -5291,6 +5321,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -5539,6 +5572,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} @@ -5628,6 +5665,9 @@ packages: intl-messageformat@11.2.0: resolution: {integrity: sha512-IhghAA8n4KSlXuWKzYsWyWb82JoYTzShfyvdSF85oJPnNOjvv4kAo7S7Jtkm3/vJ53C7dQNRO+Gpnj3iWgTjBQ==} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -6000,6 +6040,9 @@ packages: resolution: {integrity: sha512-HIf1uwublnXZsy7p3yHTrhzMzrLO6xKnqXytT9pEil5QxaXi8eyer7Is4luF5hYSV4kD3v03Y32FWoAeVYTghQ==} hasBin: true + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + katex@0.16.45: resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} hasBin: true @@ -6637,6 +6680,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -6711,6 +6757,9 @@ packages: peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -6939,6 +6988,9 @@ packages: '@types/react-dom': optional: true + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + railroad-diagrams@1.0.0: resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} @@ -7050,6 +7102,9 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} @@ -7125,6 +7180,10 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -7303,6 +7362,10 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -7418,6 +7481,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + swr@2.4.1: resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==} peerDependencies: @@ -7462,6 +7529,9 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} @@ -7688,6 +7758,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -8691,6 +8764,8 @@ snapshots: '@babel/runtime@7.28.4': {} + '@babel/runtime@7.29.2': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -12193,6 +12268,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pako@2.0.4': {} + '@types/pg-pool@2.0.6': dependencies: '@types/pg': 8.6.1 @@ -12203,6 +12280,9 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 + '@types/raf@3.4.3': + optional: true + '@types/react-dom@19.2.0(@types/react@19.2.0)': dependencies: '@types/react': 19.2.0 @@ -12492,6 +12572,8 @@ snapshots: balanced-match@4.0.4: {} + base64-arraybuffer@1.0.2: {} + baseline-browser-mapping@2.9.11: {} better-auth@1.4.6(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -12598,6 +12680,18 @@ snapshots: caniuse-lite@1.0.30001762: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.29.2 + '@types/raf': 3.4.3 + core-js: 3.49.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + ccount@2.0.1: {} cfb@1.2.2: @@ -12776,6 +12870,10 @@ snapshots: crypto-js@4.2.0: {} + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -13385,6 +13483,12 @@ snapshots: json-schema-ref-resolver: 1.0.1 rfdc: 1.4.1 + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + fast-querystring@1.1.2: dependencies: fast-decode-uri-component: 1.0.1 @@ -13447,6 +13551,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.2: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -13756,6 +13862,11 @@ snapshots: html-void-elements@3.0.0: {} + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -13850,6 +13961,8 @@ snapshots: '@formatjs/fast-memoize': 3.1.1 '@formatjs/icu-messageformat-parser': 3.5.3 + iobuffer@5.4.0: {} + ip-address@10.0.1: {} ipaddr.js@1.9.1: {} @@ -14377,6 +14490,17 @@ snapshots: jsox@1.2.125: {} + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.2 + fast-png: 6.4.0 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.49.0 + dompurify: 3.4.2 + html2canvas: 1.4.1 + katex@0.16.45: dependencies: commander: 8.3.0 @@ -15219,6 +15343,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -15287,6 +15413,9 @@ snapshots: peberminta@0.9.0: {} + performance-now@2.1.0: + optional: true + pg-cloudflare@1.3.0: optional: true @@ -15555,6 +15684,11 @@ snapshots: '@types/react': 19.2.0 '@types/react-dom': 19.2.0(@types/react@19.2.0) + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + railroad-diagrams@1.0.0: {} randexp@0.4.6: @@ -15673,6 +15807,9 @@ snapshots: reflect-metadata@0.2.2: {} + regenerator-runtime@0.13.11: + optional: true + rehype-katex@7.0.1: dependencies: '@types/hast': 3.0.4 @@ -15775,6 +15912,9 @@ snapshots: rfdc@1.4.1: {} + rgbcolor@1.0.1: + optional: true + robust-predicates@3.0.3: {} rou3@0.7.12: {} @@ -16031,6 +16171,9 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackblur-canvas@2.7.0: + optional: true + statuses@2.0.2: {} stdin-discarder@0.2.2: {} @@ -16133,6 +16276,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-pathdata@6.0.3: + optional: true + swr@2.4.1(react@19.2.3): dependencies: dequal: 2.0.3 @@ -16181,6 +16327,10 @@ snapshots: glob: 7.2.3 minimatch: 3.1.2 + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + thread-stream@3.1.0: dependencies: real-require: 0.2.0 @@ -16423,6 +16573,10 @@ snapshots: util-deprecate@1.0.2: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + uuid@11.1.0: {} v8-compile-cache-lib@3.0.1: {} From a6097a11ea7e35ebbc9c2ca2ac1c5745618d4f02 Mon Sep 17 00:00:00 2001 From: Kerwin Zheng <1747269691@qq.com> Date: Sat, 2 May 2026 20:28:34 +0800 Subject: [PATCH 03/11] fix(markdown): render pdf exports with printable layout - Render markdown PDFs from an isolated A4-width clone instead of the live preview panel - Add printable page margins and scale canvas slices to the PDF printable area - Embed URL-based images as data URLs before html2canvas captures the export clone - Strip unsupported lab/lch/oklch/color functions from export-only styles to avoid html2canvas parse failures - Cover A4 sizing, image embedding, and color sanitization in markdown export tests Co-authored-by: Codex --- lib/markdown-export.test.ts | 91 +++++++++++-- lib/markdown-export.ts | 257 ++++++++++++++++++++++++++++++++++-- 2 files changed, 324 insertions(+), 24 deletions(-) diff --git a/lib/markdown-export.test.ts b/lib/markdown-export.test.ts index 5ea916b..0348474 100644 --- a/lib/markdown-export.test.ts +++ b/lib/markdown-export.test.ts @@ -62,8 +62,8 @@ describe("markdown export helpers", () => { addPage: jest.fn(), internal: { pageSize: { - getHeight: () => 200, - getWidth: () => 100, + getHeight: () => 172, + getWidth: () => 172, }, }, save: jest.fn(), @@ -80,16 +80,16 @@ describe("markdown export helpers", () => { addCanvasToPdf(canvas, pdf); - expect(pdf.addImage).toHaveBeenCalledTimes(2); - expect(pdf.addPage).toHaveBeenCalledTimes(1); + expect(pdf.addImage).toHaveBeenCalledTimes(3); + expect(pdf.addPage).toHaveBeenCalledTimes(2); expect(pdf.addImage).toHaveBeenNthCalledWith( 1, "data:image/png;base64,page", "PNG", - 0, - 0, + 36, + 36, + 100, 100, - 200, ); getContextSpy.mockRestore(); @@ -140,20 +140,24 @@ describe("markdown export helpers", () => { }); expect(filename).toBe("analysis-report.pdf"); - expect(canvasRenderer).toHaveBeenCalledWith(sourceElement, { + expect(canvasRenderer).toHaveBeenCalledWith(expect.any(HTMLElement), { backgroundColor: "#ffffff", scale: 2, useCORS: true, - windowHeight: 720, - windowWidth: 560, + windowHeight: expect.any(Number), + windowWidth: 698, }); + expect(canvasRenderer.mock.calls[0]?.[0]).not.toBe(sourceElement); + expect(canvasRenderer.mock.calls[0]?.[0].style.width).toBe( + "697.3333333333333px", + ); expect(openSpy).not.toHaveBeenCalled(); expect(pdf.addImage).toHaveBeenCalledWith( "data:image/png;base64,markdown", "PNG", - 0, - 0, - 595, + 36, + 36, + 523, expect.any(Number), ); expect(pdf.save).toHaveBeenCalledWith("analysis-report.pdf"); @@ -162,4 +166,65 @@ describe("markdown export helpers", () => { getContextSpy.mockRestore(); toDataURLSpy.mockRestore(); }); + + it("prepares a pdf-only A4 clone with embedded URL images and safe colors", async () => { + const sourceElement = document.createElement("article"); + const canvas = document.createElement("canvas"); + const drawImage = jest.fn(); + const canvasRenderer = jest.fn(async () => canvas); + const pdf: PdfDocument = { + addImage: jest.fn(), + addPage: jest.fn(), + internal: { + pageSize: { + getHeight: () => 842, + getWidth: () => 595, + }, + }, + save: jest.fn(), + }; + const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue({ + blob: async () => new Blob(["image"], { type: "image/png" }), + ok: true, + } as Response); + const getContextSpy = jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); + const toDataURLSpy = jest + .spyOn(HTMLCanvasElement.prototype, "toDataURL") + .mockReturnValue("data:image/png;base64,markdown"); + + sourceElement.className = "prose prose-sm dark:prose-invert markdown-body"; + sourceElement.innerHTML = ` +

Report

+ Chart + `; + canvas.width = 1394; + canvas.height = 1000; + + await exportMarkdownPdf({ + canvasRenderer, + pdfFactory: () => pdf, + sourceElement, + }); + + const exportElement = canvasRenderer.mock.calls[0]?.[0]; + const exportImage = exportElement?.querySelector("img"); + const exportHeading = exportElement?.querySelector("h1"); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://assets.example.test/chart.png", + { credentials: "omit", mode: "cors" }, + ); + expect(exportElement?.className).toBe("markdown-body"); + expect(exportImage?.getAttribute("src")).toMatch( + /^data:image\/png;base64,/, + ); + expect(exportImage?.getAttribute("srcset")).toBeNull(); + expect(exportHeading?.getAttribute("style") ?? "").not.toContain("lab("); + + fetchSpy.mockRestore(); + getContextSpy.mockRestore(); + toDataURLSpy.mockRestore(); + }); }); diff --git a/lib/markdown-export.ts b/lib/markdown-export.ts index 018ead1..8091a78 100644 --- a/lib/markdown-export.ts +++ b/lib/markdown-export.ts @@ -3,8 +3,10 @@ import { jsPDF } from "jspdf"; const MARKDOWN_EXPORT_BASENAME = "refract-markdown"; const MARKDOWN_MIME_TYPE = "text/markdown;charset=utf-8"; -const PDF_PAGE_MARGIN = 0; +const PDF_PAGE_MARGIN = 36; const PDF_RENDER_SCALE = 2; +const CSS_PIXELS_PER_POINT = 96 / 72; +const UNSUPPORTED_COLOR_FUNCTION_PATTERN = /\b(?:lab|lch|oklab|oklch|color)\(/i; type FileExtension = "md" | "pdf"; @@ -56,6 +58,18 @@ type MarkdownPdfExportOptions = { sourceElement: HTMLElement; }; +type PdfExportLayout = { + cssPageHeight: number; + cssWidth: number; + windowWidth: number; +}; + +type PreparedExportElement = { + cleanup: () => void; + element: HTMLElement; + layout: PdfExportLayout; +}; + const withFileExtension = (filename: string, extension: FileExtension) => { const cleanFilename = filename.trim() || MARKDOWN_EXPORT_BASENAME; const normalizedExtension = extension.replace(/^\./, ""); @@ -137,6 +151,217 @@ const getCanvasWindowSize = (sourceElement: HTMLElement) => { }; }; +const getPdfExportLayout = (pdf: PdfDocument): PdfExportLayout => { + const pageWidth = pdf.internal.pageSize.getWidth(); + const pageHeight = pdf.internal.pageSize.getHeight(); + const printableWidth = pageWidth - PDF_PAGE_MARGIN * 2; + const cssWidth = printableWidth * CSS_PIXELS_PER_POINT; + + return { + cssPageHeight: pageHeight * CSS_PIXELS_PER_POINT, + cssWidth, + windowWidth: Math.ceil(cssWidth), + }; +}; + +const getExportCanvasWindowSize = ( + sourceElement: HTMLElement, + exportElement: HTMLElement, + layout: PdfExportLayout, +) => { + const { windowHeight } = getCanvasWindowSize(exportElement); + const sourceSize = getCanvasWindowSize(sourceElement); + + return { + windowHeight: + windowHeight || + sourceSize.windowHeight || + Math.ceil(layout.cssPageHeight), + windowWidth: layout.windowWidth, + }; +}; + +const readBlobAsDataUrl = (blob: Blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.addEventListener("load", () => { + resolve(String(reader.result)); + }); + reader.addEventListener("error", () => { + reject(reader.error); + }); + reader.readAsDataURL(blob); + }); + +const isEmbeddableImageUrl = (src: string) => { + if (!src || src.startsWith("data:") || src.startsWith("blob:")) { + return false; + } + + return true; +}; + +const embedImageSource = async (image: HTMLImageElement) => { + const src = image.currentSrc || image.getAttribute("src") || image.src; + + if (!isEmbeddableImageUrl(src) || typeof fetch !== "function") { + return; + } + + try { + const response = await fetch(src, { credentials: "omit", mode: "cors" }); + + if (!response.ok) { + throw new Error(`Image request failed with status ${response.status}`); + } + + const dataUrl = await readBlobAsDataUrl(await response.blob()); + + image.removeAttribute("srcset"); + image.crossOrigin = "anonymous"; + image.src = dataUrl; + } catch { + image.crossOrigin = "anonymous"; + image.referrerPolicy = "no-referrer"; + image.removeAttribute("srcset"); + image.src = src; + } +}; + +const prepareImagesForExport = async (exportElement: HTMLElement) => { + const images = Array.from(exportElement.querySelectorAll("img")); + + await Promise.all(images.map((image) => embedImageSource(image))); +}; + +const getColorFallback = (property: string) => { + if (property.includes("background")) { + return "transparent"; + } + + if (property.includes("shadow")) { + return "none"; + } + + if (property.includes("border") || property.includes("outline")) { + return "#d1d5db"; + } + + return "#111827"; +}; + +const sanitizeInlineColorStyles = (element: HTMLElement) => { + const inlineStyle = element.getAttribute("style"); + + if (!inlineStyle || !UNSUPPORTED_COLOR_FUNCTION_PATTERN.test(inlineStyle)) { + return; + } + + const safeDeclarations = inlineStyle + .split(";") + .map((declaration) => declaration.trim()) + .filter( + (declaration) => + declaration && !UNSUPPORTED_COLOR_FUNCTION_PATTERN.test(declaration), + ); + + if (safeDeclarations.length > 0) { + element.setAttribute("style", safeDeclarations.join("; ")); + return; + } + + element.removeAttribute("style"); +}; + +const sanitizeComputedColorStyles = (element: HTMLElement) => { + const view = element.ownerDocument.defaultView; + + if (!view) { + return; + } + + const computedStyle = view.getComputedStyle(element); + const colorProperties = [ + "background-color", + "border-bottom-color", + "border-left-color", + "border-right-color", + "border-top-color", + "box-shadow", + "color", + "outline-color", + "text-decoration-color", + "text-shadow", + ]; + + for (const property of colorProperties) { + const value = computedStyle.getPropertyValue(property); + + if (UNSUPPORTED_COLOR_FUNCTION_PATTERN.test(value)) { + element.style.setProperty(property, getColorFallback(property)); + } + } +}; + +const sanitizeUnsupportedColorStyles = (exportElement: HTMLElement) => { + const elements = [ + exportElement, + ...Array.from(exportElement.querySelectorAll("*")), + ]; + + for (const element of elements) { + sanitizeInlineColorStyles(element); + sanitizeComputedColorStyles(element); + } +}; + +const createPdfExportElement = async ( + sourceElement: HTMLElement, + pdf: PdfDocument, +): Promise => { + const ownerDocument = sourceElement.ownerDocument ?? document; + const wrapper = ownerDocument.createElement("div"); + const element = sourceElement.cloneNode(true) as HTMLElement; + const layout = getPdfExportLayout(pdf); + + element.className = "markdown-body"; + Object.assign(wrapper.style, { + background: "#ffffff", + color: "#111827", + left: "-10000px", + margin: "0", + padding: "0", + pointerEvents: "none", + position: "fixed", + top: "0", + width: `${layout.cssWidth}px`, + zIndex: "-1", + }); + Object.assign(element.style, { + background: "#ffffff", + boxSizing: "border-box", + color: "#111827", + margin: "0", + maxWidth: `${layout.cssWidth}px`, + padding: "0", + width: `${layout.cssWidth}px`, + }); + + wrapper.append(element); + ownerDocument.body.append(wrapper); + sanitizeUnsupportedColorStyles(element); + await prepareImagesForExport(element); + + return { + cleanup: () => { + wrapper.remove(); + }, + element, + layout, + }; +}; + const createCanvasSlice = ( sourceCanvas: HTMLCanvasElement, offsetY: number, @@ -208,18 +433,28 @@ const exportMarkdownPdf = async ({ } const normalizedFilename = withFileExtension(filename, "pdf"); - const { windowHeight, windowWidth } = getCanvasWindowSize(sourceElement); - const canvas = await canvasRenderer(sourceElement, { - backgroundColor: "#ffffff", - scale: PDF_RENDER_SCALE, - useCORS: true, - windowHeight, - windowWidth, - }); const pdf = pdfFactory(); + const exportElement = await createPdfExportElement(sourceElement, pdf); - addCanvasToPdf(canvas, pdf); - pdf.save(normalizedFilename); + try { + const { windowHeight, windowWidth } = getExportCanvasWindowSize( + sourceElement, + exportElement.element, + exportElement.layout, + ); + const canvas = await canvasRenderer(exportElement.element, { + backgroundColor: "#ffffff", + scale: PDF_RENDER_SCALE, + useCORS: true, + windowHeight, + windowWidth, + }); + + addCanvasToPdf(canvas, pdf); + pdf.save(normalizedFilename); + } finally { + exportElement.cleanup(); + } return normalizedFilename; }; From fea709aca3689303ecd9f0b0a669757a21234ec7 Mon Sep 17 00:00:00 2001 From: Kerwin Zheng <1747269691@qq.com> Date: Sat, 2 May 2026 21:28:46 +0800 Subject: [PATCH 04/11] fix(markdown): sanitize html2canvas clone colors - Add an html2canvas onclone hook for markdown PDF export - Force cloned html and body backgrounds to safe RGB-compatible colors before parsing - Re-run export subtree color sanitization inside the cloned document - Cover clone document lab() background regression in markdown export tests Co-authored-by: Codex --- lib/markdown-export.test.ts | 60 +++++++++++++++++++++++++++++++++++++ lib/markdown-export.ts | 21 +++++++++++++ 2 files changed, 81 insertions(+) diff --git a/lib/markdown-export.test.ts b/lib/markdown-export.test.ts index 0348474..a2c7a40 100644 --- a/lib/markdown-export.test.ts +++ b/lib/markdown-export.test.ts @@ -142,6 +142,7 @@ describe("markdown export helpers", () => { expect(filename).toBe("analysis-report.pdf"); expect(canvasRenderer).toHaveBeenCalledWith(expect.any(HTMLElement), { backgroundColor: "#ffffff", + onclone: expect.any(Function), scale: 2, useCORS: true, windowHeight: expect.any(Number), @@ -227,4 +228,63 @@ describe("markdown export helpers", () => { getContextSpy.mockRestore(); toDataURLSpy.mockRestore(); }); + + it("sanitizes the cloned html2canvas document background before rendering", async () => { + const sourceElement = document.createElement("article"); + const canvas = document.createElement("canvas"); + const drawImage = jest.fn(); + const clonedDocument = document.implementation.createHTMLDocument("export"); + const canvasRenderer = jest.fn(async (_element, options) => { + const clonedElement = clonedDocument.createElement("article"); + const heading = clonedDocument.createElement("h1"); + + heading.setAttribute("style", "color: lab(29% 39 20)"); + heading.textContent = "Report"; + clonedElement.append(heading); + clonedDocument.body.append(clonedElement); + clonedDocument.documentElement.style.backgroundColor = "lab(100% 0 0)"; + clonedDocument.body.style.backgroundColor = "lab(100% 0 0)"; + options.onclone?.(clonedDocument, clonedElement); + + expect(clonedDocument.documentElement.style.backgroundColor).toBe( + "rgb(255, 255, 255)", + ); + expect(clonedDocument.body.style.backgroundColor).toBe( + "rgb(255, 255, 255)", + ); + expect(heading.getAttribute("style") ?? "").not.toContain("lab("); + + return canvas; + }); + const pdf: PdfDocument = { + addImage: jest.fn(), + addPage: jest.fn(), + internal: { + pageSize: { + getHeight: () => 842, + getWidth: () => 595, + }, + }, + save: jest.fn(), + }; + const getContextSpy = jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); + const toDataURLSpy = jest + .spyOn(HTMLCanvasElement.prototype, "toDataURL") + .mockReturnValue("data:image/png;base64,markdown"); + + sourceElement.innerHTML = "

Report

"; + canvas.width = 1394; + canvas.height = 1000; + + await exportMarkdownPdf({ + canvasRenderer, + pdfFactory: () => pdf, + sourceElement, + }); + + getContextSpy.mockRestore(); + toDataURLSpy.mockRestore(); + }); }); diff --git a/lib/markdown-export.ts b/lib/markdown-export.ts index 8091a78..7b6bce0 100644 --- a/lib/markdown-export.ts +++ b/lib/markdown-export.ts @@ -21,6 +21,7 @@ type MarkdownFileExportOptions = DownloadBlobOptions & { type CanvasRenderOptions = { backgroundColor: string; + onclone?: (clonedDocument: Document, clonedElement: HTMLElement) => void; scale: number; useCORS: boolean; windowHeight: number; @@ -316,6 +317,25 @@ const sanitizeUnsupportedColorStyles = (exportElement: HTMLElement) => { } }; +const applySafeDocumentColors = (targetDocument: Document) => { + const roots = [targetDocument.documentElement, targetDocument.body].filter( + (root): root is HTMLElement => Boolean(root), + ); + + for (const root of roots) { + root.style.setProperty("background-color", "#ffffff", "important"); + root.style.setProperty("color", "#111827", "important"); + } +}; + +const sanitizeHtml2CanvasClone = ( + clonedDocument: Document, + clonedElement: HTMLElement, +) => { + applySafeDocumentColors(clonedDocument); + sanitizeUnsupportedColorStyles(clonedElement); +}; + const createPdfExportElement = async ( sourceElement: HTMLElement, pdf: PdfDocument, @@ -444,6 +464,7 @@ const exportMarkdownPdf = async ({ ); const canvas = await canvasRenderer(exportElement.element, { backgroundColor: "#ffffff", + onclone: sanitizeHtml2CanvasClone, scale: PDF_RENDER_SCALE, useCORS: true, windowHeight, From 5a01ad969f50386b2b731fba54d994fbcbaae91b Mon Sep 17 00:00:00 2001 From: Kerwin Zheng <1747269691@qq.com> Date: Sun, 3 May 2026 01:43:22 +0800 Subject: [PATCH 05/11] fix(test): stabilize jest markdown plugin mocks for ESM deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map rehype-katex, rehype-raw, remark-gfm, and remark-math to a shared Jest mock so Linux CI does not try to parse ESM-only packages from node_modules - remove per-test inline plugin mocks now that the Jest config owns this compatibility layer - rename __mocks__ files to kebab-case and update moduleNameMapper paths for naming consistency - keep react-markdown test doubles focused on rendered content while centralizing plugin stubbing in one place Verification: - pnpm test ✅ - pnpm build ✅ - pnpm check ⚠️ fails on pre-existing Biome formatting drift in biome.json, app/chat/components/markdown-preview-panel.tsx, lib/markdown-export.ts, and lib/markdown-export.test.ts Co-authored-by: Codex --- __mocks__/markdown-plugin-mock.js | 4 ++++ __mocks__/{styleMock.js => style-mock.js} | 0 .../components/assistant-message-round-artifacts.test.tsx | 5 ----- app/chat/components/markdown-preview-panel.test.tsx | 5 ----- jest.config.ts | 6 +++++- 5 files changed, 9 insertions(+), 11 deletions(-) create mode 100644 __mocks__/markdown-plugin-mock.js rename __mocks__/{styleMock.js => style-mock.js} (100%) diff --git a/__mocks__/markdown-plugin-mock.js b/__mocks__/markdown-plugin-mock.js new file mode 100644 index 0000000..7587e68 --- /dev/null +++ b/__mocks__/markdown-plugin-mock.js @@ -0,0 +1,4 @@ +const markdownPluginMock = () => {}; + +module.exports = markdownPluginMock; +module.exports.default = markdownPluginMock; diff --git a/__mocks__/styleMock.js b/__mocks__/style-mock.js similarity index 100% rename from __mocks__/styleMock.js rename to __mocks__/style-mock.js diff --git a/app/chat/components/assistant-message-round-artifacts.test.tsx b/app/chat/components/assistant-message-round-artifacts.test.tsx index 731507a..082c4f0 100644 --- a/app/chat/components/assistant-message-round-artifacts.test.tsx +++ b/app/chat/components/assistant-message-round-artifacts.test.tsx @@ -10,11 +10,6 @@ jest.mock("react-markdown", () => ({ default: ({ children }: { children?: ReactNode }) => <>{children}, })); -jest.mock("rehype-katex", () => jest.fn()); -jest.mock("rehype-raw", () => jest.fn()); -jest.mock("remark-gfm", () => jest.fn()); -jest.mock("remark-math", () => jest.fn()); - const assistantMessageWithRoundArtifacts = { id: "assistant-round-artifacts", role: "assistant", diff --git a/app/chat/components/markdown-preview-panel.test.tsx b/app/chat/components/markdown-preview-panel.test.tsx index 24aeb9f..f623a4d 100644 --- a/app/chat/components/markdown-preview-panel.test.tsx +++ b/app/chat/components/markdown-preview-panel.test.tsx @@ -19,11 +19,6 @@ jest.mock("react-markdown", () => ({ default: ({ children }: { children: string }) =>
{children}
, })); -jest.mock("rehype-katex", () => jest.fn()); -jest.mock("rehype-raw", () => jest.fn()); -jest.mock("remark-gfm", () => jest.fn()); -jest.mock("remark-math", () => jest.fn()); - jest.mock("./mermaid-chart", () => ({ __esModule: true, default: ({ chart }: { chart: string }) =>
{chart}
, diff --git a/jest.config.ts b/jest.config.ts index 4f9f9c3..c05f416 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -9,8 +9,12 @@ const customJestConfig: Config = { setupFilesAfterEnv: ["/jest.setup.ts"], moduleNameMapper: { "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy", - "^.+\\.(css|sass|scss)$": "/__mocks__/styleMock.js", + "^.+\\.(css|sass|scss)$": "/__mocks__/style-mock.js", "^@/(.*)$": "/$1", + "^rehype-katex$": "/__mocks__/markdown-plugin-mock.js", + "^rehype-raw$": "/__mocks__/markdown-plugin-mock.js", + "^remark-gfm$": "/__mocks__/markdown-plugin-mock.js", + "^remark-math$": "/__mocks__/markdown-plugin-mock.js", }, testEnvironment: "jest-environment-jsdom", testPathIgnorePatterns: ["/tests/e2e/"], From 516d065bfca532f36044c6c89fff38822aaa54d5 Mon Sep 17 00:00:00 2001 From: Kerwin Zheng <1747269691@qq.com> Date: Sun, 3 May 2026 02:02:24 +0800 Subject: [PATCH 06/11] feat(pdf): add server-side markdown export route - add a Node.js route handler for markdown-to-pdf export - add a Chromium-backed PDF service using puppeteer-core and @sparticuz/chromium-min - render markdown, KaTeX, and Mermaid into print HTML before generating the PDF - extend the Jest environment for route testing and document required export env vars Verification: - pnpm test -- lib/markdown-export/pdf-service.test.ts app/api/export/markdown/pdf/route.test.ts - pnpm exec biome check lib/markdown-export/pdf-export-schema.ts lib/markdown-export/pdf-renderer.tsx lib/markdown-export/pdf-service.ts lib/markdown-export/pdf-service.test.ts app/api/export/markdown/pdf/route.ts app/api/export/markdown/pdf/route.test.ts jest.setup.ts Co-authored-by: Codex --- .env.example | 6 +- app/api/export/markdown/pdf/route.test.ts | 42 ++ app/api/export/markdown/pdf/route.ts | 54 +++ jest.setup.ts | 79 +++- lib/markdown-export/pdf-export-schema.ts | 11 + lib/markdown-export/pdf-renderer.tsx | 178 ++++++++ lib/markdown-export/pdf-service.test.ts | 66 +++ lib/markdown-export/pdf-service.ts | 178 ++++++++ package.json | 2 + pnpm-lock.yaml | 495 +++++++++++++++++++++- 10 files changed, 1090 insertions(+), 21 deletions(-) create mode 100644 app/api/export/markdown/pdf/route.test.ts create mode 100644 app/api/export/markdown/pdf/route.ts create mode 100644 lib/markdown-export/pdf-export-schema.ts create mode 100644 lib/markdown-export/pdf-renderer.tsx create mode 100644 lib/markdown-export/pdf-service.test.ts create mode 100644 lib/markdown-export/pdf-service.ts diff --git a/.env.example b/.env.example index 3753740..cd582f4 100644 --- a/.env.example +++ b/.env.example @@ -18,4 +18,8 @@ R2_SECRET_ACCESS_KEY = "your_secret_key" NPM_REGISTRY = "https://registry.npmmirror.com" -DATABASE_URL = "your neon database url" \ No newline at end of file +DATABASE_URL = "your neon database url" + +# Markdown PDF export +CHROMIUM_PACK_URL = "https://your-fast-storage.example.com/chromium-pack.tar" +PUPPETEER_EXECUTABLE_PATH = "C:/path/to/local/chrome.exe" diff --git a/app/api/export/markdown/pdf/route.test.ts b/app/api/export/markdown/pdf/route.test.ts new file mode 100644 index 0000000..eb1d347 --- /dev/null +++ b/app/api/export/markdown/pdf/route.test.ts @@ -0,0 +1,42 @@ +import { POST } from "./route"; + +jest.mock("@/lib/markdown-export/pdf-service", () => ({ + renderMarkdownPdf: jest.fn(async () => ({ + bytes: new Uint8Array([1, 2, 3]), + filename: "analysis-report.pdf", + })), +})); + +describe("POST /api/export/markdown/pdf", () => { + it("returns 400 when markdownContent is missing", async () => { + const response = await POST({ + json: async () => ({}), + } as Request); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + message: "Invalid export request", + success: false, + }), + ); + }); + + it("returns a pdf attachment when the request is valid", async () => { + const response = await POST({ + json: async () => ({ + filename: "analysis-report", + markdownContent: "# Report", + }), + } as Request); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/pdf"); + expect(response.headers.get("Content-Disposition")).toContain( + "analysis-report.pdf", + ); + expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([ + 1, 2, 3, + ]); + }); +}); diff --git a/app/api/export/markdown/pdf/route.ts b/app/api/export/markdown/pdf/route.ts new file mode 100644 index 0000000..78d8d16 --- /dev/null +++ b/app/api/export/markdown/pdf/route.ts @@ -0,0 +1,54 @@ +import { MarkdownPdfExportRequestSchema } from "@/lib/markdown-export/pdf-export-schema"; +import { renderMarkdownPdf } from "@/lib/markdown-export/pdf-service"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +const toArrayBuffer = (bytes: Uint8Array) => { + const body = new ArrayBuffer(bytes.byteLength); + new Uint8Array(body).set(bytes); + return body; +}; + +const POST = async (request: Request) => { + try { + const body = await request.json(); + const parsed = MarkdownPdfExportRequestSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { + code: 400, + success: false, + message: "Invalid export request", + }, + { status: 400 }, + ); + } + + const { bytes, filename } = await renderMarkdownPdf(parsed.data); + + return new NextResponse(toArrayBuffer(bytes), { + headers: { + "Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`, + "Content-Length": String(bytes.byteLength), + "Content-Type": "application/pdf", + }, + status: 200, + }); + } catch (error) { + console.error("[Markdown PDF Export API Error]", error); + return NextResponse.json( + { + code: 500, + success: false, + message: "Failed to generate PDF", + }, + { status: 500 }, + ); + } +}; + +export { POST }; diff --git a/jest.setup.ts b/jest.setup.ts index b6cc22d..dc3feb7 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,4 +1,11 @@ import "@testing-library/jest-dom"; +import { + ReadableStream, + TransformStream, + WritableStream, +} from "node:stream/web"; +import { TextDecoder, TextEncoder } from "node:util"; +import { MessageChannel, MessagePort } from "node:worker_threads"; import type { ReactNode } from "react"; import enMessages from "./messages/en.json"; @@ -43,36 +50,21 @@ jest.mock("next-intl", () => ({ if (typeof globalThis.TransformStream === "undefined") { Object.defineProperty(globalThis, "TransformStream", { - value: class TransformStream { - readable: unknown; - writable: unknown; - constructor() { - this.readable = {}; - this.writable = {}; - } - }, + value: TransformStream, writable: true, }); } if (typeof globalThis.ReadableStream === "undefined") { Object.defineProperty(globalThis, "ReadableStream", { - value: class ReadableStream { - getReader() { - return this; - } - }, + value: ReadableStream, writable: true, }); } if (typeof globalThis.WritableStream === "undefined") { Object.defineProperty(globalThis, "WritableStream", { - value: class WritableStream { - getWriter() { - return this; - } - }, + value: WritableStream, writable: true, }); } @@ -89,3 +81,54 @@ if (typeof globalThis.fetch === "undefined") { writable: true, }); } + +if (typeof globalThis.TextDecoder === "undefined") { + Object.defineProperty(globalThis, "TextDecoder", { + value: TextDecoder, + writable: true, + }); +} + +if (typeof globalThis.TextEncoder === "undefined") { + Object.defineProperty(globalThis, "TextEncoder", { + value: TextEncoder, + writable: true, + }); +} + +if (typeof globalThis.MessageChannel === "undefined") { + Object.defineProperty(globalThis, "MessageChannel", { + value: MessageChannel, + writable: true, + }); +} + +if (typeof globalThis.MessagePort === "undefined") { + Object.defineProperty(globalThis, "MessagePort", { + value: MessagePort, + writable: true, + }); +} + +const { Headers, Request, Response } = require("undici"); + +if (typeof globalThis.Headers === "undefined") { + Object.defineProperty(globalThis, "Headers", { + value: Headers, + writable: true, + }); +} + +if (typeof globalThis.Request === "undefined") { + Object.defineProperty(globalThis, "Request", { + value: Request, + writable: true, + }); +} + +if (typeof globalThis.Response === "undefined") { + Object.defineProperty(globalThis, "Response", { + value: Response, + writable: true, + }); +} diff --git a/lib/markdown-export/pdf-export-schema.ts b/lib/markdown-export/pdf-export-schema.ts new file mode 100644 index 0000000..e3daa9c --- /dev/null +++ b/lib/markdown-export/pdf-export-schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +const MarkdownPdfExportRequestSchema = z.object({ + filename: z.string().trim().min(1).optional(), + markdownContent: z.string().trim().min(1), +}); + +type MarkdownPdfExportRequest = z.infer; + +export { MarkdownPdfExportRequestSchema }; +export type { MarkdownPdfExportRequest }; diff --git a/lib/markdown-export/pdf-renderer.tsx b/lib/markdown-export/pdf-renderer.tsx new file mode 100644 index 0000000..e823e4f --- /dev/null +++ b/lib/markdown-export/pdf-renderer.tsx @@ -0,0 +1,178 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; +import Markdown from "react-markdown"; +import rehypeKatex from "rehype-katex"; +import rehypeRaw from "rehype-raw"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; + +type CreateMarkdownPrintHtmlOptions = { + markdownContent: string; +}; + +type RenderAssets = { + katexCss: string; + markdownCss: string; + mermaidScript: string; +}; + +let cachedAssetsPromise: Promise | null = null; + +const loadRenderAssets = async (): Promise => { + if (!cachedAssetsPromise) { + cachedAssetsPromise = Promise.all([ + readFile( + path.join(process.cwd(), "styles", "markdown-preview.css"), + "utf8", + ), + readFile( + path.join( + process.cwd(), + "node_modules", + "katex", + "dist", + "katex.min.css", + ), + "utf8", + ), + readFile( + path.join( + process.cwd(), + "node_modules", + "mermaid", + "dist", + "mermaid.min.js", + ), + "utf8", + ), + ]).then(([markdownCss, katexCss, mermaidScript]) => ({ + katexCss, + markdownCss, + mermaidScript, + })); + } + + return cachedAssetsPromise; +}; + +const createMarkdownMarkup = (markdownContent: string) => + renderToStaticMarkup( + + {markdownContent} + , + ); + +const createMermaidBootScript = () => ` +window.__MERMAID_READY__ = (async () => { + if (typeof mermaid === "undefined") { + return; + } + + mermaid.initialize({ + startOnLoad: false, + theme: "default", + securityLevel: "loose", + }); + + const blocks = Array.from( + document.querySelectorAll("pre code.language-mermaid"), + ); + + for (const block of blocks) { + const pre = block.closest("pre"); + const source = block.textContent ?? ""; + + if (!pre || !source.trim()) { + continue; + } + + try { + const id = \`mermaid-\${Math.random().toString(36).slice(2, 9)}\`; + const { svg } = await mermaid.render(id, source); + const wrapper = document.createElement("div"); + wrapper.className = "mermaid-diagram"; + wrapper.innerHTML = svg; + pre.replaceWith(wrapper); + } catch (error) { + console.error("[PDF export mermaid render error]", error); + } + } +})(); +`; + +const escapeInlineScript = (script: string) => + script.replaceAll(" => { + const { katexCss, markdownCss, mermaidScript } = await loadRenderAssets(); + const markdownMarkup = createMarkdownMarkup(markdownContent); + + return ` + + + + + Markdown Export + + + +
${markdownMarkup}
+ + + +`; +}; + +export { createMarkdownPrintHtml }; +export type { CreateMarkdownPrintHtmlOptions }; diff --git a/lib/markdown-export/pdf-service.test.ts b/lib/markdown-export/pdf-service.test.ts new file mode 100644 index 0000000..351e344 --- /dev/null +++ b/lib/markdown-export/pdf-service.test.ts @@ -0,0 +1,66 @@ +jest.mock("./pdf-renderer", () => ({ + createMarkdownPrintHtml: jest.fn(async () => ""), +})); + +import { renderMarkdownPdf } from "./pdf-service"; + +describe("renderMarkdownPdf", () => { + it("renders markdown HTML through a browser page and returns pdf bytes", async () => { + const setContent = jest.fn(); + const emulateMediaType = jest.fn(); + const evaluate = jest.fn(async () => undefined); + const waitForFunction = jest.fn(); + const pdf = jest.fn(async () => new Uint8Array([1, 2, 3])); + const closePage = jest.fn(); + const newPage = jest.fn(async () => ({ + close: closePage, + emulateMediaType, + evaluate, + pdf, + setContent, + waitForFunction, + })); + const closeBrowser = jest.fn(); + const launchBrowser = jest.fn(async () => ({ + close: closeBrowser, + newPage, + })); + const createHtml = jest.fn( + () => "

Report

", + ); + + const result = await renderMarkdownPdf( + { + filename: "analysis-report", + markdownContent: "# Report", + }, + { + createHtml, + launchBrowser, + }, + ); + + expect(createHtml).toHaveBeenCalledWith({ + markdownContent: "# Report", + }); + expect(setContent).toHaveBeenCalledWith( + "

Report

", + expect.objectContaining({ + waitUntil: "networkidle0", + }), + ); + expect(emulateMediaType).toHaveBeenCalledWith("screen"); + expect(waitForFunction).toHaveBeenCalled(); + expect(evaluate).toHaveBeenCalled(); + expect(pdf).toHaveBeenCalledWith( + expect.objectContaining({ + format: "A4", + printBackground: true, + }), + ); + expect(result.filename).toBe("analysis-report.pdf"); + expect(Array.from(result.bytes)).toEqual([1, 2, 3]); + expect(closePage).toHaveBeenCalled(); + expect(closeBrowser).toHaveBeenCalled(); + }); +}); diff --git a/lib/markdown-export/pdf-service.ts b/lib/markdown-export/pdf-service.ts new file mode 100644 index 0000000..0af6234 --- /dev/null +++ b/lib/markdown-export/pdf-service.ts @@ -0,0 +1,178 @@ +import chromium from "@sparticuz/chromium-min"; +import puppeteer from "puppeteer-core"; +import { createMarkdownPrintHtml } from "./pdf-renderer"; + +type RenderMarkdownPdfOptions = { + filename?: string; + markdownContent: string; +}; + +type RenderMarkdownPdfResult = { + bytes: Uint8Array; + filename: string; +}; + +type PdfPage = { + close: () => Promise; + emulateMediaType: (type?: "screen" | "print" | null) => Promise; + evaluate: (pageFunction: () => Promise) => Promise; + pdf: (options: Record) => Promise; + setContent: ( + html: string, + options?: { waitUntil?: "domcontentloaded" | "load" | "networkidle0" }, + ) => Promise; + waitForFunction: ( + pageFunction: () => unknown, + options?: { timeout?: number }, + ) => Promise; +}; + +type PdfBrowser = { + close: () => Promise; + newPage: () => Promise; +}; + +type RenderMarkdownPdfDeps = { + createHtml?: typeof createMarkdownPrintHtml; + launchBrowser?: () => Promise; +}; + +const MARKDOWN_EXPORT_BASENAME = "refract-markdown"; + +const withPdfExtension = (filename?: string) => { + const cleanFilename = filename?.trim() || MARKDOWN_EXPORT_BASENAME; + const filenameWithoutExtension = cleanFilename.replace(/\.[^/.]+$/, ""); + + return `${filenameWithoutExtension}.pdf`; +}; + +const resolveExecutablePath = async () => { + if (process.env.PUPPETEER_EXECUTABLE_PATH) { + return { + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, + isLocalExecutable: true, + }; + } + + if (process.env.CHROMIUM_PACK_URL) { + return { + executablePath: await chromium.executablePath( + process.env.CHROMIUM_PACK_URL, + ), + isLocalExecutable: false, + }; + } + + throw new Error( + "PDF export requires PUPPETEER_EXECUTABLE_PATH locally or CHROMIUM_PACK_URL on deployed runtimes.", + ); +}; + +const launchMarkdownPdfBrowser = async (): Promise => { + const { executablePath, isLocalExecutable } = await resolveExecutablePath(); + + if (isLocalExecutable) { + return puppeteer.launch({ + args: puppeteer.defaultArgs(), + executablePath, + headless: true, + }) as Promise; + } + + return puppeteer.launch({ + args: puppeteer.defaultArgs({ + args: chromium.args, + headless: "shell", + }), + executablePath, + headless: "shell", + }) as Promise; +}; + +const waitForRenderedContent = async (page: PdfPage) => { + await page.waitForFunction( + () => + ( + window as Window & { + __MERMAID_READY__?: Promise; + } + ).__MERMAID_READY__ !== undefined, + { + timeout: 10_000, + }, + ); + + await page.evaluate(async () => { + if ("fonts" in document) { + await document.fonts.ready; + } + + const images = Array.from(document.images).filter( + (image) => !image.complete, + ); + await Promise.all( + images.map( + (image) => + new Promise((resolve) => { + image.addEventListener("load", () => resolve(), { once: true }); + image.addEventListener("error", () => resolve(), { once: true }); + }), + ), + ); + + const mermaidReady = ( + window as Window & { + __MERMAID_READY__?: Promise; + } + ).__MERMAID_READY__; + + if (mermaidReady) { + await mermaidReady; + } + }); +}; + +const renderMarkdownPdf = async ( + { filename, markdownContent }: RenderMarkdownPdfOptions, + { + createHtml = createMarkdownPrintHtml, + launchBrowser = launchMarkdownPdfBrowser, + }: RenderMarkdownPdfDeps = {}, +): Promise => { + const html = await createHtml({ markdownContent }); + const browser = await launchBrowser(); + const page = await browser.newPage(); + + try { + await page.setContent(html, { waitUntil: "networkidle0" }); + await page.emulateMediaType("screen"); + await waitForRenderedContent(page); + + const bytes = await page.pdf({ + format: "A4", + margin: { + bottom: "18mm", + left: "16mm", + right: "16mm", + top: "18mm", + }, + printBackground: true, + }); + + return { + bytes, + filename: withPdfExtension(filename), + }; + } finally { + await page.close(); + await browser.close(); + } +}; + +export { renderMarkdownPdf }; +export type { + PdfBrowser, + PdfPage, + RenderMarkdownPdfOptions, + RenderMarkdownPdfResult, +}; diff --git a/package.json b/package.json index e89309c..de38f77 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", + "@sparticuz/chromium-min": "^148.0.0", "@tanstack/react-query": "^5.96.2", "@tanstack/react-virtual": "^3.13.23", "ai": "^6.0.146", @@ -60,6 +61,7 @@ "next": "16.1.1", "next-intl": "^4.9.0", "next-themes": "^0.4.6", + "puppeteer-core": "^24.42.0", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a2ed36..adee6a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@sparticuz/chromium-min': + specifier: ^148.0.0 + version: 148.0.0 '@tanstack/react-query': specifier: ^5.96.2 version: 5.96.2(react@19.2.3) @@ -125,6 +128,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + puppeteer-core: + specifier: ^24.42.0 + version: 24.42.0 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2322,6 +2328,11 @@ packages: peerDependencies: '@opentelemetry/api': ^1.8 + '@puppeteer/browsers@2.13.0': + resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -3524,6 +3535,10 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} + '@sparticuz/chromium-min@148.0.0': + resolution: {integrity: sha512-3RW2gPJrOIutXdTtwLDuIFOZwKKq4YU2zwt73U75QmgYaDVSYfSC1xLksou71sAy1B/MgZhLSmU+wCZDLW4MFg==} + engines: {node: '>=22.17.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3782,6 +3797,9 @@ packages: '@types/react-dom': optional: true + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@triplit/client@1.0.50': resolution: {integrity: sha512-3vjXTSdDQ3fzLDrewCK7elkAQc7CiDg0eZEOZInQbVMFRiakdieO5C2voSnNjSepIYHxDxFSBllgg32QsNpL9Q==} engines: {node: '>=18.0.0'} @@ -4047,6 +4065,9 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -4284,6 +4305,10 @@ packages: resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} engines: {node: '>=12.0.0'} + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -4301,6 +4326,14 @@ packages: axios@1.13.6: resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-jest@30.2.0: resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4336,6 +4369,47 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.2: + resolution: {integrity: sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==} + base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} @@ -4344,6 +4418,10 @@ packages: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true + basic-ftp@5.3.1: + resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} + engines: {node: '>=10.0.0'} + better-auth@1.4.6: resolution: {integrity: sha512-5wEBzjolrQA26b4uT6FVVYICsE3SmE/MzrZtl8cb2a3TJtswpP8v3OVV5yTso+ef9z85swgZk0/qBzcULFWVtA==} peerDependencies: @@ -4420,6 +4498,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -4502,6 +4583,11 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + chromium-bidi@14.0.0: + resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==} + peerDependencies: + devtools-protocol: '*' + ci-info@4.3.1: resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} engines: {node: '>=8'} @@ -4855,6 +4941,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -4904,6 +4994,10 @@ packages: defu@6.1.6: resolution: {integrity: sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} @@ -4936,6 +5030,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + devtools-protocol@0.0.1595872: + resolution: {integrity: sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==} + dexie@4.4.2: resolution: {integrity: sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==} @@ -5128,6 +5225,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -5197,14 +5297,27 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -5212,6 +5325,9 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -5253,6 +5369,11 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + fast-content-type-parse@1.1.0: resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} @@ -5262,6 +5383,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -5308,6 +5432,9 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -5451,6 +5578,10 @@ packages: resolution: {integrity: sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==} engines: {node: '>=10'} + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -5462,6 +5593,10 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5672,6 +5807,10 @@ packages: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -6197,6 +6336,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + lucide-react@0.555.0: resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==} peerDependencies: @@ -6452,6 +6595,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -6523,6 +6669,10 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + next-intl-swc-plugin-extractor@4.9.0: resolution: {integrity: sha512-CAu6Qy6XiCenKsvzyCPm2cZFkGfcvhJi8N93TCnOowmzD4Br3ked7QdROusRRp4MQ1iG9u+KCLgVcM9CLDUOIQ==} @@ -6674,6 +6824,14 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -6757,6 +6915,9 @@ packages: peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -6927,6 +7088,10 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prom-client@14.2.0: resolution: {integrity: sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==} engines: {node: '>=10'} @@ -6945,13 +7110,24 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + puppeteer-core@24.42.0: + resolution: {integrity: sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg==} + engines: {node: '>=18'} + pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} @@ -7247,6 +7423,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -7314,6 +7495,18 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.8: + resolution: {integrity: sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -7374,6 +7567,9 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -7514,6 +7710,12 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + tar@7.5.1: resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} engines: {node: '>=18'} @@ -7525,10 +7727,16 @@ packages: tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + text-segmentation@1.0.3: resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} @@ -7652,6 +7860,9 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + typed-query-selector@2.12.2: + resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -7835,6 +8046,9 @@ packages: web-worker@1.5.0: resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + webdriver-bidi-protocol@0.4.1: + resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -7948,6 +8162,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -10408,6 +10625,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@puppeteer/browsers@2.13.0': + dependencies: + debug: 4.4.3 + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.5.0 + semver: 7.7.4 + tar-fs: 3.1.2 + yargs: 17.7.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -11777,6 +12009,14 @@ snapshots: dependencies: tslib: 2.8.1 + '@sparticuz/chromium-min@148.0.0': + dependencies: + tar-fs: 3.1.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -12009,6 +12249,8 @@ snapshots: '@types/react': 19.2.0 '@types/react-dom': 19.2.0(@types/react@19.2.0) + '@tootallnate/quickjs-emscripten@0.23.0': {} + '@triplit/client@1.0.50(typescript@5.9.3)': dependencies: '@triplit/db': 1.1.10(typescript@5.9.3) @@ -12322,6 +12564,11 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 20.19.19 + optional: true + '@ungap/structured-clone@1.3.0': {} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -12493,6 +12740,10 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -12514,6 +12765,8 @@ snapshots: transitivePeerDependencies: - debug + b4a@1.8.1: {} + babel-jest@30.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -12572,10 +12825,44 @@ snapshots: balanced-match@4.0.4: {} + bare-events@2.8.2: {} + + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.13.1(bare-events@2.8.2) + bare-url: 2.4.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.2: + dependencies: + bare-path: 3.0.0 + base64-arraybuffer@1.0.2: {} baseline-browser-mapping@2.9.11: {} + basic-ftp@5.3.1: {} + better-auth@1.4.6(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0) @@ -12654,6 +12941,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} bundle-name@4.1.0: @@ -12731,6 +13020,12 @@ snapshots: chownr@3.0.0: {} + chromium-bidi@14.0.0(devtools-protocol@0.0.1595872): + dependencies: + devtools-protocol: 0.0.1595872 + mitt: 3.0.1 + zod: 3.25.76 + ci-info@4.3.1: {} cjs-module-lexer@1.4.3: {} @@ -13071,6 +13366,8 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -13103,6 +13400,12 @@ snapshots: defu@6.1.6: {} + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + delaunator@5.1.0: dependencies: robust-predicates: 3.0.3 @@ -13125,6 +13428,8 @@ snapshots: dependencies: dequal: 2.0.3 + devtools-protocol@0.0.1595872: {} + dexie@4.4.2: {} diff@4.0.4: {} @@ -13238,6 +13543,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -13361,14 +13670,32 @@ snapshots: escape-string-regexp@5.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + esprima@4.0.1: {} + estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} + esutils@2.0.3: {} + etag@1.8.1: {} eventemitter3@5.0.1: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -13457,12 +13784,24 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + fast-content-type-parse@1.1.0: {} fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -13542,6 +13881,10 @@ snapshots: dependencies: bser: 2.1.1 + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -13668,6 +14011,10 @@ snapshots: get-stdin@8.0.0: {} + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + get-stream@6.0.1: {} get-stream@9.0.1: @@ -13679,6 +14026,14 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-uri@6.0.5: + dependencies: + basic-ftp: 5.3.1 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -13965,6 +14320,8 @@ snapshots: ip-address@10.0.1: {} + ip-address@10.2.0: {} + ipaddr.js@1.9.1: {} is-alphabetical@2.0.1: {} @@ -14050,7 +14407,7 @@ snapshots: '@babel/parser': 7.28.5 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 - semver: 7.7.2 + semver: 7.7.3 transitivePeerDependencies: - supports-color @@ -14639,6 +14996,8 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: {} + lucide-react@0.555.0(react@19.2.3): dependencies: react: 19.2.3 @@ -14655,7 +15014,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.7.3 make-error@1.3.6: {} @@ -15114,6 +15473,8 @@ snapshots: dependencies: minipass: 7.1.2 + mitt@3.0.1: {} + mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -15183,6 +15544,8 @@ snapshots: negotiator@1.0.0: {} + netmask@2.1.1: {} + next-intl-swc-plugin-extractor@4.9.0: {} next-intl@4.9.0(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3): @@ -15339,6 +15702,24 @@ snapshots: p-try@2.2.0: {} + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.1.1 + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -15413,6 +15794,8 @@ snapshots: peberminta@0.9.0: {} + pend@1.2.0: {} + performance-now@2.1.0: optional: true @@ -15577,6 +15960,8 @@ snapshots: process-warning@5.0.0: {} + progress@2.0.3: {} + prom-client@14.2.0: dependencies: tdigest: 0.1.2 @@ -15599,10 +15984,45 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + proxy-from-env@1.1.0: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} + puppeteer-core@24.42.0: + dependencies: + '@puppeteer/browsers': 2.13.0 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1595872) + debug: 4.4.3 + devtools-protocol: 0.0.1595872 + typed-query-selector: 2.12.2 + webdriver-bidi-protocol: 0.4.1 + ws: 8.20.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + pure-rand@7.0.1: {} pvtsutils@1.3.6: @@ -15972,6 +16392,8 @@ snapshots: semver@7.7.3: {} + semver@7.7.4: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -16126,6 +16548,21 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.8 + transitivePeerDependencies: + - supports-color + + socks@2.8.8: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -16178,6 +16615,15 @@ snapshots: stdin-discarder@0.2.2: {} + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + strict-event-emitter@0.5.1: {} string-argv@0.3.2: {} @@ -16301,6 +16747,29 @@ snapshots: tapable@2.3.0: {} + tar-fs@3.1.2: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.1 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.1 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + tar@7.5.1: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -16321,12 +16790,25 @@ snapshots: dependencies: bintrees: 1.0.2 + teex@1.0.1: + dependencies: + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.3 glob: 7.2.3 minimatch: 3.1.2 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + text-segmentation@1.0.3: dependencies: utrie: 1.0.2 @@ -16443,6 +16925,8 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typed-query-selector@2.12.2: {} + typescript@5.9.3: {} ua-is-frozen@0.1.2: {} @@ -16650,6 +17134,8 @@ snapshots: web-worker@1.5.0: {} + webdriver-bidi-protocol@0.4.1: {} + webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: @@ -16749,6 +17235,11 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yn@3.1.1: {} yocto-queue@0.1.0: {} From 55d7c1d2a65a768b954c79cf4c09f629c2f2c0d9 Mon Sep 17 00:00:00 2001 From: Kerwin Zheng <1747269691@qq.com> Date: Sun, 3 May 2026 02:06:07 +0800 Subject: [PATCH 07/11] refactor(pdf): switch markdown preview export to server route - replace the client pdf helper with a POST-based markdown export flow - update the markdown preview panel to send source markdown instead of a live DOM node - rewrite markdown export tests around API download behavior rather than html2canvas rendering Verification: - pnpm test -- lib/markdown-export.test.ts app/chat/components/markdown-preview-panel.test.tsx - pnpm exec biome check --write lib/markdown-export.ts lib/markdown-export.test.ts app/chat/components/markdown-preview-panel.tsx app/chat/components/markdown-preview-panel.test.tsx Co-authored-by: Codex --- .../markdown-preview-panel.test.tsx | 2 +- .../components/markdown-preview-panel.tsx | 14 +- lib/markdown-export.test.ts | 314 ++++-------- lib/markdown-export.ts | 450 ++---------------- 4 files changed, 128 insertions(+), 652 deletions(-) diff --git a/app/chat/components/markdown-preview-panel.test.tsx b/app/chat/components/markdown-preview-panel.test.tsx index f623a4d..225569a 100644 --- a/app/chat/components/markdown-preview-panel.test.tsx +++ b/app/chat/components/markdown-preview-panel.test.tsx @@ -68,7 +68,7 @@ describe("MarkdownPreview", () => { await waitFor(() => expect(exportMarkdownPdfMock).toHaveBeenCalledWith({ - sourceElement: expect.any(HTMLElement), + markdownContent: "# Report\n\nHello export", }), ); }); diff --git a/app/chat/components/markdown-preview-panel.tsx b/app/chat/components/markdown-preview-panel.tsx index d9b5a8e..f2b6df0 100644 --- a/app/chat/components/markdown-preview-panel.tsx +++ b/app/chat/components/markdown-preview-panel.tsx @@ -15,7 +15,7 @@ import { exportMarkdownFile, exportMarkdownPdf } from "@/lib/markdown-export"; import { useAtomValue } from "jotai"; import { ChevronDown, Download, FileText } from "lucide-react"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; -import { memo, useCallback, useRef, useState } from "react"; +import { memo, useCallback, useState } from "react"; import Markdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; @@ -59,7 +59,6 @@ const MarkdownPreBlock = ({ const MarkdownPreview = () => { const markdownContent = useAtomValue(workspaceMarkdownContentAtom); - const previewRef = useRef(null); const [isExportingPdf, setIsExportingPdf] = useState(false); const hasMarkdownContent = markdownContent.trim().length > 0; @@ -68,7 +67,7 @@ const MarkdownPreview = () => { }, [markdownContent]); const handleExportPdf = useCallback(async () => { - if (!previewRef.current || isExportingPdf) { + if (isExportingPdf || !hasMarkdownContent) { return; } @@ -76,12 +75,12 @@ const MarkdownPreview = () => { try { await exportMarkdownPdf({ - sourceElement: previewRef.current, + markdownContent, }); } finally { setIsExportingPdf(false); } - }, [isExportingPdf]); + }, [hasMarkdownContent, isExportingPdf, markdownContent]); return (
@@ -116,10 +115,7 @@ const MarkdownPreview = () => {
-
+
jest.fn()); -jest.mock("jspdf", () => ({ - jsPDF: jest.fn(() => ({ - addImage: jest.fn(), - addPage: jest.fn(), - internal: { - pageSize: { - getHeight: () => 842, - getWidth: () => 595, - }, - }, - save: jest.fn(), - })), -})); - import { - type PdfDocument, - addCanvasToPdf, createMarkdownBlob, + exportMarkdownFile, exportMarkdownPdf, withFileExtension, } from "./markdown-export"; @@ -54,237 +38,101 @@ describe("markdown export helpers", () => { await expect(readBlobText(blob)).resolves.toBe("# Report\n\nHello"); }); - it("adds a rendered markdown canvas to a pdf page", () => { - const canvas = document.createElement("canvas"); - const drawImage = jest.fn(); - const pdf: PdfDocument = { - addImage: jest.fn(), - addPage: jest.fn(), - internal: { - pageSize: { - getHeight: () => 172, - getWidth: () => 172, - }, + it("downloads the raw markdown file", () => { + const createObjectURL = jest.fn(() => "blob:markdown"); + const revokeObjectURL = jest.fn(); + const originalCreateElement = document.createElement.bind(document); + const anchor = document.createElement("a"); + const clickSpy = jest.spyOn(anchor, "click").mockImplementation(() => {}); + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation(((tagName: string) => + tagName === "a" + ? anchor + : originalCreateElement(tagName)) as typeof document.createElement); + + const filename = exportMarkdownFile("# Report", { + document, + filename: "analysis-report", + url: { + createObjectURL, + revokeObjectURL, }, - save: jest.fn(), - }; - const getContextSpy = jest - .spyOn(HTMLCanvasElement.prototype, "getContext") - .mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); - const toDataURLSpy = jest - .spyOn(HTMLCanvasElement.prototype, "toDataURL") - .mockReturnValue("data:image/png;base64,page"); - - canvas.width = 100; - canvas.height = 300; - - addCanvasToPdf(canvas, pdf); + }); - expect(pdf.addImage).toHaveBeenCalledTimes(3); - expect(pdf.addPage).toHaveBeenCalledTimes(2); - expect(pdf.addImage).toHaveBeenNthCalledWith( - 1, - "data:image/png;base64,page", - "PNG", - 36, - 36, - 100, - 100, - ); + expect(filename).toBe("analysis-report.md"); + expect(createObjectURL).toHaveBeenCalled(); + expect(anchor.download).toBe("analysis-report.md"); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:markdown"); - getContextSpy.mockRestore(); - toDataURLSpy.mockRestore(); + createElementSpy.mockRestore(); + clickSpy.mockRestore(); }); - it("renders the styled markdown element to canvas and saves the pdf directly", async () => { - const sourceElement = document.createElement("article"); - const canvas = document.createElement("canvas"); - const drawImage = jest.fn(); - const canvasRenderer = jest.fn(async () => canvas); - const pdf: PdfDocument = { - addImage: jest.fn(), - addPage: jest.fn(), - internal: { - pageSize: { - getHeight: () => 842, - getWidth: () => 595, - }, + it("posts markdown to the export route and downloads the returned pdf", async () => { + const createObjectURL = jest.fn(() => "blob:pdf"); + const revokeObjectURL = jest.fn(); + const originalCreateElement = document.createElement.bind(document); + const anchor = document.createElement("a"); + const clickSpy = jest.spyOn(anchor, "click").mockImplementation(() => {}); + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation(((tagName: string) => + tagName === "a" + ? anchor + : originalCreateElement(tagName)) as typeof document.createElement); + const fetcher = jest.fn(async () => ({ + blob: async () => new Blob(["pdf"], { type: "application/pdf" }), + headers: { + get: () => null, }, - save: jest.fn(), - }; - const openSpy = jest.spyOn(window, "open").mockReturnValue(null); - const getContextSpy = jest - .spyOn(HTMLCanvasElement.prototype, "getContext") - .mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); - const toDataURLSpy = jest - .spyOn(HTMLCanvasElement.prototype, "toDataURL") - .mockReturnValue("data:image/png;base64,markdown"); - - sourceElement.innerHTML = "

Report

Ready

"; - Object.defineProperty(sourceElement, "scrollHeight", { - configurable: true, - value: 720, - }); - Object.defineProperty(sourceElement, "scrollWidth", { - configurable: true, - value: 560, - }); - canvas.width = 560; - canvas.height = 720; + ok: true, + })); const filename = await exportMarkdownPdf({ - canvasRenderer, + document, + fetcher, filename: "analysis-report", - pdfFactory: () => pdf, - sourceElement, - }); - - expect(filename).toBe("analysis-report.pdf"); - expect(canvasRenderer).toHaveBeenCalledWith(expect.any(HTMLElement), { - backgroundColor: "#ffffff", - onclone: expect.any(Function), - scale: 2, - useCORS: true, - windowHeight: expect.any(Number), - windowWidth: 698, - }); - expect(canvasRenderer.mock.calls[0]?.[0]).not.toBe(sourceElement); - expect(canvasRenderer.mock.calls[0]?.[0].style.width).toBe( - "697.3333333333333px", - ); - expect(openSpy).not.toHaveBeenCalled(); - expect(pdf.addImage).toHaveBeenCalledWith( - "data:image/png;base64,markdown", - "PNG", - 36, - 36, - 523, - expect.any(Number), - ); - expect(pdf.save).toHaveBeenCalledWith("analysis-report.pdf"); - - openSpy.mockRestore(); - getContextSpy.mockRestore(); - toDataURLSpy.mockRestore(); - }); - - it("prepares a pdf-only A4 clone with embedded URL images and safe colors", async () => { - const sourceElement = document.createElement("article"); - const canvas = document.createElement("canvas"); - const drawImage = jest.fn(); - const canvasRenderer = jest.fn(async () => canvas); - const pdf: PdfDocument = { - addImage: jest.fn(), - addPage: jest.fn(), - internal: { - pageSize: { - getHeight: () => 842, - getWidth: () => 595, - }, + markdownContent: "# Report", + url: { + createObjectURL, + revokeObjectURL, }, - save: jest.fn(), - }; - const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue({ - blob: async () => new Blob(["image"], { type: "image/png" }), - ok: true, - } as Response); - const getContextSpy = jest - .spyOn(HTMLCanvasElement.prototype, "getContext") - .mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); - const toDataURLSpy = jest - .spyOn(HTMLCanvasElement.prototype, "toDataURL") - .mockReturnValue("data:image/png;base64,markdown"); - - sourceElement.className = "prose prose-sm dark:prose-invert markdown-body"; - sourceElement.innerHTML = ` -

Report

- Chart - `; - canvas.width = 1394; - canvas.height = 1000; - - await exportMarkdownPdf({ - canvasRenderer, - pdfFactory: () => pdf, - sourceElement, }); - const exportElement = canvasRenderer.mock.calls[0]?.[0]; - const exportImage = exportElement?.querySelector("img"); - const exportHeading = exportElement?.querySelector("h1"); - - expect(fetchSpy).toHaveBeenCalledWith( - "https://assets.example.test/chart.png", - { credentials: "omit", mode: "cors" }, - ); - expect(exportElement?.className).toBe("markdown-body"); - expect(exportImage?.getAttribute("src")).toMatch( - /^data:image\/png;base64,/, + expect(fetcher).toHaveBeenCalledWith( + "/api/export/markdown/pdf", + expect.objectContaining({ + body: JSON.stringify({ + filename: "analysis-report", + markdownContent: "# Report", + }), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }), ); - expect(exportImage?.getAttribute("srcset")).toBeNull(); - expect(exportHeading?.getAttribute("style") ?? "").not.toContain("lab("); + expect(filename).toBe("analysis-report.pdf"); + expect(anchor.download).toBe("analysis-report.pdf"); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:pdf"); - fetchSpy.mockRestore(); - getContextSpy.mockRestore(); - toDataURLSpy.mockRestore(); + createElementSpy.mockRestore(); + clickSpy.mockRestore(); }); - it("sanitizes the cloned html2canvas document background before rendering", async () => { - const sourceElement = document.createElement("article"); - const canvas = document.createElement("canvas"); - const drawImage = jest.fn(); - const clonedDocument = document.implementation.createHTMLDocument("export"); - const canvasRenderer = jest.fn(async (_element, options) => { - const clonedElement = clonedDocument.createElement("article"); - const heading = clonedDocument.createElement("h1"); - - heading.setAttribute("style", "color: lab(29% 39 20)"); - heading.textContent = "Report"; - clonedElement.append(heading); - clonedDocument.body.append(clonedElement); - clonedDocument.documentElement.style.backgroundColor = "lab(100% 0 0)"; - clonedDocument.body.style.backgroundColor = "lab(100% 0 0)"; - options.onclone?.(clonedDocument, clonedElement); - - expect(clonedDocument.documentElement.style.backgroundColor).toBe( - "rgb(255, 255, 255)", - ); - expect(clonedDocument.body.style.backgroundColor).toBe( - "rgb(255, 255, 255)", - ); - expect(heading.getAttribute("style") ?? "").not.toContain("lab("); - - return canvas; - }); - const pdf: PdfDocument = { - addImage: jest.fn(), - addPage: jest.fn(), - internal: { - pageSize: { - getHeight: () => 842, - getWidth: () => 595, - }, - }, - save: jest.fn(), - }; - const getContextSpy = jest - .spyOn(HTMLCanvasElement.prototype, "getContext") - .mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); - const toDataURLSpy = jest - .spyOn(HTMLCanvasElement.prototype, "toDataURL") - .mockReturnValue("data:image/png;base64,markdown"); - - sourceElement.innerHTML = "

Report

"; - canvas.width = 1394; - canvas.height = 1000; - - await exportMarkdownPdf({ - canvasRenderer, - pdfFactory: () => pdf, - sourceElement, - }); - - getContextSpy.mockRestore(); - toDataURLSpy.mockRestore(); + it("throws when the pdf export route fails", async () => { + const fetcher = jest.fn(async () => ({ + ok: false, + })); + + await expect( + exportMarkdownPdf({ + fetcher, + markdownContent: "# Report", + }), + ).rejects.toThrow("Failed to export markdown as PDF."); }); }); diff --git a/lib/markdown-export.ts b/lib/markdown-export.ts index 7b6bce0..4ba8e8c 100644 --- a/lib/markdown-export.ts +++ b/lib/markdown-export.ts @@ -1,12 +1,5 @@ -import html2canvas from "html2canvas"; -import { jsPDF } from "jspdf"; - const MARKDOWN_EXPORT_BASENAME = "refract-markdown"; const MARKDOWN_MIME_TYPE = "text/markdown;charset=utf-8"; -const PDF_PAGE_MARGIN = 36; -const PDF_RENDER_SCALE = 2; -const CSS_PIXELS_PER_POINT = 96 / 72; -const UNSUPPORTED_COLOR_FUNCTION_PATTERN = /\b(?:lab|lch|oklab|oklch|color)\(/i; type FileExtension = "md" | "pdf"; @@ -19,56 +12,10 @@ type MarkdownFileExportOptions = DownloadBlobOptions & { filename?: string; }; -type CanvasRenderOptions = { - backgroundColor: string; - onclone?: (clonedDocument: Document, clonedElement: HTMLElement) => void; - scale: number; - useCORS: boolean; - windowHeight: number; - windowWidth: number; -}; - -type CanvasRenderer = ( - sourceElement: HTMLElement, - options: CanvasRenderOptions, -) => Promise; - -type PdfDocument = { - addImage: ( - imageData: string, - format: "PNG", - x: number, - y: number, - width: number, - height: number, - ) => void; - addPage: () => void; - internal: { - pageSize: { - getHeight: () => number; - getWidth: () => number; - }; - }; - save: (filename: string) => void; -}; - -type MarkdownPdfExportOptions = { - canvasRenderer?: CanvasRenderer; +type MarkdownPdfExportOptions = DownloadBlobOptions & { + fetcher?: typeof fetch; filename?: string; - pdfFactory?: () => PdfDocument; - sourceElement: HTMLElement; -}; - -type PdfExportLayout = { - cssPageHeight: number; - cssWidth: number; - windowWidth: number; -}; - -type PreparedExportElement = { - cleanup: () => void; - element: HTMLElement; - layout: PdfExportLayout; + markdownContent: string; }; const withFileExtension = (filename: string, extension: FileExtension) => { @@ -117,379 +64,64 @@ const exportMarkdownFile = ( return filename; }; -const createPdfDocument = (): PdfDocument => - new jsPDF({ - format: "a4", - orientation: "portrait", - unit: "pt", - }) as PdfDocument; - -const renderElementToCanvas: CanvasRenderer = (sourceElement, options) => - html2canvas(sourceElement, options); - -const hasExportableContent = (sourceElement: HTMLElement) => { - const visibleText = - "innerText" in sourceElement && typeof sourceElement.innerText === "string" - ? sourceElement.innerText - : (sourceElement.textContent ?? ""); - - return Boolean(visibleText.trim() || sourceElement.innerHTML.trim()); -}; - -const getCanvasWindowSize = (sourceElement: HTMLElement) => { - const windowWidth = - sourceElement.scrollWidth || - sourceElement.clientWidth || - sourceElement.offsetWidth; - const windowHeight = - sourceElement.scrollHeight || - sourceElement.clientHeight || - sourceElement.offsetHeight; - - return { - windowHeight, - windowWidth, - }; -}; - -const getPdfExportLayout = (pdf: PdfDocument): PdfExportLayout => { - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); - const printableWidth = pageWidth - PDF_PAGE_MARGIN * 2; - const cssWidth = printableWidth * CSS_PIXELS_PER_POINT; - - return { - cssPageHeight: pageHeight * CSS_PIXELS_PER_POINT, - cssWidth, - windowWidth: Math.ceil(cssWidth), - }; -}; - -const getExportCanvasWindowSize = ( - sourceElement: HTMLElement, - exportElement: HTMLElement, - layout: PdfExportLayout, -) => { - const { windowHeight } = getCanvasWindowSize(exportElement); - const sourceSize = getCanvasWindowSize(sourceElement); - - return { - windowHeight: - windowHeight || - sourceSize.windowHeight || - Math.ceil(layout.cssPageHeight), - windowWidth: layout.windowWidth, - }; -}; - -const readBlobAsDataUrl = (blob: Blob) => - new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.addEventListener("load", () => { - resolve(String(reader.result)); - }); - reader.addEventListener("error", () => { - reject(reader.error); - }); - reader.readAsDataURL(blob); - }); - -const isEmbeddableImageUrl = (src: string) => { - if (!src || src.startsWith("data:") || src.startsWith("blob:")) { - return false; - } - - return true; -}; - -const embedImageSource = async (image: HTMLImageElement) => { - const src = image.currentSrc || image.getAttribute("src") || image.src; - - if (!isEmbeddableImageUrl(src) || typeof fetch !== "function") { - return; - } - - try { - const response = await fetch(src, { credentials: "omit", mode: "cors" }); - - if (!response.ok) { - throw new Error(`Image request failed with status ${response.status}`); - } - - const dataUrl = await readBlobAsDataUrl(await response.blob()); - - image.removeAttribute("srcset"); - image.crossOrigin = "anonymous"; - image.src = dataUrl; - } catch { - image.crossOrigin = "anonymous"; - image.referrerPolicy = "no-referrer"; - image.removeAttribute("srcset"); - image.src = src; - } -}; - -const prepareImagesForExport = async (exportElement: HTMLElement) => { - const images = Array.from(exportElement.querySelectorAll("img")); - - await Promise.all(images.map((image) => embedImageSource(image))); -}; - -const getColorFallback = (property: string) => { - if (property.includes("background")) { - return "transparent"; +const getFilenameFromContentDisposition = (headerValue?: string | null) => { + if (!headerValue) { + return undefined; } - if (property.includes("shadow")) { - return "none"; - } + const match = headerValue.match(/filename="?([^"]+)"?/i); - if (property.includes("border") || property.includes("outline")) { - return "#d1d5db"; + if (!match?.[1]) { + return undefined; } - return "#111827"; -}; - -const sanitizeInlineColorStyles = (element: HTMLElement) => { - const inlineStyle = element.getAttribute("style"); - - if (!inlineStyle || !UNSUPPORTED_COLOR_FUNCTION_PATTERN.test(inlineStyle)) { - return; - } - - const safeDeclarations = inlineStyle - .split(";") - .map((declaration) => declaration.trim()) - .filter( - (declaration) => - declaration && !UNSUPPORTED_COLOR_FUNCTION_PATTERN.test(declaration), - ); - - if (safeDeclarations.length > 0) { - element.setAttribute("style", safeDeclarations.join("; ")); - return; - } - - element.removeAttribute("style"); -}; - -const sanitizeComputedColorStyles = (element: HTMLElement) => { - const view = element.ownerDocument.defaultView; - - if (!view) { - return; - } - - const computedStyle = view.getComputedStyle(element); - const colorProperties = [ - "background-color", - "border-bottom-color", - "border-left-color", - "border-right-color", - "border-top-color", - "box-shadow", - "color", - "outline-color", - "text-decoration-color", - "text-shadow", - ]; - - for (const property of colorProperties) { - const value = computedStyle.getPropertyValue(property); - - if (UNSUPPORTED_COLOR_FUNCTION_PATTERN.test(value)) { - element.style.setProperty(property, getColorFallback(property)); - } - } -}; - -const sanitizeUnsupportedColorStyles = (exportElement: HTMLElement) => { - const elements = [ - exportElement, - ...Array.from(exportElement.querySelectorAll("*")), - ]; - - for (const element of elements) { - sanitizeInlineColorStyles(element); - sanitizeComputedColorStyles(element); - } -}; - -const applySafeDocumentColors = (targetDocument: Document) => { - const roots = [targetDocument.documentElement, targetDocument.body].filter( - (root): root is HTMLElement => Boolean(root), - ); - - for (const root of roots) { - root.style.setProperty("background-color", "#ffffff", "important"); - root.style.setProperty("color", "#111827", "important"); - } -}; - -const sanitizeHtml2CanvasClone = ( - clonedDocument: Document, - clonedElement: HTMLElement, -) => { - applySafeDocumentColors(clonedDocument); - sanitizeUnsupportedColorStyles(clonedElement); -}; - -const createPdfExportElement = async ( - sourceElement: HTMLElement, - pdf: PdfDocument, -): Promise => { - const ownerDocument = sourceElement.ownerDocument ?? document; - const wrapper = ownerDocument.createElement("div"); - const element = sourceElement.cloneNode(true) as HTMLElement; - const layout = getPdfExportLayout(pdf); - - element.className = "markdown-body"; - Object.assign(wrapper.style, { - background: "#ffffff", - color: "#111827", - left: "-10000px", - margin: "0", - padding: "0", - pointerEvents: "none", - position: "fixed", - top: "0", - width: `${layout.cssWidth}px`, - zIndex: "-1", - }); - Object.assign(element.style, { - background: "#ffffff", - boxSizing: "border-box", - color: "#111827", - margin: "0", - maxWidth: `${layout.cssWidth}px`, - padding: "0", - width: `${layout.cssWidth}px`, - }); - - wrapper.append(element); - ownerDocument.body.append(wrapper); - sanitizeUnsupportedColorStyles(element); - await prepareImagesForExport(element); - - return { - cleanup: () => { - wrapper.remove(); - }, - element, - layout, - }; -}; - -const createCanvasSlice = ( - sourceCanvas: HTMLCanvasElement, - offsetY: number, - sliceHeight: number, -) => { - const ownerDocument = sourceCanvas.ownerDocument ?? document; - const sliceCanvas = ownerDocument.createElement("canvas"); - const context = sliceCanvas.getContext("2d"); - - sliceCanvas.width = sourceCanvas.width; - sliceCanvas.height = sliceHeight; - - if (!context) { - throw new Error("Unable to create PDF canvas context"); - } - - context.drawImage( - sourceCanvas, - 0, - offsetY, - sourceCanvas.width, - sliceHeight, - 0, - 0, - sourceCanvas.width, - sliceHeight, - ); - - return sliceCanvas; -}; - -const addCanvasToPdf = (canvas: HTMLCanvasElement, pdf: PdfDocument) => { - const pageWidth = pdf.internal.pageSize.getWidth(); - const pageHeight = pdf.internal.pageSize.getHeight(); - const printableWidth = pageWidth - PDF_PAGE_MARGIN * 2; - const printableHeight = pageHeight - PDF_PAGE_MARGIN * 2; - const pageCanvasHeight = Math.floor( - (printableHeight * canvas.width) / printableWidth, - ); - - for (let offsetY = 0; offsetY < canvas.height; offsetY += pageCanvasHeight) { - if (offsetY > 0) { - pdf.addPage(); - } - - const sliceHeight = Math.min(pageCanvasHeight, canvas.height - offsetY); - const pageCanvas = createCanvasSlice(canvas, offsetY, sliceHeight); - const pageImageHeight = (sliceHeight * printableWidth) / canvas.width; - - pdf.addImage( - pageCanvas.toDataURL("image/png"), - "PNG", - PDF_PAGE_MARGIN, - PDF_PAGE_MARGIN, - printableWidth, - pageImageHeight, - ); - } + return decodeURIComponent(match[1]); }; const exportMarkdownPdf = async ({ - canvasRenderer = renderElementToCanvas, - filename = MARKDOWN_EXPORT_BASENAME, - pdfFactory = createPdfDocument, - sourceElement, + document: targetDocument, + fetcher = fetch, + filename, + markdownContent, + url, }: MarkdownPdfExportOptions) => { - if (!hasExportableContent(sourceElement)) { + if (!markdownContent.trim()) { return false; } - const normalizedFilename = withFileExtension(filename, "pdf"); - const pdf = pdfFactory(); - const exportElement = await createPdfExportElement(sourceElement, pdf); - - try { - const { windowHeight, windowWidth } = getExportCanvasWindowSize( - sourceElement, - exportElement.element, - exportElement.layout, - ); - const canvas = await canvasRenderer(exportElement.element, { - backgroundColor: "#ffffff", - onclone: sanitizeHtml2CanvasClone, - scale: PDF_RENDER_SCALE, - useCORS: true, - windowHeight, - windowWidth, - }); + const response = await fetcher("/api/export/markdown/pdf", { + body: JSON.stringify({ + filename, + markdownContent, + }), + headers: { + "Content-Type": "application/json", + }, + method: "POST", + }); - addCanvasToPdf(canvas, pdf); - pdf.save(normalizedFilename); - } finally { - exportElement.cleanup(); + if (!response.ok) { + throw new Error("Failed to export markdown as PDF."); } - return normalizedFilename; + const blob = await response.blob(); + const resolvedFilename = + getFilenameFromContentDisposition( + response.headers.get("Content-Disposition"), + ) ?? withFileExtension(filename ?? MARKDOWN_EXPORT_BASENAME, "pdf"); + + downloadBlob(blob, resolvedFilename, { + document: targetDocument, + url, + }); + + return resolvedFilename; }; export { - addCanvasToPdf, createMarkdownBlob, exportMarkdownFile, exportMarkdownPdf, withFileExtension, }; -export type { - CanvasRenderer, - MarkdownFileExportOptions, - MarkdownPdfExportOptions, - PdfDocument, -}; +export type { MarkdownFileExportOptions, MarkdownPdfExportOptions }; From ef07e3d982cfbfde139fe13de4b599b6fb091216 Mon Sep 17 00:00:00 2001 From: Kerwin Zheng <1747269691@qq.com> Date: Sun, 3 May 2026 02:10:35 +0800 Subject: [PATCH 08/11] refactor(pdf): remove html2canvas export pipeline - remove html2canvas and jspdf dependencies after switching markdown PDF export to the server route - update the server renderer to use a runtime react-dom/server import so the Next.js route builds cleanly - format biome.json so pnpm check passes again Verification: - pnpm test - pnpm build - pnpm check Co-authored-by: Codex --- lib/markdown-export/pdf-renderer.tsx | 10 +- package.json | 2 - pnpm-lock.yaml | 154 --------------------------- 3 files changed, 6 insertions(+), 160 deletions(-) diff --git a/lib/markdown-export/pdf-renderer.tsx b/lib/markdown-export/pdf-renderer.tsx index e823e4f..cb1cfc1 100644 --- a/lib/markdown-export/pdf-renderer.tsx +++ b/lib/markdown-export/pdf-renderer.tsx @@ -1,6 +1,5 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { renderToStaticMarkup } from "react-dom/server"; import Markdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; @@ -56,8 +55,10 @@ const loadRenderAssets = async (): Promise => { return cachedAssetsPromise; }; -const createMarkdownMarkup = (markdownContent: string) => - renderToStaticMarkup( +const createMarkdownMarkup = async (markdownContent: string) => { + const { renderToStaticMarkup } = await import("react-dom/server"); + + return renderToStaticMarkup( {markdownContent} , ); +}; const createMermaidBootScript = () => ` window.__MERMAID_READY__ = (async () => { @@ -111,7 +113,7 @@ const createMarkdownPrintHtml = async ({ markdownContent, }: CreateMarkdownPrintHtmlOptions): Promise => { const { katexCss, markdownCss, mermaidScript } = await loadRenderAssets(); - const markdownMarkup = createMarkdownMarkup(markdownContent); + const markdownMarkup = await createMarkdownMarkup(markdownContent); return ` diff --git a/package.json b/package.json index de38f77..0057040 100644 --- a/package.json +++ b/package.json @@ -50,11 +50,9 @@ "dexie": "^4.4.2", "drizzle-orm": "^0.45.2", "frimousse": "^0.3.0", - "html2canvas": "^1.4.1", "iconv-lite": "^0.7.2", "input-otp": "^1.4.2", "jotai": "^2.18.0", - "jspdf": "^4.2.1", "katex": "^0.16.45", "lucide-react": "^0.575.0", "mermaid": "^11.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adee6a4..5f404a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,9 +95,6 @@ importers: frimousse: specifier: ^0.3.0 version: 0.3.0(react@19.2.3)(typescript@5.9.3) - html2canvas: - specifier: ^1.4.1 - version: 1.4.1 iconv-lite: specifier: ^0.7.2 version: 0.7.2 @@ -107,9 +104,6 @@ importers: jotai: specifier: ^2.18.0 version: 2.18.0(@babel/core@7.28.5)(@babel/template@7.28.6)(@types/react@19.2.0)(react@19.2.3) - jspdf: - specifier: ^4.2.1 - version: 4.2.1 katex: specifier: ^0.16.45 version: 0.16.45 @@ -683,10 +677,6 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -4009,18 +3999,12 @@ packages: '@types/node@20.19.19': resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==} - '@types/pako@2.0.4': - resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} - '@types/pg-pool@2.0.6': resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} '@types/pg@8.6.1': resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==} - '@types/raf@3.4.3': - resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} - '@types/react-dom@19.2.0': resolution: {integrity: sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==} peerDependencies: @@ -4410,10 +4394,6 @@ packages: bare-url@2.4.2: resolution: {integrity: sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==} - base64-arraybuffer@1.0.2: - resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} - engines: {node: '>= 0.6.0'} - baseline-browser-mapping@2.9.11: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true @@ -4535,10 +4515,6 @@ packages: caniuse-lite@1.0.30001762: resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} - canvg@3.0.11: - resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} - engines: {node: '>=10.0.0'} - ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4763,9 +4739,6 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} - css-line-break@2.1.0: - resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} - css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -5396,9 +5369,6 @@ packages: fast-json-stringify@5.16.1: resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} - fast-png@6.4.0: - resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} - fast-querystring@1.1.2: resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} @@ -5448,9 +5418,6 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} - figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -5707,10 +5674,6 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - html2canvas@1.4.1: - resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} - engines: {node: '>=8.0.0'} - htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} @@ -5800,9 +5763,6 @@ packages: intl-messageformat@11.2.0: resolution: {integrity: sha512-IhghAA8n4KSlXuWKzYsWyWb82JoYTzShfyvdSF85oJPnNOjvv4kAo7S7Jtkm3/vJ53C7dQNRO+Gpnj3iWgTjBQ==} - iobuffer@5.4.0: - resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} - ip-address@10.0.1: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} @@ -6179,9 +6139,6 @@ packages: resolution: {integrity: sha512-HIf1uwublnXZsy7p3yHTrhzMzrLO6xKnqXytT9pEil5QxaXi8eyer7Is4luF5hYSV4kD3v03Y32FWoAeVYTghQ==} hasBin: true - jspdf@4.2.1: - resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} - katex@0.16.45: resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} hasBin: true @@ -6838,9 +6795,6 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} - pako@2.1.0: - resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -6918,9 +6872,6 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - performance-now@2.1.0: - resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} - pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -7164,9 +7115,6 @@ packages: '@types/react-dom': optional: true - raf@3.4.1: - resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} - railroad-diagrams@1.0.0: resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} @@ -7278,9 +7226,6 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - regenerator-runtime@0.13.11: - resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} - rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} @@ -7356,10 +7301,6 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rgbcolor@1.0.1: - resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} - engines: {node: '>= 0.8.15'} - robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -7555,10 +7496,6 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} - stackblur-canvas@2.7.0: - resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} - engines: {node: '>=0.1.14'} - statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -7677,10 +7614,6 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - svg-pathdata@6.0.3: - resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} - engines: {node: '>=12.0.0'} - swr@2.4.1: resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==} peerDependencies: @@ -7737,9 +7670,6 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - text-segmentation@1.0.3: - resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} - thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} @@ -7969,9 +7899,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utrie@1.0.2: - resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} - uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -8981,8 +8908,6 @@ snapshots: '@babel/runtime@7.28.4': {} - '@babel/runtime@7.29.2': {} - '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -12510,8 +12435,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/pako@2.0.4': {} - '@types/pg-pool@2.0.6': dependencies: '@types/pg': 8.6.1 @@ -12522,9 +12445,6 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 - '@types/raf@3.4.3': - optional: true - '@types/react-dom@19.2.0(@types/react@19.2.0)': dependencies: '@types/react': 19.2.0 @@ -12857,8 +12777,6 @@ snapshots: dependencies: bare-path: 3.0.0 - base64-arraybuffer@1.0.2: {} - baseline-browser-mapping@2.9.11: {} basic-ftp@5.3.1: {} @@ -12969,18 +12887,6 @@ snapshots: caniuse-lite@1.0.30001762: {} - canvg@3.0.11: - dependencies: - '@babel/runtime': 7.29.2 - '@types/raf': 3.4.3 - core-js: 3.49.0 - raf: 3.4.1 - regenerator-runtime: 0.13.11 - rgbcolor: 1.0.1 - stackblur-canvas: 2.7.0 - svg-pathdata: 6.0.3 - optional: true - ccount@2.0.1: {} cfb@1.2.2: @@ -13165,10 +13071,6 @@ snapshots: crypto-js@4.2.0: {} - css-line-break@2.1.0: - dependencies: - utrie: 1.0.2 - css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -13822,12 +13724,6 @@ snapshots: json-schema-ref-resolver: 1.0.1 rfdc: 1.4.1 - fast-png@6.4.0: - dependencies: - '@types/pako': 2.0.4 - iobuffer: 5.4.0 - pako: 2.1.0 - fast-querystring@1.1.2: dependencies: fast-decode-uri-component: 1.0.1 @@ -13894,8 +13790,6 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - fflate@0.8.2: {} - figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -14217,11 +14111,6 @@ snapshots: html-void-elements@3.0.0: {} - html2canvas@1.4.1: - dependencies: - css-line-break: 2.1.0 - text-segmentation: 1.0.3 - htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -14316,8 +14205,6 @@ snapshots: '@formatjs/fast-memoize': 3.1.1 '@formatjs/icu-messageformat-parser': 3.5.3 - iobuffer@5.4.0: {} - ip-address@10.0.1: {} ip-address@10.2.0: {} @@ -14847,17 +14734,6 @@ snapshots: jsox@1.2.125: {} - jspdf@4.2.1: - dependencies: - '@babel/runtime': 7.29.2 - fast-png: 6.4.0 - fflate: 0.8.2 - optionalDependencies: - canvg: 3.0.11 - core-js: 3.49.0 - dompurify: 3.4.2 - html2canvas: 1.4.1 - katex@0.16.45: dependencies: commander: 8.3.0 @@ -15724,8 +15600,6 @@ snapshots: package-manager-detector@1.6.0: {} - pako@2.1.0: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -15796,9 +15670,6 @@ snapshots: pend@1.2.0: {} - performance-now@2.1.0: - optional: true - pg-cloudflare@1.3.0: optional: true @@ -16104,11 +15975,6 @@ snapshots: '@types/react': 19.2.0 '@types/react-dom': 19.2.0(@types/react@19.2.0) - raf@3.4.1: - dependencies: - performance-now: 2.1.0 - optional: true - railroad-diagrams@1.0.0: {} randexp@0.4.6: @@ -16227,9 +16093,6 @@ snapshots: reflect-metadata@0.2.2: {} - regenerator-runtime@0.13.11: - optional: true - rehype-katex@7.0.1: dependencies: '@types/hast': 3.0.4 @@ -16332,9 +16195,6 @@ snapshots: rfdc@1.4.1: {} - rgbcolor@1.0.1: - optional: true - robust-predicates@3.0.3: {} rou3@0.7.12: {} @@ -16608,9 +16468,6 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 - stackblur-canvas@2.7.0: - optional: true - statuses@2.0.2: {} stdin-discarder@0.2.2: {} @@ -16722,9 +16579,6 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svg-pathdata@6.0.3: - optional: true - swr@2.4.1(react@19.2.3): dependencies: dequal: 2.0.3 @@ -16809,10 +16663,6 @@ snapshots: transitivePeerDependencies: - react-native-b4a - text-segmentation@1.0.3: - dependencies: - utrie: 1.0.2 - thread-stream@3.1.0: dependencies: real-require: 0.2.0 @@ -17057,10 +16907,6 @@ snapshots: util-deprecate@1.0.2: {} - utrie@1.0.2: - dependencies: - base64-arraybuffer: 1.0.2 - uuid@11.1.0: {} v8-compile-cache-lib@3.0.1: {} From 8793f9b90c42ea96cb597ac14857500d2e08f5ed Mon Sep 17 00:00:00 2001 From: Kerwin Zheng <1747269691@qq.com> Date: Sun, 3 May 2026 08:45:38 +0800 Subject: [PATCH 09/11] refactor(pdf): make snapdom the only client export path - collapse markdown pdf export onto the SnapDOM-based client pipeline - remove the split server/snapdom export menu in the markdown preview panel - update markdown export tests to target the SnapDOM PDF flow Verification: - pnpm test -- lib/markdown-export.test.ts app/chat/components/markdown-preview-panel.test.tsx - pnpm exec biome check lib/markdown-export.ts lib/markdown-export.test.ts app/chat/components/markdown-preview-panel.tsx app/chat/components/markdown-preview-panel.test.tsx Co-authored-by: Codex --- .../markdown-preview-panel.test.tsx | 2 +- .../components/markdown-preview-panel.tsx | 16 +- lib/markdown-export.test.ts | 100 ++++++--- lib/markdown-export.ts | 206 +++++++++++++++--- package.json | 4 +- pnpm-lock.yaml | 40 ++++ 6 files changed, 301 insertions(+), 67 deletions(-) diff --git a/app/chat/components/markdown-preview-panel.test.tsx b/app/chat/components/markdown-preview-panel.test.tsx index 225569a..f623a4d 100644 --- a/app/chat/components/markdown-preview-panel.test.tsx +++ b/app/chat/components/markdown-preview-panel.test.tsx @@ -68,7 +68,7 @@ describe("MarkdownPreview", () => { await waitFor(() => expect(exportMarkdownPdfMock).toHaveBeenCalledWith({ - markdownContent: "# Report\n\nHello export", + sourceElement: expect.any(HTMLElement), }), ); }); diff --git a/app/chat/components/markdown-preview-panel.tsx b/app/chat/components/markdown-preview-panel.tsx index f2b6df0..a1f4be9 100644 --- a/app/chat/components/markdown-preview-panel.tsx +++ b/app/chat/components/markdown-preview-panel.tsx @@ -15,7 +15,7 @@ import { exportMarkdownFile, exportMarkdownPdf } from "@/lib/markdown-export"; import { useAtomValue } from "jotai"; import { ChevronDown, Download, FileText } from "lucide-react"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; -import { memo, useCallback, useState } from "react"; +import { memo, useCallback, useRef, useState } from "react"; import Markdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; @@ -59,6 +59,7 @@ const MarkdownPreBlock = ({ const MarkdownPreview = () => { const markdownContent = useAtomValue(workspaceMarkdownContentAtom); + const previewRef = useRef(null); const [isExportingPdf, setIsExportingPdf] = useState(false); const hasMarkdownContent = markdownContent.trim().length > 0; @@ -67,7 +68,7 @@ const MarkdownPreview = () => { }, [markdownContent]); const handleExportPdf = useCallback(async () => { - if (isExportingPdf || !hasMarkdownContent) { + if (isExportingPdf || !hasMarkdownContent || !previewRef.current) { return; } @@ -75,12 +76,12 @@ const MarkdownPreview = () => { try { await exportMarkdownPdf({ - markdownContent, + sourceElement: previewRef.current, }); } finally { setIsExportingPdf(false); } - }, [hasMarkdownContent, isExportingPdf, markdownContent]); + }, [hasMarkdownContent, isExportingPdf]); return (
@@ -105,7 +106,7 @@ const MarkdownPreview = () => { disabled={isExportingPdf} > - PDF + {isExportingPdf ? "PDF..." : "PDF"} @@ -115,7 +116,10 @@ const MarkdownPreview = () => {
-
+
{ clickSpy.mockRestore(); }); - it("posts markdown to the export route and downloads the returned pdf", async () => { + it("captures the preview with snapdom and downloads a pdf", async () => { const createObjectURL = jest.fn(() => "blob:pdf"); const revokeObjectURL = jest.fn(); const originalCreateElement = document.createElement.bind(document); const anchor = document.createElement("a"); + const drawImage = jest.fn(); + const sourceElement = document.createElement("article"); + const canvas = document.createElement("canvas"); const clickSpy = jest.spyOn(anchor, "click").mockImplementation(() => {}); const createElementSpy = jest .spyOn(document, "createElement") @@ -82,57 +85,104 @@ describe("markdown export helpers", () => { tagName === "a" ? anchor : originalCreateElement(tagName)) as typeof document.createElement); - const fetcher = jest.fn(async () => ({ - blob: async () => new Blob(["pdf"], { type: "application/pdf" }), - headers: { - get: () => null, - }, - ok: true, + const getContextSpy = jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); + const toBlobSpy = jest + .spyOn(HTMLCanvasElement.prototype, "toBlob") + .mockImplementation((callback) => { + callback?.(new Blob(["png"], { type: "image/png" })); + }); + const captureElement = jest.fn(async () => ({ + toCanvas: async () => canvas, + })); + const addPage = jest.fn(() => ({ + drawImage: jest.fn(), + })); + const embedPng = jest.fn(async () => ({})); + const save = jest.fn(async () => new Uint8Array([4, 5, 6])); + const createPdfDocument = jest.fn(async () => ({ + addPage, + embedPng, + save, })); + sourceElement.innerHTML = "

Report

"; + canvas.width = 200; + canvas.height = 400; + const filename = await exportMarkdownPdf({ + captureElement, + createPdfDocument, document, - fetcher, filename: "analysis-report", - markdownContent: "# Report", + sourceElement, url: { createObjectURL, revokeObjectURL, }, }); - expect(fetcher).toHaveBeenCalledWith( - "/api/export/markdown/pdf", + expect(captureElement).toHaveBeenCalledWith( + sourceElement, expect.objectContaining({ - body: JSON.stringify({ - filename: "analysis-report", - markdownContent: "# Report", - }), - headers: { - "Content-Type": "application/json", - }, - method: "POST", + backgroundColor: "#ffffff", + embedFonts: true, + scale: 2, }), ); + expect(createPdfDocument).toHaveBeenCalled(); + expect(embedPng).toHaveBeenCalled(); + expect(save).toHaveBeenCalled(); expect(filename).toBe("analysis-report.pdf"); expect(anchor.download).toBe("analysis-report.pdf"); expect(clickSpy).toHaveBeenCalled(); expect(revokeObjectURL).toHaveBeenCalledWith("blob:pdf"); createElementSpy.mockRestore(); + getContextSpy.mockRestore(); + toBlobSpy.mockRestore(); clickSpy.mockRestore(); }); - it("throws when the pdf export route fails", async () => { - const fetcher = jest.fn(async () => ({ - ok: false, + it("returns false when the snapdom source element is empty", async () => { + const sourceElement = document.createElement("article"); + + await expect( + exportMarkdownPdf({ + filename: "analysis-report", + sourceElement, + }), + ).resolves.toBe(false); + }); + + it("throws when snapdom export cannot create a png blob", async () => { + const captureElement = jest.fn(async () => ({ + toCanvas: async () => document.createElement("canvas"), })); + const sourceElement = document.createElement("article"); + const getContextSpy = jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue({ + drawImage: jest.fn(), + } as unknown as CanvasRenderingContext2D); + const toBlobSpy = jest + .spyOn(HTMLCanvasElement.prototype, "toBlob") + .mockImplementation((callback) => { + callback?.(null); + }); + + sourceElement.innerHTML = "

Report

"; await expect( exportMarkdownPdf({ - fetcher, - markdownContent: "# Report", + captureElement, + filename: "analysis-report", + sourceElement, }), - ).rejects.toThrow("Failed to export markdown as PDF."); + ).rejects.toThrow("Failed to convert SnapDOM canvas to PNG."); + + getContextSpy.mockRestore(); + toBlobSpy.mockRestore(); }); }); diff --git a/lib/markdown-export.ts b/lib/markdown-export.ts index 4ba8e8c..ec54ddd 100644 --- a/lib/markdown-export.ts +++ b/lib/markdown-export.ts @@ -1,5 +1,9 @@ const MARKDOWN_EXPORT_BASENAME = "refract-markdown"; const MARKDOWN_MIME_TYPE = "text/markdown;charset=utf-8"; +const PDF_PAGE_HEIGHT = 841.89; +const PDF_PAGE_MARGIN = 36; +const PDF_PAGE_WIDTH = 595.28; +const SNAPDOM_PDF_SCALE = 2; type FileExtension = "md" | "pdf"; @@ -12,10 +16,38 @@ type MarkdownFileExportOptions = DownloadBlobOptions & { filename?: string; }; +type SnapdomCanvasCapture = { + toCanvas: () => Promise; +}; + +type SnapdomCaptureElement = ( + element: HTMLElement, + options: Record, +) => Promise; + +type SnapdomPdfDocumentPage = { + drawImage: ( + image: unknown, + options: { + height: number; + width: number; + x: number; + y: number; + }, + ) => void; +}; + +type SnapdomPdfDocument = { + addPage: (size: [number, number]) => SnapdomPdfDocumentPage; + embedPng: (bytes: Uint8Array) => Promise; + save: () => Promise; +}; + type MarkdownPdfExportOptions = DownloadBlobOptions & { - fetcher?: typeof fetch; + captureElement?: SnapdomCaptureElement; + createPdfDocument?: () => Promise; filename?: string; - markdownContent: string; + sourceElement: HTMLElement; }; const withFileExtension = (filename: string, extension: FileExtension) => { @@ -64,56 +96,157 @@ const exportMarkdownFile = ( return filename; }; -const getFilenameFromContentDisposition = (headerValue?: string | null) => { - if (!headerValue) { - return undefined; +const hasExportableElementContent = (sourceElement: HTMLElement) => { + const visibleText = + "innerText" in sourceElement && typeof sourceElement.innerText === "string" + ? sourceElement.innerText + : (sourceElement.textContent ?? ""); + + return Boolean(visibleText.trim() || sourceElement.innerHTML.trim()); +}; + +const createCanvasSlice = ( + sourceCanvas: HTMLCanvasElement, + offsetY: number, + sliceHeight: number, +) => { + const ownerDocument = sourceCanvas.ownerDocument ?? document; + const sliceCanvas = ownerDocument.createElement("canvas"); + const context = sliceCanvas.getContext("2d"); + + sliceCanvas.width = sourceCanvas.width; + sliceCanvas.height = sliceHeight; + + if (!context) { + throw new Error("Unable to create SnapDOM PDF canvas context."); + } + + context.drawImage( + sourceCanvas, + 0, + offsetY, + sourceCanvas.width, + sliceHeight, + 0, + 0, + sourceCanvas.width, + sliceHeight, + ); + + return sliceCanvas; +}; + +const createCanvasBlob = (canvas: HTMLCanvasElement) => + new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error("Failed to convert SnapDOM canvas to PNG.")); + return; + } + + resolve(blob); + }, "image/png"); + }); + +const readBlobBytes = async (blob: Blob) => { + if (typeof blob.arrayBuffer === "function") { + return new Uint8Array(await blob.arrayBuffer()); } - const match = headerValue.match(/filename="?([^"]+)"?/i); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.addEventListener("load", () => { + resolve(new Uint8Array(reader.result as ArrayBuffer)); + }); + reader.addEventListener("error", () => { + reject(reader.error); + }); + reader.readAsArrayBuffer(blob); + }); +}; + +const createCanvasBytes = async (canvas: HTMLCanvasElement) => + readBlobBytes(await createCanvasBlob(canvas)); - if (!match?.[1]) { - return undefined; +const addCanvasPagesToPdf = async ( + canvas: HTMLCanvasElement, + pdfDocument: SnapdomPdfDocument, +) => { + const printableHeight = PDF_PAGE_HEIGHT - PDF_PAGE_MARGIN * 2; + const printableWidth = PDF_PAGE_WIDTH - PDF_PAGE_MARGIN * 2; + const pageCanvasHeight = Math.floor( + (printableHeight * canvas.width) / printableWidth, + ); + + for (let offsetY = 0; offsetY < canvas.height; offsetY += pageCanvasHeight) { + const sliceHeight = Math.min(pageCanvasHeight, canvas.height - offsetY); + const pageCanvas = createCanvasSlice(canvas, offsetY, sliceHeight); + const pageBytes = await createCanvasBytes(pageCanvas); + const pageImage = await pdfDocument.embedPng(pageBytes); + const page = pdfDocument.addPage([PDF_PAGE_WIDTH, PDF_PAGE_HEIGHT]); + const pageImageHeight = (sliceHeight * printableWidth) / canvas.width; + + page.drawImage(pageImage, { + height: pageImageHeight, + width: printableWidth, + x: PDF_PAGE_MARGIN, + y: PDF_PAGE_HEIGHT - PDF_PAGE_MARGIN - pageImageHeight, + }); } +}; - return decodeURIComponent(match[1]); +const createSnapdomCaptureElement: SnapdomCaptureElement = async ( + element, + options, +) => { + const { snapdom } = await import("@zumer/snapdom"); + return snapdom(element, options) as Promise; +}; + +const createSnapdomPdfDocument = async (): Promise => { + const { PDFDocument } = await import("pdf-lib"); + return (await PDFDocument.create()) as unknown as SnapdomPdfDocument; }; const exportMarkdownPdf = async ({ + captureElement = createSnapdomCaptureElement, + createPdfDocument = createSnapdomPdfDocument, document: targetDocument, - fetcher = fetch, filename, - markdownContent, + sourceElement, url, }: MarkdownPdfExportOptions) => { - if (!markdownContent.trim()) { + if (!hasExportableElementContent(sourceElement)) { return false; } - const response = await fetcher("/api/export/markdown/pdf", { - body: JSON.stringify({ - filename, - markdownContent, - }), - headers: { - "Content-Type": "application/json", - }, - method: "POST", + const capture = await captureElement(sourceElement, { + backgroundColor: "#ffffff", + embedFonts: true, + fast: false, + scale: SNAPDOM_PDF_SCALE, }); + const canvas = await capture.toCanvas(); + const pdfDocument = await createPdfDocument(); - if (!response.ok) { - throw new Error("Failed to export markdown as PDF."); - } + await addCanvasPagesToPdf(canvas, pdfDocument); - const blob = await response.blob(); - const resolvedFilename = - getFilenameFromContentDisposition( - response.headers.get("Content-Disposition"), - ) ?? withFileExtension(filename ?? MARKDOWN_EXPORT_BASENAME, "pdf"); + const pdfBytes = await pdfDocument.save(); + const normalizedPdfBytes = new Uint8Array(pdfBytes); + const resolvedFilename = withFileExtension( + filename ?? MARKDOWN_EXPORT_BASENAME, + "pdf", + ); - downloadBlob(blob, resolvedFilename, { - document: targetDocument, - url, - }); + downloadBlob( + new Blob([normalizedPdfBytes], { type: "application/pdf" }), + resolvedFilename, + { + document: targetDocument, + url, + }, + ); return resolvedFilename; }; @@ -124,4 +257,9 @@ export { exportMarkdownPdf, withFileExtension, }; -export type { MarkdownFileExportOptions, MarkdownPdfExportOptions }; +export type { + MarkdownFileExportOptions, + MarkdownPdfExportOptions, + SnapdomCaptureElement, + SnapdomPdfDocument, +}; diff --git a/package.json b/package.json index 0057040..9bc67d9 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev --turbopack -p 3001", "build": "next build --turbopack", "start": "next start", "db:generate": "dotenv -e .env.local -- drizzle-kit generate", @@ -42,6 +42,7 @@ "@sparticuz/chromium-min": "^148.0.0", "@tanstack/react-query": "^5.96.2", "@tanstack/react-virtual": "^3.13.23", + "@zumer/snapdom": "^2.9.0", "ai": "^6.0.146", "axios": "^1.13.6", "class-variance-authority": "^0.7.1", @@ -59,6 +60,7 @@ "next": "16.1.1", "next-intl": "^4.9.0", "next-themes": "^0.4.6", + "pdf-lib": "^1.17.1", "puppeteer-core": "^24.42.0", "radix-ui": "^1.4.3", "react": "19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f404a6..723f9f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.23 version: 3.13.23(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@zumer/snapdom': + specifier: ^2.9.0 + version: 2.9.0 ai: specifier: ^6.0.146 version: 6.0.146(zod@4.3.6) @@ -122,6 +125,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 puppeteer-core: specifier: ^24.42.0 version: 24.42.0 @@ -2257,6 +2263,12 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@peculiar/asn1-android@2.6.0': resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} @@ -4175,6 +4187,9 @@ packages: '@types/react': optional: true + '@zumer/snapdom@2.9.0': + resolution: {integrity: sha512-ksDM5gXQmu1isBof/YumD2HeIHf4ImTMfba9xE4JyDbARhcZCWHFdjW8oZV3hSKdWpQILEspNq5MrAfwEfzpIw==} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -6795,6 +6810,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -6866,6 +6884,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -10434,6 +10455,14 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@peculiar/asn1-android@2.6.0': dependencies: '@peculiar/asn1-schema': 2.6.0 @@ -12565,6 +12594,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.0 + '@zumer/snapdom@2.9.0': {} + abstract-logging@2.0.1: {} accepts@2.0.0: @@ -15600,6 +15631,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -15666,6 +15699,13 @@ snapshots: pathe@2.0.3: {} + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + peberminta@0.9.0: {} pend@1.2.0: {} From b3a9c0064679c590b9a6bb8bc4cd9b4b418afe2d Mon Sep 17 00:00:00 2001 From: Kerwin Zheng <1747269691@qq.com> Date: Sun, 3 May 2026 08:48:28 +0800 Subject: [PATCH 10/11] refactor(pdf): delete server-side export implementation - remove the markdown PDF route, renderer, schema, and Chromium service files - drop puppeteer-core and @sparticuz/chromium-min after choosing SnapDOM as the only PDF path - remove the server-export environment variables from .env.example Verification: - pnpm test - pnpm build - pnpm check Co-authored-by: Codex --- .env.example | 4 - app/api/export/markdown/pdf/route.test.ts | 42 -- app/api/export/markdown/pdf/route.ts | 54 --- lib/markdown-export/pdf-export-schema.ts | 11 - lib/markdown-export/pdf-renderer.tsx | 180 -------- lib/markdown-export/pdf-service.test.ts | 66 --- lib/markdown-export/pdf-service.ts | 178 -------- package.json | 2 - pnpm-lock.yaml | 491 ---------------------- 9 files changed, 1028 deletions(-) delete mode 100644 app/api/export/markdown/pdf/route.test.ts delete mode 100644 app/api/export/markdown/pdf/route.ts delete mode 100644 lib/markdown-export/pdf-export-schema.ts delete mode 100644 lib/markdown-export/pdf-renderer.tsx delete mode 100644 lib/markdown-export/pdf-service.test.ts delete mode 100644 lib/markdown-export/pdf-service.ts diff --git a/.env.example b/.env.example index cd582f4..1215f00 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,3 @@ R2_SECRET_ACCESS_KEY = "your_secret_key" NPM_REGISTRY = "https://registry.npmmirror.com" DATABASE_URL = "your neon database url" - -# Markdown PDF export -CHROMIUM_PACK_URL = "https://your-fast-storage.example.com/chromium-pack.tar" -PUPPETEER_EXECUTABLE_PATH = "C:/path/to/local/chrome.exe" diff --git a/app/api/export/markdown/pdf/route.test.ts b/app/api/export/markdown/pdf/route.test.ts deleted file mode 100644 index eb1d347..0000000 --- a/app/api/export/markdown/pdf/route.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { POST } from "./route"; - -jest.mock("@/lib/markdown-export/pdf-service", () => ({ - renderMarkdownPdf: jest.fn(async () => ({ - bytes: new Uint8Array([1, 2, 3]), - filename: "analysis-report.pdf", - })), -})); - -describe("POST /api/export/markdown/pdf", () => { - it("returns 400 when markdownContent is missing", async () => { - const response = await POST({ - json: async () => ({}), - } as Request); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual( - expect.objectContaining({ - message: "Invalid export request", - success: false, - }), - ); - }); - - it("returns a pdf attachment when the request is valid", async () => { - const response = await POST({ - json: async () => ({ - filename: "analysis-report", - markdownContent: "# Report", - }), - } as Request); - - expect(response.status).toBe(200); - expect(response.headers.get("Content-Type")).toBe("application/pdf"); - expect(response.headers.get("Content-Disposition")).toContain( - "analysis-report.pdf", - ); - expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual([ - 1, 2, 3, - ]); - }); -}); diff --git a/app/api/export/markdown/pdf/route.ts b/app/api/export/markdown/pdf/route.ts deleted file mode 100644 index 78d8d16..0000000 --- a/app/api/export/markdown/pdf/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { MarkdownPdfExportRequestSchema } from "@/lib/markdown-export/pdf-export-schema"; -import { renderMarkdownPdf } from "@/lib/markdown-export/pdf-service"; -import { NextResponse } from "next/server"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; -export const maxDuration = 60; - -const toArrayBuffer = (bytes: Uint8Array) => { - const body = new ArrayBuffer(bytes.byteLength); - new Uint8Array(body).set(bytes); - return body; -}; - -const POST = async (request: Request) => { - try { - const body = await request.json(); - const parsed = MarkdownPdfExportRequestSchema.safeParse(body); - - if (!parsed.success) { - return NextResponse.json( - { - code: 400, - success: false, - message: "Invalid export request", - }, - { status: 400 }, - ); - } - - const { bytes, filename } = await renderMarkdownPdf(parsed.data); - - return new NextResponse(toArrayBuffer(bytes), { - headers: { - "Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`, - "Content-Length": String(bytes.byteLength), - "Content-Type": "application/pdf", - }, - status: 200, - }); - } catch (error) { - console.error("[Markdown PDF Export API Error]", error); - return NextResponse.json( - { - code: 500, - success: false, - message: "Failed to generate PDF", - }, - { status: 500 }, - ); - } -}; - -export { POST }; diff --git a/lib/markdown-export/pdf-export-schema.ts b/lib/markdown-export/pdf-export-schema.ts deleted file mode 100644 index e3daa9c..0000000 --- a/lib/markdown-export/pdf-export-schema.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { z } from "zod"; - -const MarkdownPdfExportRequestSchema = z.object({ - filename: z.string().trim().min(1).optional(), - markdownContent: z.string().trim().min(1), -}); - -type MarkdownPdfExportRequest = z.infer; - -export { MarkdownPdfExportRequestSchema }; -export type { MarkdownPdfExportRequest }; diff --git a/lib/markdown-export/pdf-renderer.tsx b/lib/markdown-export/pdf-renderer.tsx deleted file mode 100644 index cb1cfc1..0000000 --- a/lib/markdown-export/pdf-renderer.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import Markdown from "react-markdown"; -import rehypeKatex from "rehype-katex"; -import rehypeRaw from "rehype-raw"; -import remarkGfm from "remark-gfm"; -import remarkMath from "remark-math"; - -type CreateMarkdownPrintHtmlOptions = { - markdownContent: string; -}; - -type RenderAssets = { - katexCss: string; - markdownCss: string; - mermaidScript: string; -}; - -let cachedAssetsPromise: Promise | null = null; - -const loadRenderAssets = async (): Promise => { - if (!cachedAssetsPromise) { - cachedAssetsPromise = Promise.all([ - readFile( - path.join(process.cwd(), "styles", "markdown-preview.css"), - "utf8", - ), - readFile( - path.join( - process.cwd(), - "node_modules", - "katex", - "dist", - "katex.min.css", - ), - "utf8", - ), - readFile( - path.join( - process.cwd(), - "node_modules", - "mermaid", - "dist", - "mermaid.min.js", - ), - "utf8", - ), - ]).then(([markdownCss, katexCss, mermaidScript]) => ({ - katexCss, - markdownCss, - mermaidScript, - })); - } - - return cachedAssetsPromise; -}; - -const createMarkdownMarkup = async (markdownContent: string) => { - const { renderToStaticMarkup } = await import("react-dom/server"); - - return renderToStaticMarkup( - - {markdownContent} - , - ); -}; - -const createMermaidBootScript = () => ` -window.__MERMAID_READY__ = (async () => { - if (typeof mermaid === "undefined") { - return; - } - - mermaid.initialize({ - startOnLoad: false, - theme: "default", - securityLevel: "loose", - }); - - const blocks = Array.from( - document.querySelectorAll("pre code.language-mermaid"), - ); - - for (const block of blocks) { - const pre = block.closest("pre"); - const source = block.textContent ?? ""; - - if (!pre || !source.trim()) { - continue; - } - - try { - const id = \`mermaid-\${Math.random().toString(36).slice(2, 9)}\`; - const { svg } = await mermaid.render(id, source); - const wrapper = document.createElement("div"); - wrapper.className = "mermaid-diagram"; - wrapper.innerHTML = svg; - pre.replaceWith(wrapper); - } catch (error) { - console.error("[PDF export mermaid render error]", error); - } - } -})(); -`; - -const escapeInlineScript = (script: string) => - script.replaceAll(" => { - const { katexCss, markdownCss, mermaidScript } = await loadRenderAssets(); - const markdownMarkup = await createMarkdownMarkup(markdownContent); - - return ` - - - - - Markdown Export - - - -
${markdownMarkup}
- - - -`; -}; - -export { createMarkdownPrintHtml }; -export type { CreateMarkdownPrintHtmlOptions }; diff --git a/lib/markdown-export/pdf-service.test.ts b/lib/markdown-export/pdf-service.test.ts deleted file mode 100644 index 351e344..0000000 --- a/lib/markdown-export/pdf-service.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -jest.mock("./pdf-renderer", () => ({ - createMarkdownPrintHtml: jest.fn(async () => ""), -})); - -import { renderMarkdownPdf } from "./pdf-service"; - -describe("renderMarkdownPdf", () => { - it("renders markdown HTML through a browser page and returns pdf bytes", async () => { - const setContent = jest.fn(); - const emulateMediaType = jest.fn(); - const evaluate = jest.fn(async () => undefined); - const waitForFunction = jest.fn(); - const pdf = jest.fn(async () => new Uint8Array([1, 2, 3])); - const closePage = jest.fn(); - const newPage = jest.fn(async () => ({ - close: closePage, - emulateMediaType, - evaluate, - pdf, - setContent, - waitForFunction, - })); - const closeBrowser = jest.fn(); - const launchBrowser = jest.fn(async () => ({ - close: closeBrowser, - newPage, - })); - const createHtml = jest.fn( - () => "

Report

", - ); - - const result = await renderMarkdownPdf( - { - filename: "analysis-report", - markdownContent: "# Report", - }, - { - createHtml, - launchBrowser, - }, - ); - - expect(createHtml).toHaveBeenCalledWith({ - markdownContent: "# Report", - }); - expect(setContent).toHaveBeenCalledWith( - "

Report

", - expect.objectContaining({ - waitUntil: "networkidle0", - }), - ); - expect(emulateMediaType).toHaveBeenCalledWith("screen"); - expect(waitForFunction).toHaveBeenCalled(); - expect(evaluate).toHaveBeenCalled(); - expect(pdf).toHaveBeenCalledWith( - expect.objectContaining({ - format: "A4", - printBackground: true, - }), - ); - expect(result.filename).toBe("analysis-report.pdf"); - expect(Array.from(result.bytes)).toEqual([1, 2, 3]); - expect(closePage).toHaveBeenCalled(); - expect(closeBrowser).toHaveBeenCalled(); - }); -}); diff --git a/lib/markdown-export/pdf-service.ts b/lib/markdown-export/pdf-service.ts deleted file mode 100644 index 0af6234..0000000 --- a/lib/markdown-export/pdf-service.ts +++ /dev/null @@ -1,178 +0,0 @@ -import chromium from "@sparticuz/chromium-min"; -import puppeteer from "puppeteer-core"; -import { createMarkdownPrintHtml } from "./pdf-renderer"; - -type RenderMarkdownPdfOptions = { - filename?: string; - markdownContent: string; -}; - -type RenderMarkdownPdfResult = { - bytes: Uint8Array; - filename: string; -}; - -type PdfPage = { - close: () => Promise; - emulateMediaType: (type?: "screen" | "print" | null) => Promise; - evaluate: (pageFunction: () => Promise) => Promise; - pdf: (options: Record) => Promise; - setContent: ( - html: string, - options?: { waitUntil?: "domcontentloaded" | "load" | "networkidle0" }, - ) => Promise; - waitForFunction: ( - pageFunction: () => unknown, - options?: { timeout?: number }, - ) => Promise; -}; - -type PdfBrowser = { - close: () => Promise; - newPage: () => Promise; -}; - -type RenderMarkdownPdfDeps = { - createHtml?: typeof createMarkdownPrintHtml; - launchBrowser?: () => Promise; -}; - -const MARKDOWN_EXPORT_BASENAME = "refract-markdown"; - -const withPdfExtension = (filename?: string) => { - const cleanFilename = filename?.trim() || MARKDOWN_EXPORT_BASENAME; - const filenameWithoutExtension = cleanFilename.replace(/\.[^/.]+$/, ""); - - return `${filenameWithoutExtension}.pdf`; -}; - -const resolveExecutablePath = async () => { - if (process.env.PUPPETEER_EXECUTABLE_PATH) { - return { - executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, - isLocalExecutable: true, - }; - } - - if (process.env.CHROMIUM_PACK_URL) { - return { - executablePath: await chromium.executablePath( - process.env.CHROMIUM_PACK_URL, - ), - isLocalExecutable: false, - }; - } - - throw new Error( - "PDF export requires PUPPETEER_EXECUTABLE_PATH locally or CHROMIUM_PACK_URL on deployed runtimes.", - ); -}; - -const launchMarkdownPdfBrowser = async (): Promise => { - const { executablePath, isLocalExecutable } = await resolveExecutablePath(); - - if (isLocalExecutable) { - return puppeteer.launch({ - args: puppeteer.defaultArgs(), - executablePath, - headless: true, - }) as Promise; - } - - return puppeteer.launch({ - args: puppeteer.defaultArgs({ - args: chromium.args, - headless: "shell", - }), - executablePath, - headless: "shell", - }) as Promise; -}; - -const waitForRenderedContent = async (page: PdfPage) => { - await page.waitForFunction( - () => - ( - window as Window & { - __MERMAID_READY__?: Promise; - } - ).__MERMAID_READY__ !== undefined, - { - timeout: 10_000, - }, - ); - - await page.evaluate(async () => { - if ("fonts" in document) { - await document.fonts.ready; - } - - const images = Array.from(document.images).filter( - (image) => !image.complete, - ); - await Promise.all( - images.map( - (image) => - new Promise((resolve) => { - image.addEventListener("load", () => resolve(), { once: true }); - image.addEventListener("error", () => resolve(), { once: true }); - }), - ), - ); - - const mermaidReady = ( - window as Window & { - __MERMAID_READY__?: Promise; - } - ).__MERMAID_READY__; - - if (mermaidReady) { - await mermaidReady; - } - }); -}; - -const renderMarkdownPdf = async ( - { filename, markdownContent }: RenderMarkdownPdfOptions, - { - createHtml = createMarkdownPrintHtml, - launchBrowser = launchMarkdownPdfBrowser, - }: RenderMarkdownPdfDeps = {}, -): Promise => { - const html = await createHtml({ markdownContent }); - const browser = await launchBrowser(); - const page = await browser.newPage(); - - try { - await page.setContent(html, { waitUntil: "networkidle0" }); - await page.emulateMediaType("screen"); - await waitForRenderedContent(page); - - const bytes = await page.pdf({ - format: "A4", - margin: { - bottom: "18mm", - left: "16mm", - right: "16mm", - top: "18mm", - }, - printBackground: true, - }); - - return { - bytes, - filename: withPdfExtension(filename), - }; - } finally { - await page.close(); - await browser.close(); - } -}; - -export { renderMarkdownPdf }; -export type { - PdfBrowser, - PdfPage, - RenderMarkdownPdfOptions, - RenderMarkdownPdfResult, -}; diff --git a/package.json b/package.json index 9bc67d9..f774b52 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", - "@sparticuz/chromium-min": "^148.0.0", "@tanstack/react-query": "^5.96.2", "@tanstack/react-virtual": "^3.13.23", "@zumer/snapdom": "^2.9.0", @@ -61,7 +60,6 @@ "next-intl": "^4.9.0", "next-themes": "^0.4.6", "pdf-lib": "^1.17.1", - "puppeteer-core": "^24.42.0", "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 723f9f3..f50bce1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,9 +62,6 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@sparticuz/chromium-min': - specifier: ^148.0.0 - version: 148.0.0 '@tanstack/react-query': specifier: ^5.96.2 version: 5.96.2(react@19.2.3) @@ -128,9 +125,6 @@ importers: pdf-lib: specifier: ^1.17.1 version: 1.17.1 - puppeteer-core: - specifier: ^24.42.0 - version: 24.42.0 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.0(@types/react@19.2.0))(@types/react@19.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2330,11 +2324,6 @@ packages: peerDependencies: '@opentelemetry/api': ^1.8 - '@puppeteer/browsers@2.13.0': - resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==} - engines: {node: '>=18'} - hasBin: true - '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -3537,10 +3526,6 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} - '@sparticuz/chromium-min@148.0.0': - resolution: {integrity: sha512-3RW2gPJrOIutXdTtwLDuIFOZwKKq4YU2zwt73U75QmgYaDVSYfSC1xLksou71sAy1B/MgZhLSmU+wCZDLW4MFg==} - engines: {node: '>=22.17.0'} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3799,9 +3784,6 @@ packages: '@types/react-dom': optional: true - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@triplit/client@1.0.50': resolution: {integrity: sha512-3vjXTSdDQ3fzLDrewCK7elkAQc7CiDg0eZEOZInQbVMFRiakdieO5C2voSnNjSepIYHxDxFSBllgg32QsNpL9Q==} engines: {node: '>=18.0.0'} @@ -4061,9 +4043,6 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -4304,10 +4283,6 @@ packages: resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} engines: {node: '>=12.0.0'} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -4325,14 +4300,6 @@ packages: axios@1.13.6: resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} - b4a@1.8.1: - resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} - peerDependencies: - react-native-b4a: '*' - peerDependenciesMeta: - react-native-b4a: - optional: true - babel-jest@30.2.0: resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4368,55 +4335,10 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - bare-events@2.8.2: - resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} - peerDependencies: - bare-abort-controller: '*' - peerDependenciesMeta: - bare-abort-controller: - optional: true - - bare-fs@4.7.1: - resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} - engines: {bare: '>=1.16.0'} - peerDependencies: - bare-buffer: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - - bare-os@3.9.1: - resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} - engines: {bare: '>=1.14.0'} - - bare-path@3.0.0: - resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} - - bare-stream@2.13.1: - resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} - peerDependencies: - bare-abort-controller: '*' - bare-buffer: '*' - bare-events: '*' - peerDependenciesMeta: - bare-abort-controller: - optional: true - bare-buffer: - optional: true - bare-events: - optional: true - - bare-url@2.4.2: - resolution: {integrity: sha512-/9a2j4ac6ckpmAHvod/ob7x439OAHst/drc2Clnq+reRYd/ovddwcF4LfoxHyNk5AuGBnPg+HqFjmE/Zpq6v0A==} - baseline-browser-mapping@2.9.11: resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true - basic-ftp@5.3.1: - resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} - engines: {node: '>=10.0.0'} - better-auth@1.4.6: resolution: {integrity: sha512-5wEBzjolrQA26b4uT6FVVYICsE3SmE/MzrZtl8cb2a3TJtswpP8v3OVV5yTso+ef9z85swgZk0/qBzcULFWVtA==} peerDependencies: @@ -4493,9 +4415,6 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -4574,11 +4493,6 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} - chromium-bidi@14.0.0: - resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==} - peerDependencies: - devtools-protocol: '*' - ci-info@4.3.1: resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} engines: {node: '>=8'} @@ -4929,10 +4843,6 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -4982,10 +4892,6 @@ packages: defu@6.1.6: resolution: {integrity: sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - delaunator@5.1.0: resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} @@ -5018,9 +4924,6 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - devtools-protocol@0.0.1595872: - resolution: {integrity: sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==} - dexie@4.4.2: resolution: {integrity: sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==} @@ -5213,9 +5116,6 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -5285,27 +5185,14 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -5313,9 +5200,6 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - events-universal@1.0.1: - resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} - eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -5357,11 +5241,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - fast-content-type-parse@1.1.0: resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} @@ -5371,9 +5250,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -5417,9 +5293,6 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -5560,10 +5433,6 @@ packages: resolution: {integrity: sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==} engines: {node: '>=10'} - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -5575,10 +5444,6 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - get-uri@6.0.5: - resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} - engines: {node: '>= 14'} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5782,10 +5647,6 @@ packages: resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} engines: {node: '>= 12'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -6308,10 +6169,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - lucide-react@0.555.0: resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==} peerDependencies: @@ -6567,9 +6424,6 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - mitt@3.0.1: - resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -6641,10 +6495,6 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - netmask@2.1.1: - resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} - engines: {node: '>= 0.4.0'} - next-intl-swc-plugin-extractor@4.9.0: resolution: {integrity: sha512-CAu6Qy6XiCenKsvzyCPm2cZFkGfcvhJi8N93TCnOowmzD4Br3ked7QdROusRRp4MQ1iG9u+KCLgVcM9CLDUOIQ==} @@ -6796,14 +6646,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - pac-proxy-agent@7.2.0: - resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -6890,9 +6732,6 @@ packages: peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} @@ -7060,10 +6899,6 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - progress@2.0.3: - resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} - engines: {node: '>=0.4.0'} - prom-client@14.2.0: resolution: {integrity: sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==} engines: {node: '>=10'} @@ -7082,24 +6917,13 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-agent@6.5.0: - resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} - engines: {node: '>= 14'} - proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - puppeteer-core@24.42.0: - resolution: {integrity: sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg==} - engines: {node: '>=18'} - pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} @@ -7385,11 +7209,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -7457,18 +7276,6 @@ packages: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.8: - resolution: {integrity: sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -7525,9 +7332,6 @@ packages: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} - streamx@2.25.0: - resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} - strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -7664,12 +7468,6 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - tar-fs@3.1.2: - resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} - - tar-stream@3.2.0: - resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} - tar@7.5.1: resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==} engines: {node: '>=18'} @@ -7681,16 +7479,10 @@ packages: tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} - teex@1.0.1: - resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} - test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} - text-decoder@1.2.7: - resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} @@ -7811,9 +7603,6 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typed-query-selector@2.12.2: - resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -7994,9 +7783,6 @@ packages: web-worker@1.5.0: resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} - webdriver-bidi-protocol@0.4.1: - resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} - webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -8110,9 +7896,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -10579,21 +10362,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@puppeteer/browsers@2.13.0': - dependencies: - debug: 4.4.3 - extract-zip: 2.0.1 - progress: 2.0.3 - proxy-agent: 6.5.0 - semver: 7.7.4 - tar-fs: 3.1.2 - yargs: 17.7.2 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - supports-color - '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -11963,14 +11731,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@sparticuz/chromium-min@148.0.0': - dependencies: - tar-fs: 3.1.2 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -12203,8 +11963,6 @@ snapshots: '@types/react': 19.2.0 '@types/react-dom': 19.2.0(@types/react@19.2.0) - '@tootallnate/quickjs-emscripten@0.23.0': {} - '@triplit/client@1.0.50(typescript@5.9.3)': dependencies: '@triplit/db': 1.1.10(typescript@5.9.3) @@ -12513,11 +12271,6 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 20.19.19 - optional: true - '@ungap/structured-clone@1.3.0': {} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -12691,10 +12444,6 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 - ast-types@0.13.4: - dependencies: - tslib: 2.8.1 - ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -12716,8 +12465,6 @@ snapshots: transitivePeerDependencies: - debug - b4a@1.8.1: {} - babel-jest@30.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -12776,42 +12523,8 @@ snapshots: balanced-match@4.0.4: {} - bare-events@2.8.2: {} - - bare-fs@4.7.1: - dependencies: - bare-events: 2.8.2 - bare-path: 3.0.0 - bare-stream: 2.13.1(bare-events@2.8.2) - bare-url: 2.4.2 - fast-fifo: 1.3.2 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - - bare-os@3.9.1: {} - - bare-path@3.0.0: - dependencies: - bare-os: 3.9.1 - - bare-stream@2.13.1(bare-events@2.8.2): - dependencies: - streamx: 2.25.0 - teex: 1.0.1 - optionalDependencies: - bare-events: 2.8.2 - transitivePeerDependencies: - - react-native-b4a - - bare-url@2.4.2: - dependencies: - bare-path: 3.0.0 - baseline-browser-mapping@2.9.11: {} - basic-ftp@5.3.1: {} - better-auth@1.4.6(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0) @@ -12890,8 +12603,6 @@ snapshots: dependencies: node-int64: 0.4.0 - buffer-crc32@0.2.13: {} - buffer-from@1.1.2: {} bundle-name@4.1.0: @@ -12957,12 +12668,6 @@ snapshots: chownr@3.0.0: {} - chromium-bidi@14.0.0(devtools-protocol@0.0.1595872): - dependencies: - devtools-protocol: 0.0.1595872 - mitt: 3.0.1 - zod: 3.25.76 - ci-info@4.3.1: {} cjs-module-lexer@1.4.3: {} @@ -13299,8 +13004,6 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-uri-to-buffer@6.0.2: {} - data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -13333,12 +13036,6 @@ snapshots: defu@6.1.6: {} - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - delaunator@5.1.0: dependencies: robust-predicates: 3.0.3 @@ -13361,8 +13058,6 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1595872: {} - dexie@4.4.2: {} diff@4.0.4: {} @@ -13476,10 +13171,6 @@ snapshots: encodeurl@2.0.0: {} - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -13603,32 +13294,14 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - esprima@4.0.1: {} - estraverse@5.3.0: {} - estree-util-is-identifier-name@3.0.0: {} - esutils@2.0.3: {} - etag@1.8.1: {} eventemitter3@5.0.1: {} - events-universal@1.0.1: - dependencies: - bare-events: 2.8.2 - transitivePeerDependencies: - - bare-abort-controller - eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -13717,24 +13390,12 @@ snapshots: extend@3.0.2: {} - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - fast-content-type-parse@1.1.0: {} fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} - fast-fifo@1.3.2: {} - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -13808,10 +13469,6 @@ snapshots: dependencies: bser: 2.1.1 - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -13936,10 +13593,6 @@ snapshots: get-stdin@8.0.0: {} - get-stream@5.2.0: - dependencies: - pump: 3.0.4 - get-stream@6.0.1: {} get-stream@9.0.1: @@ -13951,14 +13604,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.5: - dependencies: - basic-ftp: 5.3.1 - data-uri-to-buffer: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -14238,8 +13883,6 @@ snapshots: ip-address@10.0.1: {} - ip-address@10.2.0: {} - ipaddr.js@1.9.1: {} is-alphabetical@2.0.1: {} @@ -14903,8 +14546,6 @@ snapshots: dependencies: yallist: 3.1.1 - lru-cache@7.18.3: {} - lucide-react@0.555.0(react@19.2.3): dependencies: react: 19.2.3 @@ -15380,8 +15021,6 @@ snapshots: dependencies: minipass: 7.1.2 - mitt@3.0.1: {} - mlly@1.8.2: dependencies: acorn: 8.16.0 @@ -15451,8 +15090,6 @@ snapshots: negotiator@1.0.0: {} - netmask@2.1.1: {} - next-intl-swc-plugin-extractor@4.9.0: {} next-intl@4.9.0(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(typescript@5.9.3): @@ -15609,24 +15246,6 @@ snapshots: p-try@2.2.0: {} - pac-proxy-agent@7.2.0: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.4 - debug: 4.4.3 - get-uri: 6.0.5 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.1.1 - package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -15708,8 +15327,6 @@ snapshots: peberminta@0.9.0: {} - pend@1.2.0: {} - pg-cloudflare@1.3.0: optional: true @@ -15871,8 +15488,6 @@ snapshots: process-warning@5.0.0: {} - progress@2.0.3: {} - prom-client@14.2.0: dependencies: tdigest: 0.1.2 @@ -15895,45 +15510,10 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-agent@6.5.0: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - proxy-from-env@1.1.0: {} - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - punycode@2.3.1: {} - puppeteer-core@24.42.0: - dependencies: - '@puppeteer/browsers': 2.13.0 - chromium-bidi: 14.0.0(devtools-protocol@0.0.1595872) - debug: 4.4.3 - devtools-protocol: 0.0.1595872 - typed-query-selector: 2.12.2 - webdriver-bidi-protocol: 0.4.1 - ws: 8.20.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - bufferutil - - react-native-b4a - - supports-color - - utf-8-validate - pure-rand@7.0.1: {} pvtsutils@1.3.6: @@ -16292,8 +15872,6 @@ snapshots: semver@7.7.3: {} - semver@7.7.4: {} - send@1.2.1: dependencies: debug: 4.4.3 @@ -16448,21 +16026,6 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - smart-buffer@4.2.0: {} - - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.8 - transitivePeerDependencies: - - supports-color - - socks@2.8.8: - dependencies: - ip-address: 10.2.0 - smart-buffer: 4.2.0 - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -16512,15 +16075,6 @@ snapshots: stdin-discarder@0.2.2: {} - streamx@2.25.0: - dependencies: - events-universal: 1.0.1 - fast-fifo: 1.3.2 - text-decoder: 1.2.7 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - strict-event-emitter@0.5.1: {} string-argv@0.3.2: {} @@ -16641,29 +16195,6 @@ snapshots: tapable@2.3.0: {} - tar-fs@3.1.2: - dependencies: - pump: 3.0.4 - tar-stream: 3.2.0 - optionalDependencies: - bare-fs: 4.7.1 - bare-path: 3.0.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - tar-stream@3.2.0: - dependencies: - b4a: 1.8.1 - bare-fs: 4.7.1 - fast-fifo: 1.3.2 - streamx: 2.25.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - tar@7.5.1: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -16684,25 +16215,12 @@ snapshots: dependencies: bintrees: 1.0.2 - teex@1.0.1: - dependencies: - streamx: 2.25.0 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.3 glob: 7.2.3 minimatch: 3.1.2 - text-decoder@1.2.7: - dependencies: - b4a: 1.8.1 - transitivePeerDependencies: - - react-native-b4a - thread-stream@3.1.0: dependencies: real-require: 0.2.0 @@ -16815,8 +16333,6 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typed-query-selector@2.12.2: {} - typescript@5.9.3: {} ua-is-frozen@0.1.2: {} @@ -17020,8 +16536,6 @@ snapshots: web-worker@1.5.0: {} - webdriver-bidi-protocol@0.4.1: {} - webidl-conversions@7.0.0: {} whatwg-encoding@3.1.1: @@ -17121,11 +16635,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yn@3.1.1: {} yocto-queue@0.1.0: {} From b6f4fbe8372231bb9265659d6ef62e405538646f Mon Sep 17 00:00:00 2001 From: Kerwin Zheng <1747269691@qq.com> Date: Sun, 3 May 2026 08:54:50 +0800 Subject: [PATCH 11/11] fix(test): remove undici polyfill from jest setup - delete the undici-based Headers/Request/Response polyfill that was crashing in CI - keep the stream, encoder, and worker polyfills that the test environment still needs - replace the default fetch mock headers with a lightweight get() stub so tests stay decoupled from undici internals Verification: - pnpm test -- hooks/use-chat.test.ts lib/chat/workspace-hydration.test.ts app/chat/components/markdown-preview-panel.test.tsx - pnpm test - pnpm exec biome check jest.setup.ts Co-authored-by: Codex --- jest.setup.ts | 27 +++------------------------ 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/jest.setup.ts b/jest.setup.ts index dc3feb7..f9fcdd3 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -74,7 +74,9 @@ if (typeof globalThis.fetch === "undefined") { value: jest.fn(async () => ({ ok: true, status: 200, - headers: new Headers(), + headers: { + get: () => null, + }, json: async () => ({}), text: async () => "", })), @@ -109,26 +111,3 @@ if (typeof globalThis.MessagePort === "undefined") { writable: true, }); } - -const { Headers, Request, Response } = require("undici"); - -if (typeof globalThis.Headers === "undefined") { - Object.defineProperty(globalThis, "Headers", { - value: Headers, - writable: true, - }); -} - -if (typeof globalThis.Request === "undefined") { - Object.defineProperty(globalThis, "Request", { - value: Request, - writable: true, - }); -} - -if (typeof globalThis.Response === "undefined") { - Object.defineProperty(globalThis, "Response", { - value: Response, - writable: true, - }); -}