From a429032803b5dda343235f3f93605a74edfc3c7e Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Sun, 3 May 2026 22:34:03 +0900 Subject: [PATCH 01/15] feat(deps): add typst.ts and typst-ts-web-compiler (#12) Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 ++ pnpm-lock.yaml | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) 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 From bfba89046d0a35849f53370245a6c58f12b37f03 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Sun, 3 May 2026 22:37:11 +0900 Subject: [PATCH 02/15] feat(typst): add source detection utilities (#12) Co-Authored-By: Claude Sonnet 4.6 --- src/lib/typst-source-detect.test.ts | 39 +++++++++++++++++++++++++++++ src/lib/typst-source-detect.ts | 27 ++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 src/lib/typst-source-detect.test.ts create mode 100644 src/lib/typst-source-detect.ts diff --git a/src/lib/typst-source-detect.test.ts b/src/lib/typst-source-detect.test.ts new file mode 100644 index 0000000..653255d --- /dev/null +++ b/src/lib/typst-source-detect.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { containsTypst, 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); + }); +}); diff --git a/src/lib/typst-source-detect.ts b/src/lib/typst-source-detect.ts new file mode 100644 index 0000000..3699c64 --- /dev/null +++ b/src/lib/typst-source-detect.ts @@ -0,0 +1,27 @@ +export interface TypstSource { + /** Project-root-relative path with no leading slash. e.g. "main.typ", "images/logo.png" */ + path: string; + data: Uint8Array; +} + +export function containsTypst(sources: TypstSource[]): boolean { + return sources.some((s) => /\.typ$/i.test(s.path)); +} + +function depth(path: string): number { + return path.split("/").length; +} + +export function pickMainTypst(sources: TypstSource[]): 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; +} From 80a983250bfffb293aa4d13eea16954fd416999f Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Sun, 3 May 2026 22:37:56 +0900 Subject: [PATCH 03/15] feat(typst): convert File[] to TypstSource[] (#12) Co-Authored-By: Claude Sonnet 4.6 --- src/lib/typst-source-detect.test.ts | 13 ++++++++++++- src/lib/typst-source-detect.ts | 9 +++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/lib/typst-source-detect.test.ts b/src/lib/typst-source-detect.test.ts index 653255d..829b85d 100644 --- a/src/lib/typst-source-detect.test.ts +++ b/src/lib/typst-source-detect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { containsTypst, pickMainTypst, type TypstSource } from "./typst-source-detect"; +import { containsTypst, filesToTypstSources, pickMainTypst, type TypstSource } from "./typst-source-detect"; const src = (path: string): TypstSource => ({ path, data: new Uint8Array() }); @@ -37,3 +37,14 @@ describe("pickMainTypst", () => { 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])); + }); +}); diff --git a/src/lib/typst-source-detect.ts b/src/lib/typst-source-detect.ts index 3699c64..f89aaec 100644 --- a/src/lib/typst-source-detect.ts +++ b/src/lib/typst-source-detect.ts @@ -25,3 +25,12 @@ export function pickMainTypst(sources: TypstSource[]): string | null { }); return sorted[0].path; } + +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()), + })), + ); +} From 73582468799444b7867c4e7a3a237bdb2e1ec416 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Sun, 3 May 2026 22:38:55 +0900 Subject: [PATCH 04/15] feat(typst): recursively expand drag-drop directory entries (#12) Co-Authored-By: Claude Sonnet 4.6 --- src/lib/typst-source-detect.test.ts | 38 ++++++++++++++++++++++- src/lib/typst-source-detect.ts | 48 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/lib/typst-source-detect.test.ts b/src/lib/typst-source-detect.test.ts index 829b85d..df2a506 100644 --- a/src/lib/typst-source-detect.test.ts +++ b/src/lib/typst-source-detect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { containsTypst, filesToTypstSources, pickMainTypst, type TypstSource } from "./typst-source-detect"; +import { containsTypst, entriesToTypstSources, filesToTypstSources, pickMainTypst, type TypstSource } from "./typst-source-detect"; const src = (path: string): TypstSource => ({ path, data: new Uint8Array() }); @@ -48,3 +48,39 @@ describe("filesToTypstSources", () => { expect(result[1].data).toEqual(new Uint8Array([1, 2])); }); }); + +describe("entriesToTypstSources", () => { + it("recursively expands directory entries with relative paths", async () => { + function fileEntry(name: string, content: string): any { + return { + isFile: true, + isDirectory: false, + name, + file: (cb: (f: File) => void) => cb(new File([content], name)), + }; + } + function dirEntry(name: string, children: any[]): any { + return { + isFile: false, + isDirectory: true, + name, + createReader: () => { + let exhausted = false; + return { + readEntries: (cb: (e: any[]) => 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 index f89aaec..689603c 100644 --- a/src/lib/typst-source-detect.ts +++ b/src/lib/typst-source-detect.ts @@ -26,6 +26,54 @@ export function pickMainTypst(sources: TypstSource[]): string | null { 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(); +} + export async function filesToTypstSources(files: File[]): Promise { return Promise.all( files.map(async (f) => ({ From 354a9f260c11536637f64a166da4e3ee59cc5843 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Sun, 3 May 2026 22:42:06 +0900 Subject: [PATCH 05/15] feat(typst): add worker message protocol and types (#12) --- src/lib/typst.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/lib/typst.ts diff --git a/src/lib/typst.ts b/src/lib/typst.ts new file mode 100644 index 0000000..3c891e6 --- /dev/null +++ b/src/lib/typst.ts @@ -0,0 +1,36 @@ +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 }; From 235e98d55115fb2bafce4cacf93387c80a2a3813 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Sun, 3 May 2026 22:42:35 +0900 Subject: [PATCH 06/15] feat(typst): worker that compiles .typ to PDF using typst.ts (#12) --- src/workers/typst-worker.ts | 86 +++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/workers/typst-worker.ts diff --git a/src/workers/typst-worker.ts b/src/workers/typst-worker.ts new file mode 100644 index 0000000..0b3d9e4 --- /dev/null +++ b/src/workers/typst-worker.ts @@ -0,0 +1,86 @@ +import { + createTypstCompiler, + FetchPackageRegistry, + initOptions, + MemoryAccessModel, +} from "@myriaddreamin/typst.ts"; +// Note: in typst.ts 0.6, withAccessModel/withPackageRegistry are exposed via the +// `initOptions` namespace re-export, not as top-level named exports. +import wasmUrl from "@myriaddreamin/typst-ts-web-compiler/wasm?url"; +import type { + CompileRequest, + CompileResult, + TypstDiagnostic, + WorkerInbound, + WorkerOutbound, +} from "#src/lib/typst"; + +const RANGE_RE = /^(\d+):(\d+)-\d+:\d+$/; + +function parseDiagnostic(d: { + package: string; + path: string; + severity: string; + range: string; + message: string; +}): TypstDiagnostic { + const m = RANGE_RE.exec(d.range); + return { + severity: d.severity === "warning" ? "warning" : "error", + path: d.path, + line: m ? Number(m[1]) : 1, + column: m ? Number(m[2]) : 1, + message: d.message, + package: d.package || undefined, + }; +} + +function post(msg: WorkerOutbound) { + (self as unknown as Worker).postMessage(msg); +} + +async function compile(req: CompileRequest): Promise { + post({ kind: "progress", payload: { stage: "loading-wasm" } }); + + const am = new MemoryAccessModel(); + const reg = new FetchPackageRegistry(am); + const compiler = createTypstCompiler(); + await compiler.init({ + getModule: () => fetch(wasmUrl).then((r) => r.arrayBuffer()), + beforeBuild: [initOptions.withAccessModel(am), initOptions.withPackageRegistry(reg)], + }); + + for (const src of req.sources) { + if (/\.typ$/i.test(src.path)) { + compiler.addSource(`/${src.path}`, new TextDecoder().decode(src.data)); + } else { + compiler.mapShadow(`/${src.path}`, src.data); + } + } + + // Note: typst.ts 0.6 does not expose package-fetch lifecycle events. + // The `fetching-packages` stage in the protocol is reserved for future use + // and is currently never emitted here. + post({ kind: "progress", payload: { stage: "compiling" } }); + const res = await compiler.compile({ + format: "pdf", + mainFilePath: `/${req.mainPath}`, + diagnostics: "full", + }); + + if (res.result && res.result.length > 0) { + return { ok: true, pdf: res.result }; + } + const diagnostics = (res.diagnostics ?? []).map(parseDiagnostic); + return { ok: false, diagnostics }; +} + +self.addEventListener("message", async (ev: MessageEvent) => { + if (ev.data.kind !== "compile") return; + try { + const result = await compile(ev.data.req); + post({ kind: "result", payload: result }); + } catch (err) { + post({ kind: "fatal", message: err instanceof Error ? err.message : String(err) }); + } +}); From 8a5f50f1e5b3ef0bb60b8a903390a5b2018ebbe7 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Sun, 3 May 2026 23:06:28 +0900 Subject: [PATCH 07/15] feat(typst): compileTypst client with worker lifecycle (#12) Co-Authored-By: Claude Sonnet 4.6 --- src/lib/typst.test.ts | 43 ++++++++++++++++++++++++++++++++++++++ src/lib/typst.ts | 48 +++++++++++++++++++++++++++++++++++++++++++ vite.config.ts | 3 +++ 3 files changed, 94 insertions(+) create mode 100644 src/lib/typst.test.ts diff --git a/src/lib/typst.test.ts b/src/lib/typst.test.ts new file mode 100644 index 0000000..b28732a --- /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 index 3c891e6..f30806e 100644 --- a/src/lib/typst.ts +++ b/src/lib/typst.ts @@ -34,3 +34,51 @@ 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/vite.config.ts b/vite.config.ts index 5779245..820b5c5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -211,6 +211,9 @@ export default defineConfig({ devOptions: { enabled: false }, }), ], + optimizeDeps: { + include: ["@myriaddreamin/typst.ts"], + }, test: { globals: true, setupFiles: ["./src/test-setup.ts"], From 0c9d0943a51e6a8140d3445c2bd449e5cff50ff4 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Sun, 3 May 2026 23:08:59 +0900 Subject: [PATCH 08/15] i18n(typst): add Typst status and error messages (#12) --- messages/en.json | 12 +++++++++++- messages/ja.json | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) 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": "警告" } From 2435e35c833e76c974ae144373cc18cede9c83d3 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Sun, 3 May 2026 23:18:57 +0900 Subject: [PATCH 09/15] feat(typst): TypstDiagnosticList component (#12) Co-Authored-By: Claude Sonnet 4.6 --- src/components/TypstDiagnosticList.test.tsx | 17 +++++++++ src/components/TypstDiagnosticList.tsx | 42 +++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/components/TypstDiagnosticList.test.tsx create mode 100644 src/components/TypstDiagnosticList.tsx diff --git a/src/components/TypstDiagnosticList.test.tsx b/src/components/TypstDiagnosticList.test.tsx new file mode 100644 index 0000000..8241ec1 --- /dev/null +++ b/src/components/TypstDiagnosticList.test.tsx @@ -0,0 +1,17 @@ +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..9b724d6 --- /dev/null +++ b/src/components/TypstDiagnosticList.tsx @@ -0,0 +1,42 @@ +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) })} +
+
    + {shown.map((d, i) => ( +
  • + {d.severity === "error" ? ( + + ) : ( + + )} + + {d.path}:{d.line}:{d.column} + + + {d.message} + +
  • + ))} +
+ {remaining > 0 && ( +
+ {remaining} more
+ )} +
+ ); +} From 139cc29017d4f7bb0dc1d260b983dcc5d5a3c9d6 Mon Sep 17 00:00:00 2001 From: miyaji255 <84168445+miyaji255@users.noreply.github.com> Date: Sun, 3 May 2026 23:21:11 +0900 Subject: [PATCH 10/15] feat(home): accept .typ files and expand dropped folders (#12) Co-Authored-By: Claude Sonnet 4.6 --- .../$locale/(main)/-index/HeroSection.tsx | 70 +++++++++++++++++-- 1 file changed, 63 insertions(+), 7 deletions(-) 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}
)}