diff --git a/.env.example b/.env.example
index 3753740..1215f00 100644
--- a/.env.example
+++ b/.env.example
@@ -18,4 +18,4 @@ 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"
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/markdown-preview-panel.test.tsx b/app/chat/components/markdown-preview-panel.test.tsx
new file mode 100644
index 0000000..f623a4d
--- /dev/null
+++ b/app/chat/components/markdown-preview-panel.test.tsx
@@ -0,0 +1,75 @@
+import jotaiStore, { workspaceMarkdownContentAtom } from "@/atoms";
+import { exportMarkdownFile, exportMarkdownPdf } from "@/lib/markdown-export";
+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(async () => "refract-markdown.pdf"),
+}));
+
+jest.mock("react-markdown", () => ({
+ __esModule: true,
+ default: ({ children }: { children: string }) =>
{children}
,
+}));
+
+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("downloads the rendered markdown preview as a pdf", async () => {
+ render();
+
+ fireEvent.keyDown(screen.getByRole("button", { name: /export/i }), {
+ key: "ArrowDown",
+ });
+ fireEvent.click(await screen.findByRole("menuitem", { name: /pdf/i }));
+
+ 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 7843402..a1f4be9 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, useState } from "react";
import Markdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import rehypeRaw from "rehype-raw";
@@ -50,22 +59,80 @@ 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(async () => {
+ if (isExportingPdf || !hasMarkdownContent || !previewRef.current) {
+ return;
+ }
+
+ setIsExportingPdf(true);
+
+ try {
+ await exportMarkdownPdf({
+ sourceElement: previewRef.current,
+ });
+ } finally {
+ setIsExportingPdf(false);
+ }
+ }, [hasMarkdownContent, isExportingPdf]);
return (
-
-
-
+
+
+
+
+
+
+
+
+ {isExportingPdf ? "PDF..." : "PDF"}
+
+
+
+ Markdown
+
+
+
+
+
+
- {markdownContent}
-
-
-
+
+ {markdownContent}
+
+
+
+
);
};
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/"],
diff --git a/jest.setup.ts b/jest.setup.ts
index b6cc22d..f9fcdd3 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,
});
}
@@ -82,10 +74,40 @@ if (typeof globalThis.fetch === "undefined") {
value: jest.fn(async () => ({
ok: true,
status: 200,
- headers: new Headers(),
+ headers: {
+ get: () => null,
+ },
json: async () => ({}),
text: async () => "",
})),
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,
+ });
+}
diff --git a/lib/markdown-export.test.ts b/lib/markdown-export.test.ts
new file mode 100644
index 0000000..eace7ff
--- /dev/null
+++ b/lib/markdown-export.test.ts
@@ -0,0 +1,188 @@
+import {
+ createMarkdownBlob,
+ exportMarkdownFile,
+ exportMarkdownPdf,
+ 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("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,
+ },
+ });
+
+ expect(filename).toBe("analysis-report.md");
+ expect(createObjectURL).toHaveBeenCalled();
+ expect(anchor.download).toBe("analysis-report.md");
+ expect(clickSpy).toHaveBeenCalled();
+ expect(revokeObjectURL).toHaveBeenCalledWith("blob:markdown");
+
+ createElementSpy.mockRestore();
+ clickSpy.mockRestore();
+ });
+
+ 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")
+ .mockImplementation(((tagName: string) =>
+ tagName === "a"
+ ? anchor
+ : originalCreateElement(tagName)) as typeof document.createElement);
+ 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,
+ filename: "analysis-report",
+ sourceElement,
+ url: {
+ createObjectURL,
+ revokeObjectURL,
+ },
+ });
+
+ expect(captureElement).toHaveBeenCalledWith(
+ sourceElement,
+ expect.objectContaining({
+ 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("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({
+ captureElement,
+ filename: "analysis-report",
+ sourceElement,
+ }),
+ ).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
new file mode 100644
index 0000000..ec54ddd
--- /dev/null
+++ b/lib/markdown-export.ts
@@ -0,0 +1,265 @@
+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";
+
+type DownloadBlobOptions = {
+ document?: Document;
+ url?: Pick;
+};
+
+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 & {
+ captureElement?: SnapdomCaptureElement;
+ createPdfDocument?: () => Promise;
+ filename?: string;
+ sourceElement: HTMLElement;
+};
+
+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 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());
+ }
+
+ 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));
+
+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,
+ });
+ }
+};
+
+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,
+ filename,
+ sourceElement,
+ url,
+}: MarkdownPdfExportOptions) => {
+ if (!hasExportableElementContent(sourceElement)) {
+ return false;
+ }
+
+ const capture = await captureElement(sourceElement, {
+ backgroundColor: "#ffffff",
+ embedFonts: true,
+ fast: false,
+ scale: SNAPDOM_PDF_SCALE,
+ });
+ const canvas = await capture.toCanvas();
+ const pdfDocument = await createPdfDocument();
+
+ await addCanvasPagesToPdf(canvas, pdfDocument);
+
+ const pdfBytes = await pdfDocument.save();
+ const normalizedPdfBytes = new Uint8Array(pdfBytes);
+ const resolvedFilename = withFileExtension(
+ filename ?? MARKDOWN_EXPORT_BASENAME,
+ "pdf",
+ );
+
+ downloadBlob(
+ new Blob([normalizedPdfBytes], { type: "application/pdf" }),
+ resolvedFilename,
+ {
+ document: targetDocument,
+ url,
+ },
+ );
+
+ return resolvedFilename;
+};
+
+export {
+ createMarkdownBlob,
+ exportMarkdownFile,
+ exportMarkdownPdf,
+ withFileExtension,
+};
+export type {
+ MarkdownFileExportOptions,
+ MarkdownPdfExportOptions,
+ SnapdomCaptureElement,
+ SnapdomPdfDocument,
+};
diff --git a/package.json b/package.json
index da9494d..f774b52 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",
@@ -41,6 +41,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@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",
@@ -58,6 +59,7 @@
"next": "16.1.1",
"next-intl": "^4.9.0",
"next-themes": "^0.4.6",
+ "pdf-lib": "^1.17.1",
"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 e3471ae..f50bce1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -68,6 +68,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)
@@ -119,6 +122,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
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)
@@ -2251,6 +2257,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==}
@@ -4154,6 +4166,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==}
@@ -6637,6 +6652,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'}
@@ -6708,6 +6726,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==}
@@ -10217,6 +10238,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
@@ -12318,6 +12347,8 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.0
+ '@zumer/snapdom@2.9.0': {}
+
abstract-logging@2.0.1: {}
accepts@2.0.0:
@@ -13937,7 +13968,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
@@ -14531,7 +14562,7 @@ snapshots:
make-dir@4.0.0:
dependencies:
- semver: 7.7.2
+ semver: 7.7.3
make-error@1.3.6: {}
@@ -15219,6 +15250,8 @@ snapshots:
package-manager-detector@1.6.0: {}
+ pako@1.0.11: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -15285,6 +15318,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: {}
pg-cloudflare@1.3.0: