diff --git a/messages/en.json b/messages/en.json index 8ffd105..ecb3a8c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -145,5 +145,15 @@ "update_reload": "Reload", "license_label_license": "License", - "license_label_author": "Author" + "license_label_author": "Author", + + "typst_status_loading_wasm": "Loading Typst engine…", + "typst_status_fetching_packages": "Fetching Typst package: {package}", + "typst_status_compiling": "Compiling Typst…", + "typst_error_no_main": "Could not find a Typst main file (main.typ)", + "typst_error_compile_failed": "Typst compile failed ({count} errors)", + "typst_error_runtime_init": "Failed to initialize Typst engine", + "typst_error_timeout": "Typst compilation timed out", + "typst_diag_severity_error": "error", + "typst_diag_severity_warning": "warning" } diff --git a/messages/ja.json b/messages/ja.json index 0290281..2d413be 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -145,5 +145,15 @@ "update_reload": "再読み込み", "license_label_license": "ライセンス", - "license_label_author": "作者" + "license_label_author": "作者", + + "typst_status_loading_wasm": "Typst エンジンを読み込み中…", + "typst_status_fetching_packages": "Typst パッケージを取得中: {package}", + "typst_status_compiling": "Typst をコンパイル中…", + "typst_error_no_main": "Typst の主ファイル (main.typ) が見つかりません", + "typst_error_compile_failed": "Typst のコンパイルに失敗しました ({count} 件のエラー)", + "typst_error_runtime_init": "Typst エンジンの初期化に失敗しました", + "typst_error_timeout": "Typst のコンパイルがタイムアウトしました", + "typst_diag_severity_error": "エラー", + "typst_diag_severity_warning": "警告" } diff --git a/package.json b/package.json index f70dd49..8f135c2 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "dependencies": { "@fontsource/geist-mono": "^5.2.7", "@fontsource/geist-sans": "^5.2.5", + "@myriaddreamin/typst-ts-web-compiler": "^0.6.0", + "@myriaddreamin/typst.ts": "^0.6.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49f351e..0c97311 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@fontsource/geist-sans': specifier: ^5.2.5 version: 5.2.5 + '@myriaddreamin/typst-ts-web-compiler': + specifier: ^0.6.0 + version: 0.6.0 + '@myriaddreamin/typst.ts': + specifier: ^0.6.0 + version: 0.6.0(@myriaddreamin/typst-ts-web-compiler@0.6.0) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1073,6 +1079,20 @@ packages: '@lix-js/server-protocol-schema@0.1.1': resolution: {integrity: sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==} + '@myriaddreamin/typst-ts-web-compiler@0.6.0': + resolution: {integrity: sha512-P/eIJ5RnfElj0NYzn5PI296t/IwWtgqUyyTMi5Jm5X3V5kZfskkH+LI7mSQe8tEyxwgCvxbxvFe5adinA3K8Gg==} + + '@myriaddreamin/typst.ts@0.6.0': + resolution: {integrity: sha512-IUpetG0NyF2H6eXRm4j+NsbanJHIvyrffHEijqYb6q128sLWQgT1FYJS+h7dtjXmrBEfnIl1mI80DyfDR6kB/w==} + peerDependencies: + '@myriaddreamin/typst-ts-renderer': ^0.6.0 + '@myriaddreamin/typst-ts-web-compiler': ^0.6.0 + peerDependenciesMeta: + '@myriaddreamin/typst-ts-renderer': + optional: true + '@myriaddreamin/typst-ts-web-compiler': + optional: true + '@napi-rs/canvas-android-arm64@0.1.99': resolution: {integrity: sha512-9OCRt8VVxA17m32NWZKyNC2qamdaS/SC5CEOIQwFngRq0DIeVm4PDal+6Ljnhqm2whZiC63DNuKZ4xSp2nbj9w==} engines: {node: '>= 10'} @@ -4907,6 +4927,14 @@ snapshots: '@lix-js/server-protocol-schema@0.1.1': {} + '@myriaddreamin/typst-ts-web-compiler@0.6.0': {} + + '@myriaddreamin/typst.ts@0.6.0(@myriaddreamin/typst-ts-web-compiler@0.6.0)': + dependencies: + idb: 7.1.1 + optionalDependencies: + '@myriaddreamin/typst-ts-web-compiler': 0.6.0 + '@napi-rs/canvas-android-arm64@0.1.99': optional: true diff --git a/src/components/TypstDiagnosticList.test.tsx b/src/components/TypstDiagnosticList.test.tsx new file mode 100644 index 0000000..57ade38 --- /dev/null +++ b/src/components/TypstDiagnosticList.test.tsx @@ -0,0 +1,31 @@ +import { render } from "vitest-browser-react"; +import { describe, expect, it } from "vitest"; +import type { TypstDiagnostic } from "#src/lib/typst"; +import { TypstDiagnosticList } from "./TypstDiagnosticList"; + +describe("TypstDiagnosticList", () => { + it("renders one row per diagnostic with file:line:col and message", async () => { + const items: TypstDiagnostic[] = [ + { + severity: "error", + path: "main.typ", + line: 12, + column: 5, + message: "unknown variable: foo", + }, + { + severity: "warning", + path: "main.typ", + line: 20, + column: 1, + message: "deprecated", + }, + ]; + const screen = await render(); + await expect.element(screen.getByText("main.typ:12:5")).toBeVisible(); + await expect + .element(screen.getByText("unknown variable: foo")) + .toBeVisible(); + await expect.element(screen.getByText("main.typ:20:1")).toBeVisible(); + }); +}); diff --git a/src/components/TypstDiagnosticList.tsx b/src/components/TypstDiagnosticList.tsx new file mode 100644 index 0000000..2168e7f --- /dev/null +++ b/src/components/TypstDiagnosticList.tsx @@ -0,0 +1,47 @@ +import { AlertCircleIcon, AlertTriangleIcon } from "lucide-react"; +import type { TypstDiagnostic } from "#src/lib/typst"; +import { cn } from "#src/lib/utils"; +import * as m from "#src/paraglide/messages.js"; + +interface Props { + items: TypstDiagnostic[]; + max?: number; +} + +export function TypstDiagnosticList({ items, max = 20 }: Props) { + const errors = items.filter((d) => d.severity === "error").length; + const shown = items.slice(0, max); + const remaining = items.length - shown.length; + return ( +
+
+ {m.typst_error_compile_failed({ count: String(errors) })} +
+ + {remaining > 0 && ( +
+ {remaining} more
+ )} +
+ ); +} diff --git a/src/lib/recent-store.ts b/src/lib/recent-store.ts index d5ec31f..76ffc9b 100644 --- a/src/lib/recent-store.ts +++ b/src/lib/recent-store.ts @@ -1,16 +1,35 @@ import { type DBSchema, type IDBPDatabase, openDB } from "idb"; -export type RecentFile = { +export interface RecentPdfFile { + kind?: "pdf"; id: string; name: string; - lastOpened: number; handle?: FileSystemFileHandle; configHandle?: FileSystemFileHandle; configName?: string; file?: File; configFile?: File; + lastOpened: number; thumbnail?: string; -}; +} + +export interface RecentTypstFile { + kind: "typst"; + id: string; + name: string; + mainPath: string; + handle?: FileSystemFileHandle; + assetHandles?: FileSystemFileHandle[]; + file?: File; + assetFiles?: File[]; + configHandle?: FileSystemFileHandle; + configFile?: File; + configName?: string; + lastOpened: number; + thumbnail?: string; +} + +export type RecentFile = RecentPdfFile | RecentTypstFile; export type Settings = { saveHistory: boolean; @@ -70,14 +89,22 @@ export function openDb(): Promise { export async function getRecentFiles( db: IDBPDatabase, ): Promise { - return db.getAll(DB_STORE); + const all = await db.getAll(DB_STORE); + // Migration: entries stored before the discriminated union had no `kind`. + // Treat missing `kind` as "pdf". + return all.map((item) => + item.kind === undefined ? { ...item, kind: "pdf" as const } : item, + ); } export async function getRecentFileById( db: IDBPDatabase, id: string, ): Promise { - return db.get(DB_STORE, id); + const item = await db.get(DB_STORE, id); + if (!item) return undefined; + // Migration: entries stored before the discriminated union had no `kind`. + return item.kind === undefined ? { ...item, kind: "pdf" as const } : item; } export async function upsertRecent( diff --git a/src/lib/typst-source-detect.test.ts b/src/lib/typst-source-detect.test.ts new file mode 100644 index 0000000..2c7733e --- /dev/null +++ b/src/lib/typst-source-detect.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + containsTypst, + entriesToTypstSources, + filesToTypstSources, + pickMainTypst, + type TypstSource, +} from "./typst-source-detect"; + +const src = (path: string): TypstSource => ({ path, data: new Uint8Array() }); + +describe("containsTypst", () => { + it("returns true when any file ends with .typ", () => { + expect(containsTypst([src("main.typ")])).toBe(true); + expect(containsTypst([src("a.pdf"), src("b.typ")])).toBe(true); + }); + it("returns false otherwise", () => { + expect(containsTypst([src("a.pdf")])).toBe(false); + expect(containsTypst([])).toBe(false); + }); +}); + +describe("pickMainTypst", () => { + it("returns the only .typ when single", () => { + expect(pickMainTypst([src("hello.typ")])).toBe("hello.typ"); + }); + it("prefers main.typ at root when multiple", () => { + expect( + pickMainTypst([src("intro.typ"), src("main.typ"), src("appendix.typ")]), + ).toBe("main.typ"); + }); + it("falls back to shallowest then alphabetical when no main.typ", () => { + expect( + pickMainTypst([ + src("chapters/01.typ"), + src("chapters/02.typ"), + src("zoo.typ"), + src("alpha.typ"), + ]), + ).toBe("alpha.typ"); + }); + it("returns null when no .typ", () => { + expect(pickMainTypst([src("a.pdf")])).toBe(null); + }); +}); + +describe("filesToTypstSources", () => { + it("uses webkitRelativePath when present, otherwise file name", async () => { + const a = new File(["hello"], "main.typ", { type: "text/plain" }); + const b = new File([new Uint8Array([1, 2])], "logo.png"); + Object.defineProperty(b, "webkitRelativePath", { + value: "assets/logo.png", + }); + const result = await filesToTypstSources([a, b]); + expect(result.map((r) => r.path)).toEqual(["main.typ", "assets/logo.png"]); + expect(result[1].data).toEqual(new Uint8Array([1, 2])); + }); +}); + +describe("entriesToTypstSources", () => { + it("recursively expands directory entries with relative paths", async () => { + type MockEntry = { + isFile: boolean; + isDirectory: boolean; + name: string; + file?: (cb: (f: File) => void) => void; + createReader?: () => { + readEntries: (cb: (e: MockEntry[]) => void) => void; + }; + }; + function fileEntry(name: string, content: string): MockEntry { + return { + isFile: true, + isDirectory: false, + name, + file: (cb: (f: File) => void) => cb(new File([content], name)), + }; + } + function dirEntry(name: string, children: MockEntry[]): MockEntry { + return { + isFile: false, + isDirectory: true, + name, + createReader: () => { + let exhausted = false; + return { + readEntries: (cb: (e: MockEntry[]) => void) => { + if (exhausted) return cb([]); + exhausted = true; + cb(children); + }, + }; + }, + }; + } + const root = [ + fileEntry("main.typ", "= Hi"), + dirEntry("img", [fileEntry("a.png", "x")]), + ]; + const result = await entriesToTypstSources(root); + expect(result.map((r) => r.path).sort()).toEqual(["img/a.png", "main.typ"]); + }); +}); diff --git a/src/lib/typst-source-detect.ts b/src/lib/typst-source-detect.ts new file mode 100644 index 0000000..718aab4 --- /dev/null +++ b/src/lib/typst-source-detect.ts @@ -0,0 +1,122 @@ +export interface TypstSource { + /** Project-root-relative path with no leading slash. e.g. "main.typ", "images/logo.png" */ + path: string; + data: Uint8Array; +} + +/** + * Path-only view of a Typst input. `containsTypst` and `pickMainTypst` only + * need the path; this lets callers decide whether to load the bytes (which is + * costly for large PDF decks that pass through the same `handleFiles` entry). + */ +export interface TypstSourceMeta { + path: string; +} + +export function containsTypst( + sources: ReadonlyArray, +): boolean { + return sources.some((s) => /\.typ$/i.test(s.path)); +} + +function depth(path: string): number { + return path.split("/").length; +} + +export function pickMainTypst( + sources: ReadonlyArray, +): string | null { + const typs = sources.filter((s) => /\.typ$/i.test(s.path)); + if (typs.length === 0) return null; + if (typs.length === 1) return typs[0].path; + const root = typs.find((s) => s.path.toLowerCase() === "main.typ"); + if (root) return root.path; + const sorted = [...typs].sort((a, b) => { + const d = depth(a.path) - depth(b.path); + if (d !== 0) return d; + return a.path.localeCompare(b.path); + }); + return sorted[0].path; +} + +type FsEntry = { + isFile: boolean; + isDirectory: boolean; + name: string; + file?: (cb: (f: File) => void, err?: (e: unknown) => void) => void; + createReader?: () => { + readEntries: ( + cb: (entries: FsEntry[]) => void, + err?: (e: unknown) => void, + ) => void; + }; +}; + +function readDirAll( + reader: ReturnType>, +): Promise { + return new Promise((resolve, reject) => { + const all: FsEntry[] = []; + const pump = () => + reader.readEntries((batch) => { + if (batch.length === 0) return resolve(all); + all.push(...batch); + pump(); + }, reject); + pump(); + }); +} + +async function entryToSources( + entry: FsEntry, + prefix: string, +): Promise { + const path = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isFile && entry.file) { + const file = await new Promise((res, rej) => entry.file!(res, rej)); + return [{ path, data: new Uint8Array(await file.arrayBuffer()) }]; + } + if (entry.isDirectory && entry.createReader) { + const reader = entry.createReader(); + const children = await readDirAll(reader); + const nested = await Promise.all( + children.map((c) => entryToSources(c, path)), + ); + return nested.flat(); + } + return []; +} + +export async function entriesToTypstSources( + entries: ReadonlyArray, +): Promise { + const filtered = entries.filter((e): e is FsEntry => !!e); + const nested = await Promise.all(filtered.map((e) => entryToSources(e, ""))); + return nested.flat(); +} + +/** + * Cheap path-only view of `File[]`. Use this for `containsTypst` / + * `pickMainTypst` checks before deciding whether to materialize the bytes + * via `filesToTypstSources`. + */ +export function filesToTypstMeta(files: File[]): TypstSourceMeta[] { + return files.map((f) => ({ + path: + (f as File & { webkitRelativePath?: string }).webkitRelativePath || + f.name, + })); +} + +export async function filesToTypstSources( + files: File[], +): Promise { + return Promise.all( + files.map(async (f) => ({ + path: + (f as File & { webkitRelativePath?: string }).webkitRelativePath || + f.name, + data: new Uint8Array(await f.arrayBuffer()), + })), + ); +} diff --git a/src/lib/typst.test.ts b/src/lib/typst.test.ts new file mode 100644 index 0000000..e7f8a4f --- /dev/null +++ b/src/lib/typst.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { compileTypst } from "./typst"; + +const enc = (s: string) => new TextEncoder().encode(s); + +describe("compileTypst", () => { + it("compiles a minimal .typ to a PDF", async () => { + const res = await compileTypst({ + sources: [{ path: "main.typ", data: enc("= Hello\nWorld") }], + mainPath: "main.typ", + }); + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.pdf.length).toBeGreaterThan(100); + expect(new TextDecoder().decode(res.pdf.slice(0, 5))).toBe("%PDF-"); + } + }, 60_000); + + it("returns diagnostics on syntax error", async () => { + const res = await compileTypst({ + sources: [{ path: "main.typ", data: enc("#let x = (\n") }], + mainPath: "main.typ", + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.diagnostics.length).toBeGreaterThan(0); + expect(res.diagnostics[0].severity).toBe("error"); + } + }, 60_000); + + it("emits loading-wasm progress before compiling", async () => { + const stages: string[] = []; + await compileTypst( + { + sources: [{ path: "main.typ", data: enc("= Hi") }], + mainPath: "main.typ", + }, + { onProgress: (p) => stages.push(p.stage) }, + ); + expect(stages[0]).toBe("loading-wasm"); + expect(stages).toContain("compiling"); + }, 60_000); +}); diff --git a/src/lib/typst.ts b/src/lib/typst.ts new file mode 100644 index 0000000..91ff1d1 --- /dev/null +++ b/src/lib/typst.ts @@ -0,0 +1,84 @@ +import type { TypstSource } from "./typst-source-detect"; +export type { TypstSource } from "./typst-source-detect"; + +export interface TypstDiagnostic { + severity: "error" | "warning"; + path: string; + line: number; // 1-origin + column: number; // 1-origin + message: string; + package?: string; +} + +export type TypstProgress = + | { stage: "loading-wasm" } + | { stage: "fetching-packages"; current?: string } + | { stage: "compiling" }; + +export interface CompileRequest { + sources: TypstSource[]; + mainPath: string; +} + +export type CompileResult = + | { ok: true; pdf: Uint8Array } + | { ok: false; diagnostics: TypstDiagnostic[] }; + +export interface CompileOptions { + signal?: AbortSignal; + onProgress?: (p: TypstProgress) => void; +} + +export type WorkerInbound = { kind: "compile"; req: CompileRequest }; +export type WorkerOutbound = + | { kind: "progress"; payload: TypstProgress } + | { kind: "result"; payload: CompileResult } + | { kind: "fatal"; message: string }; + +export function compileTypst( + req: CompileRequest, + opts: CompileOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + const worker = new Worker( + new URL("../workers/typst-worker.ts", import.meta.url), + { type: "module" }, + ); + const cleanup = () => { + worker.terminate(); + opts.signal?.removeEventListener("abort", onAbort); + }; + const onAbort = () => { + cleanup(); + reject(new DOMException("Aborted", "AbortError")); + }; + if (opts.signal) { + if (opts.signal.aborted) return onAbort(); + opts.signal.addEventListener("abort", onAbort); + } + + worker.addEventListener("message", (ev: MessageEvent) => { + const msg = ev.data; + switch (msg.kind) { + case "progress": + opts.onProgress?.(msg.payload); + break; + case "result": + cleanup(); + resolve(msg.payload); + break; + case "fatal": + cleanup(); + reject(new Error(msg.message)); + break; + } + }); + worker.addEventListener("error", (ev) => { + cleanup(); + reject(new Error(ev.message || "worker error")); + }); + + const inbound: WorkerInbound = { kind: "compile", req }; + worker.postMessage(inbound); + }); +} diff --git a/src/routes/$locale/(main)/-index/HeroSection.tsx b/src/routes/$locale/(main)/-index/HeroSection.tsx index 9d57c54..eeb97cf 100644 --- a/src/routes/$locale/(main)/-index/HeroSection.tsx +++ b/src/routes/$locale/(main)/-index/HeroSection.tsx @@ -1,9 +1,55 @@ import { FileIcon, LinkIcon, PlayIcon, PlusIcon } from "lucide-react"; import { type DragEvent, useId, useRef, useState } from "react"; +import type React from "react"; import { Button } from "#src/components/ui/button"; import { cn } from "#src/lib/utils"; import * as m from "#src/paraglide/messages.js"; +type FsEntry = { + isFile: boolean; + isDirectory: boolean; + name: string; + file?: (cb: (f: File) => void, err?: (e: unknown) => void) => void; + createReader?: () => { + readEntries: (cb: (entries: FsEntry[]) => void, err?: (e: unknown) => void) => void; + }; +}; + +async function expandDroppedItems(list: DataTransferItemList): Promise { + const entries: FsEntry[] = []; + for (const it of Array.from(list)) { + const getEntry = (it as DataTransferItem & { + webkitGetAsEntry?: () => FsEntry | null; + }).webkitGetAsEntry; + if (typeof getEntry === "function") { + const e = getEntry.call(it); + if (e) entries.push(e); + } + } + if (entries.length === 0) return []; + + const out: File[] = []; + async function walk(entry: FsEntry, prefix: string): Promise { + const path = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isFile && entry.file) { + const file: File = await new Promise((res, rej) => entry.file!(res, rej)); + Object.defineProperty(file, "webkitRelativePath", { value: path }); + out.push(file); + return; + } + if (entry.isDirectory && entry.createReader) { + const reader = entry.createReader(); + let batch: FsEntry[] = []; + do { + batch = await new Promise((res, rej) => reader.readEntries(res, rej)); + for (const c of batch) await walk(c, path); + } while (batch.length > 0); + } + } + for (const e of entries) await walk(e, ""); + return out; +} + const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent); const DEMO_BASE = @@ -20,7 +66,7 @@ function demoUrls(locale: string): { pdf: string; pdfpc: string } { } interface HeroSectionProps { - status: string | null; + status: React.ReactNode | null; inputId: string; supportsFSA: boolean; locale: string; @@ -45,10 +91,20 @@ export function HeroSection({ const [pdfpcUrlValue, setPdfpcUrlValue] = useState(""); const urlFormId = useId(); - function handleDrop(event: DragEvent) { + async function handleDrop(event: DragEvent) { event.preventDefault(); setDragActive(false); - const files = Array.from(event.dataTransfer.files); + const items = event.dataTransfer.items; + const supportsEntries = + items != null && + Array.from(items).some( + (it) => + typeof (it as DataTransferItem & { webkitGetAsEntry?: unknown }) + .webkitGetAsEntry === "function", + ); + const files = supportsEntries + ? await expandDroppedItems(items) + : Array.from(event.dataTransfer.files); if (files.length > 0) void onFilesSelected(files); } @@ -198,14 +254,14 @@ export function HeroSection({

{m.hero_url_hint()}

)} - {status && ( - {status} + {status !== null && status !== undefined && status !== false && ( +
{status}
)}