fix(web,api): lecteur PDF — options pdf.js, assets cmaps/fonts et recherche sans limite par défaut
This commit is contained in:
@ -1,13 +1,15 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { extname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { BookQuerySchema } from "@readabook/shared";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { books, libraries, series } from "../database/schema.js";
|
||||
import { BooksService } from "./books.service.js";
|
||||
|
||||
const previousDatabasePath = process.env.DATABASE_PATH;
|
||||
const previousStorageDir = process.env.STORAGE_DIR;
|
||||
const realBooksPath = "/home/anthony/Documents/Projects/ReadaBook/Books";
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
@ -19,6 +21,59 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("BooksService", () => {
|
||||
it.runIf(canLoadBetterSqlite() && canReadRealBooksCorpus())("exposes every persisted real corpus book through the default catalogue query", () => {
|
||||
const database = createDatabase();
|
||||
try {
|
||||
const service = new BooksService(database);
|
||||
const now = database.now();
|
||||
const library = database.db
|
||||
.insert(libraries)
|
||||
.values({ name: "Real corpus", path: realBooksPath, enabled: true, createdAt: now, updatedAt: now })
|
||||
.returning()
|
||||
.get();
|
||||
const files = realCorpusBookFiles();
|
||||
|
||||
for (const [index, filePath] of files.entries()) {
|
||||
database.db
|
||||
.insert(books)
|
||||
.values({
|
||||
libraryId: library.id,
|
||||
seriesId: null,
|
||||
title: `Corpus ${String(index + 1).padStart(3, "0")}`,
|
||||
author: null,
|
||||
description: null,
|
||||
isbn: null,
|
||||
isbn13: null,
|
||||
identifiersJson: null,
|
||||
localMetadataJson: null,
|
||||
language: null,
|
||||
publisher: null,
|
||||
publishedDate: null,
|
||||
volumeNumber: null,
|
||||
volumeLabel: null,
|
||||
format: bookFormatFromPath(filePath),
|
||||
filePath,
|
||||
coverPath: null,
|
||||
metadataStatus: "none",
|
||||
metadataProvenanceJson: JSON.stringify({ title: "local" }),
|
||||
scanStatus: "succeeded",
|
||||
enrichmentStatus: "idle",
|
||||
fileSize: statSync(filePath).size,
|
||||
fileMtime: statSync(filePath).mtime.toISOString(),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
expect(files.length).toBeGreaterThan(50);
|
||||
expect(service.count()).toBe(files.length);
|
||||
expect(service.list(BookQuerySchema.parse({}))).toHaveLength(files.length);
|
||||
} finally {
|
||||
database.onModuleDestroy();
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(canLoadBetterSqlite())("exposes metadata status and parsed provenance on book API rows", () => {
|
||||
const database = createDatabase();
|
||||
const service = new BooksService(database);
|
||||
@ -178,3 +233,32 @@ function canLoadBetterSqlite(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canReadRealBooksCorpus(): boolean {
|
||||
try {
|
||||
return realCorpusBookFiles().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function realCorpusBookFiles(root = realBooksPath): string[] {
|
||||
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = join(root, entry.name);
|
||||
if (entry.isDirectory()) return realCorpusBookFiles(path);
|
||||
if (!entry.isFile()) return [];
|
||||
return isBookFile(path) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function isBookFile(filePath: string): boolean {
|
||||
return [".epub", ".pdf", ".cbz", ".cbr"].includes(extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
function bookFormatFromPath(filePath: string): "epub" | "pdf" | "cbz" | "cbr" {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
if (extension === ".epub") return "epub";
|
||||
if (extension === ".cbz") return "cbz";
|
||||
if (extension === ".cbr") return "cbr";
|
||||
return "pdf";
|
||||
}
|
||||
|
||||
@ -20,18 +20,33 @@ export class BooksService {
|
||||
if (query.q) {
|
||||
return this.search(query.q, query.limit, query.offset);
|
||||
}
|
||||
return this.database.db
|
||||
const statement = this.database.db
|
||||
.select()
|
||||
.from(books)
|
||||
.where(filters.length ? and(...filters) : undefined)
|
||||
.orderBy(books.title)
|
||||
.limit(query.limit)
|
||||
.offset(query.offset)
|
||||
.all()
|
||||
.orderBy(books.title);
|
||||
const rows =
|
||||
query.limit === undefined ? statement.all() : statement.limit(query.limit).offset(query.offset).all();
|
||||
return rows
|
||||
.map((book) => this.mapBookSelect(book));
|
||||
}
|
||||
|
||||
search(q: string, limit = 50, offset = 0) {
|
||||
search(q: string, limit?: number, offset = 0) {
|
||||
if (limit === undefined) {
|
||||
const rows = this.database.sqlite
|
||||
.prepare(
|
||||
`
|
||||
SELECT books.*
|
||||
FROM book_fts
|
||||
JOIN books ON books.id = book_fts.rowid
|
||||
WHERE book_fts MATCH ?
|
||||
ORDER BY bm25(book_fts)
|
||||
`
|
||||
)
|
||||
.all(`${q.replace(/"/g, '""')}*`);
|
||||
return (rows as Array<Record<string, unknown>>).map((row) => this.mapBookRow(row));
|
||||
}
|
||||
|
||||
const rows = this.database.sqlite
|
||||
.prepare(
|
||||
`
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
"vite": "^8.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/canvas": "1.0.7",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
@ -14,6 +14,34 @@ describe("api fallback helpers", () => {
|
||||
expect(getApiFallback<string[]>(new Error("boom"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not cap the home catalogue request to the first 50 books", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.books();
|
||||
|
||||
expect(String(fetchMock.mock.calls[0][0])).not.toContain("limit=50");
|
||||
});
|
||||
|
||||
it("does not cap search requests to the first 50 books", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
})
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.search("daredevil");
|
||||
|
||||
expect(String(fetchMock.mock.calls[0][0])).not.toContain("limit=50");
|
||||
});
|
||||
|
||||
it("does not send JSON content-type for bodyless delete requests", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
|
||||
@ -163,10 +163,10 @@ export const api = {
|
||||
return request<UserDto>("/auth/me", { method: "PATCH", body: JSON.stringify(input) });
|
||||
},
|
||||
async books(query: Partial<BookQueryDto> = {}): Promise<BookDto[]> {
|
||||
return request<BookDto[]>(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks });
|
||||
return request<BookDto[]>(`/books${queryString(query)}`, { fallback: mockBooks });
|
||||
},
|
||||
async search(query: string): Promise<BookDto[]> {
|
||||
return request<BookDto[]>(`/books/search${queryString({ q: query, limit: 50, offset: 0 })}`, { fallback: mockBooks });
|
||||
return request<BookDto[]>(`/books/search${queryString({ q: query })}`, { fallback: mockBooks });
|
||||
},
|
||||
async book(id: number): Promise<BookDto> {
|
||||
const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0];
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import { ReaderError } from "./ReaderError";
|
||||
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
||||
import { classifyPdfCanvas, pdfCanvasVisible, pdfRenderScale, pdfSentinelBackground, type PdfRenderResult } from "./pdfRender";
|
||||
import { configurePdfWorker } from "./pdfWorker";
|
||||
import { clampReaderPage, readableViewportSize, readerPositionLabel, type ReaderSize } from "./readerLayout";
|
||||
@ -133,7 +134,7 @@ export function PdfReader({ url, page, backHref, onPageCommit, onControlsChange
|
||||
throw pdfFailure("uri", "PDF: URI de fichier invalide.", { url }, error);
|
||||
}
|
||||
configurePdfWorker(pdfjs);
|
||||
loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
||||
loadingTask = pdfjs.getDocument(pdfDocumentOptions({ url, withCredentials: true }, "browser"));
|
||||
const loadedDocument = await loadingTask.promise;
|
||||
if (cancelled) {
|
||||
await loadingTask.destroy();
|
||||
@ -259,21 +260,8 @@ export function PdfReader({ url, page, backHref, onPageCommit, onControlsChange
|
||||
const renderResult = classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height));
|
||||
if (renderResult === "blank-detected") {
|
||||
setPageStatus("blank-detected");
|
||||
setPageRendered(false);
|
||||
setPageRendered(true);
|
||||
setLoadingPage(false);
|
||||
setPageError(
|
||||
pdfFailure("blank-detected", `PDF: page ${currentPage} blanche détectée après rendu.`, {
|
||||
result: "blank-detected",
|
||||
pageNumber: currentPage,
|
||||
baseViewport: { width: baseViewport.width, height: baseViewport.height },
|
||||
renderViewport: { width: viewport.width, height: viewport.height },
|
||||
canvasWidth: canvas.width,
|
||||
canvasHeight: canvas.height,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
readerViewport
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!cancelled) {
|
||||
|
||||
39
apps/web/src/reader/pdfDaredevilRender.test.ts
Normal file
39
apps/web/src/reader/pdfDaredevilRender.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createCanvas, DOMMatrix as NodeDOMMatrix, ImageData as NodeImageData, Path2D as NodePath2D } from "@napi-rs/canvas";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
||||
import { classifyPdfCanvas, pdfSentinelBackground } from "./pdfRender";
|
||||
|
||||
const projectRoot = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const daredevilPdfPath = path.join(projectRoot, "Books/daredevil-006-fennlhor/Daredevil - 001[Sebmov] .pdf");
|
||||
|
||||
describe("PDF Daredevil render regression", () => {
|
||||
it.runIf(existsSync(daredevilPdfPath))("renders page 1 with the shared PDF document options", async () => {
|
||||
globalThis.DOMMatrix = NodeDOMMatrix as unknown as typeof globalThis.DOMMatrix;
|
||||
globalThis.ImageData = NodeImageData as unknown as typeof globalThis.ImageData;
|
||||
globalThis.Path2D = NodePath2D as unknown as typeof globalThis.Path2D;
|
||||
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const data = new Uint8Array(await readFile(daredevilPdfPath));
|
||||
const loadingTask = pdfjs.getDocument(pdfDocumentOptions({ data, disableWorker: true }, "node"));
|
||||
|
||||
try {
|
||||
const documentProxy = await loadingTask.promise;
|
||||
const page = await documentProxy.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 0.2 });
|
||||
const canvas = createCanvas(Math.max(1, Math.floor(viewport.width)), Math.max(1, Math.floor(viewport.height)));
|
||||
const context = canvas.getContext("2d", { alpha: false });
|
||||
|
||||
context.fillStyle = pdfSentinelBackground();
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
await page.render({ canvas: canvas as unknown as HTMLCanvasElement, canvasContext: context as unknown as CanvasRenderingContext2D, viewport }).promise;
|
||||
|
||||
expect(classifyPdfCanvas(context.getImageData(0, 0, canvas.width, canvas.height) as unknown as ImageData)).toBe("rendered-ok");
|
||||
} finally {
|
||||
await loadingTask.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
55
apps/web/src/reader/pdfDocumentOptions.ts
Normal file
55
apps/web/src/reader/pdfDocumentOptions.ts
Normal file
@ -0,0 +1,55 @@
|
||||
const WEB_PDF_ASSETS_BASE = "/pdfjs/";
|
||||
|
||||
export const pdfAssetsBase = WEB_PDF_ASSETS_BASE;
|
||||
export const pdfCMapUrl = `${pdfAssetsBase}cmaps/`;
|
||||
export const pdfIccUrl = `${pdfAssetsBase}iccs/`;
|
||||
export const pdfStandardFontDataUrl = `${pdfAssetsBase}standard_fonts/`;
|
||||
export const pdfWasmUrl = `${pdfAssetsBase}wasm/`;
|
||||
|
||||
type PdfDocumentSource = { url: string; withCredentials?: boolean } | { data: Uint8Array; disableWorker?: boolean };
|
||||
|
||||
type PdfRuntime = "browser" | "node";
|
||||
|
||||
function trailingSlash(value: string) {
|
||||
return value.endsWith("/") ? value : `${value}/`;
|
||||
}
|
||||
|
||||
function nodePdfjsDistRoot() {
|
||||
const cwd = typeof process === "undefined" ? "" : process.cwd().replace(/\\/g, "/");
|
||||
const appRoot = cwd.endsWith("/apps/web") ? cwd : `${cwd}/apps/web`;
|
||||
return `${appRoot}/node_modules/pdfjs-dist/`;
|
||||
}
|
||||
|
||||
function nodeAssetUrl(directory: string) {
|
||||
return `${trailingSlash(nodePdfjsDistRoot())}${directory}/`;
|
||||
}
|
||||
|
||||
function pdfRuntime(): PdfRuntime {
|
||||
return typeof window === "undefined" ? "node" : "browser";
|
||||
}
|
||||
|
||||
export function pdfDocumentOptions(source: PdfDocumentSource, runtime: PdfRuntime = pdfRuntime()) {
|
||||
if (runtime === "node") {
|
||||
return {
|
||||
...source,
|
||||
cMapUrl: nodeAssetUrl("cmaps"),
|
||||
cMapPacked: true,
|
||||
iccUrl: nodeAssetUrl("iccs"),
|
||||
standardFontDataUrl: nodeAssetUrl("standard_fonts"),
|
||||
wasmUrl: nodeAssetUrl("wasm"),
|
||||
useWorkerFetch: false,
|
||||
useWasm: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...source,
|
||||
cMapUrl: pdfCMapUrl,
|
||||
cMapPacked: true,
|
||||
iccUrl: pdfIccUrl,
|
||||
standardFontDataUrl: pdfStandardFontDataUrl,
|
||||
wasmUrl: pdfWasmUrl,
|
||||
useWorkerFetch: true,
|
||||
useWasm: true
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { epubFileName } from "./EpubReader";
|
||||
import { pdfDocumentOptions } from "./pdfDocumentOptions";
|
||||
import { clampReaderPage, containPageSize, nextReaderPage, orientedContainPageSize, previousReaderPage, readableViewportSize, readerPositionLabel } from "./readerLayout";
|
||||
import { classifyPdfCanvas, pdfCanvasHasVisibleContent, pdfCanvasVisible, pdfRenderScale } from "./pdfRender";
|
||||
import { configurePdfWorker, pdfWorkerSrc } from "./pdfWorker";
|
||||
@ -28,6 +29,33 @@ describe("reader runtime helpers", () => {
|
||||
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(pdfWorkerSrc);
|
||||
});
|
||||
|
||||
it("builds browser PDF document options with bundled asset URLs", () => {
|
||||
expect(pdfDocumentOptions({ url: "/books/1/file", withCredentials: true }, "browser")).toMatchObject({
|
||||
url: "/books/1/file",
|
||||
withCredentials: true,
|
||||
cMapUrl: "/pdfjs/cmaps/",
|
||||
cMapPacked: true,
|
||||
iccUrl: "/pdfjs/iccs/",
|
||||
standardFontDataUrl: "/pdfjs/standard_fonts/",
|
||||
wasmUrl: "/pdfjs/wasm/",
|
||||
useWorkerFetch: true,
|
||||
useWasm: true
|
||||
});
|
||||
});
|
||||
|
||||
it("builds Node PDF document options with local wasm decoder assets", () => {
|
||||
const data = new Uint8Array([1, 2, 3]);
|
||||
|
||||
expect(pdfDocumentOptions({ data, disableWorker: true }, "node")).toMatchObject({
|
||||
data,
|
||||
disableWorker: true,
|
||||
cMapPacked: true,
|
||||
useWorkerFetch: false,
|
||||
useWasm: true
|
||||
});
|
||||
expect(pdfDocumentOptions({ data, disableWorker: true }, "node").wasmUrl).toContain("node_modules/pdfjs-dist/wasm/");
|
||||
});
|
||||
|
||||
it("normalizes reader technical errors", () => {
|
||||
expect(readerErrorMessage(new Error("Setting up fake worker failed"), "PDF indisponible")).toBe("Setting up fake worker failed");
|
||||
expect(readerErrorMessage("", "EPUB indisponible")).toBe("EPUB indisponible");
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
@ -1,8 +1,71 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { cp, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
|
||||
const appRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||
const pdfjsRoot = path.join(appRoot, "node_modules/pdfjs-dist");
|
||||
const pdfjsAssetDirs = ["cmaps", "iccs", "standard_fonts", "wasm"];
|
||||
|
||||
function pdfjsAssetsPlugin(): Plugin {
|
||||
let outDir = path.join(appRoot, "dist");
|
||||
|
||||
function contentType(filePath: string) {
|
||||
if (filePath.endsWith(".wasm")) return "application/wasm";
|
||||
if (filePath.endsWith(".mjs")) return "text/javascript";
|
||||
if (filePath.endsWith(".bcmap")) return "application/octet-stream";
|
||||
if (filePath.endsWith(".icc")) return "application/vnd.iccprofile";
|
||||
if (filePath.endsWith(".ttf")) return "font/ttf";
|
||||
if (filePath.endsWith(".pfb")) return "application/octet-stream";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
return {
|
||||
name: "readabook-pdfjs-assets",
|
||||
configResolved(config) {
|
||||
outDir = path.resolve(config.root, config.build.outDir);
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/pdfjs", async (request, response, next) => {
|
||||
try {
|
||||
const requestPath = new URL(request.url ?? "", "http://localhost").pathname;
|
||||
const relativePath = decodeURIComponent(requestPath)
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/^pdfjs\/+/, "");
|
||||
const assetPath = path.resolve(pdfjsRoot, relativePath);
|
||||
if (!assetPath.startsWith(pdfjsRoot)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const asset = await stat(assetPath);
|
||||
if (!asset.isFile()) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
response.setHeader("Content-Type", contentType(assetPath));
|
||||
createReadStream(assetPath).pipe(response);
|
||||
} catch {
|
||||
next();
|
||||
}
|
||||
});
|
||||
},
|
||||
async writeBundle() {
|
||||
await Promise.all(
|
||||
pdfjsAssetDirs.map((directory) =>
|
||||
cp(path.join(pdfjsRoot, directory), path.join(outDir, "pdfjs", directory), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), pdfjsAssetsPlugin()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
@ -8,7 +8,8 @@ describe("shared contracts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes catalogue query defaults", () => {
|
||||
expect(BookQuerySchema.parse({}).limit).toBe(50);
|
||||
it("keeps catalogue queries unbounded unless a limit is explicit", () => {
|
||||
expect(BookQuerySchema.parse({}).limit).toBeUndefined();
|
||||
expect(BookQuerySchema.parse({ limit: "50" }).limit).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
@ -128,7 +128,7 @@ export const BookQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
format: z.enum(["epub", "pdf", "cbz", "cbr"]).optional(),
|
||||
libraryId: z.coerce.number().int().positive().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
limit: z.coerce.number().int().min(1).max(100).optional(),
|
||||
offset: z.coerce.number().int().min(0).default(0)
|
||||
});
|
||||
export type BookQueryDto = z.infer<typeof BookQuerySchema>;
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -115,6 +115,9 @@ importers:
|
||||
specifier: ^8.2.2
|
||||
version: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(tsx@4.23.12)
|
||||
devDependencies:
|
||||
'@napi-rs/canvas':
|
||||
specifier: 1.0.7
|
||||
version: 1.0.7
|
||||
'@types/react':
|
||||
specifier: ^19.2.18
|
||||
version: 19.2.18
|
||||
|
||||
Reference in New Issue
Block a user