chore: wire vendored foliate-js into the build

$foliate is a Vite-only alias, deliberately absent from kit.alias and
tsconfig paths: TypeScript cannot resolve it, so the ambient declaration in
src/lib/reader/foliate.d.ts is the only candidate and svelte-check never
walks the ~11k lines of untyped vendored JS. The generated tsconfig includes
src/**/*.js and checkJs is on, so src/lib/vendor is excluded as well —
`exclude` replaces rather than merges, hence the repeated service-worker
entries.

optimizeDeps.include for construct-style-sheets-polyfill: it sits behind a
dynamic import in fixed-layout.js, so Vite would otherwise discover it
mid-session and force a page reload on the first fixed-layout EPUB.

Verified with a temporary import that the graph resolves: view.js and
paginator.js are emitted as chunks, and the bundled pdf.js is our stub
rather than upstream's bare `import '@pdfjs/pdf.min.mjs'`.
This commit is contained in:
2026-08-11 22:04:50 -04:00
parent dd65e34869
commit c946b6d71c
4 changed files with 154 additions and 2 deletions
+90
View File
@@ -0,0 +1,90 @@
/**
* Hand-written types for the vendored foliate-js (src/lib/vendor/foliate-js).
*
* `$foliate` is a Vite-only alias, so TypeScript cannot resolve it and this
* ambient declaration is the only candidate — which is the point: svelte-check
* never walks the untyped vendored JS. Only the surface Chitai calls is typed.
* When you start using a new method, add it here rather than casting to `any`.
*/
declare module '$foliate/view.js' {
/** A TOC entry. `id` is assigned by foliate's `assignIDs`, not by the book. */
export interface FoliateTocItem {
id: number;
label: string;
href: string;
subitems?: FoliateTocItem[];
}
/** `event.detail` of the `relocate` event. Shape from SectionProgress.getProgress. */
export interface FoliateRelocateDetail {
/** Overall progress through the book, 01. Chitai stores this as `percentage`. */
fraction: number;
section: { current: number; total: number };
location: { current: number; next: number; total: number };
/** Estimated remaining reading time, in minutes. */
time: { section: number; total: number };
/** Null when the book has no TOC entry covering this position. */
tocItem?: FoliateTocItem | null;
pageItem?: { label: string; href: string } | null;
cfi: string;
range?: Range;
}
/** `event.detail` of the `load` event. `doc` is the section's iframe document. */
export interface FoliateLoadDetail {
doc: Document;
index: number;
}
export interface FoliateRenderer extends HTMLElement {
/**
* A single string sets the stylesheet appended to <head>, which wins over the
* book's own CSS on equal specificity. A [before, after] tuple also sets one
* prepended to <head>, which the book's CSS overrides. Re-applied by the
* paginator on every section load. Absent on the fixed-layout renderer.
*/
setStyles?(styles: string | [before: string, after: string]): void;
getContents(): { doc: Document; index: number }[];
destroy(): void;
}
export interface FoliateBook {
toc?: FoliateTocItem[];
/** 'rtl' for right-to-left books; drives goLeft/goRight. */
dir?: string;
metadata?: Record<string, unknown>;
rendition?: { layout?: string };
}
/** Accepted by goTo/select/init: a CFI or href, a spine index, or a fraction. */
export type FoliateTarget = string | number | { fraction: number };
export class View extends HTMLElement {
book: FoliateBook;
renderer: FoliateRenderer;
/** True when the book is pre-paginated; the renderer is then foliate-fxl. */
isFixedLayout: boolean;
lastLocation: FoliateRelocateDetail | null;
open(book: File | string | FoliateBook): Promise<void>;
close(): void;
init(opts: { lastLocation?: FoliateTarget | null; showTextStart?: boolean }): Promise<void>;
/** Returns undefined on failure — it logs and swallows. Check before relying on it. */
resolveNavigation(target: FoliateTarget): { index: number; anchor?: unknown } | undefined;
goTo(target: FoliateTarget): Promise<{ index: number } | undefined>;
goToFraction(fraction: number): Promise<void>;
prev(distance?: number): Promise<void>;
next(distance?: number): Promise<void>;
/** Direction-aware: inverts against prev/next for rtl books. */
goLeft(): Promise<void>;
goRight(): Promise<void>;
}
export function makeBook(file: File | string): Promise<FoliateBook>;
export class ResponseError extends Error {}
export class NotFoundError extends Error {}
export class UnsupportedTypeError extends Error {}
}
+32
View File
@@ -0,0 +1,32 @@
import type { View } from '$foliate/view.js';
let pending: Promise<void> | undefined;
/**
* Loads foliate-js and registers its custom elements.
*
* view.js calls customElements.define() at module scope and subclasses
* HTMLElement, and its class fields construct DOM helpers eagerly — so importing
* it on the server throws. This module is safe to import anywhere because it
* touches nothing at module scope; the vendored code is only pulled in when
* loadFoliate() is called, which must be from onMount or a browser guard.
*
* The promise is cached so concurrent callers share one load. ESM already dedupes
* module evaluation; this mainly makes the "define runs once" contract explicit.
* Editing a file under src/lib/vendor invalidates the module and re-runs
* customElements.define, which throws — hard-refresh after re-vendoring.
*/
export function loadFoliate(): Promise<void> {
return (pending ??= import('$foliate/view.js').then(() => undefined));
}
/**
* Creates a <foliate-view>. Call only after loadFoliate() has resolved.
*
* Built imperatively rather than written in markup so SSR never emits an unknown
* element for Svelte to hydrate, and so svelte-check has no unknown attributes to
* complain about.
*/
export function createFoliateView(): View {
return document.createElement('foliate-view') as View;
}
+15 -1
View File
@@ -10,7 +10,21 @@
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler"
} },
// `exclude` replaces the generated list rather than merging with it, so the
// service-worker entries from .svelte-kit/tsconfig.json are repeated here.
// src/lib/vendor is added: it is vendored third-party JS and checkJs is on,
// so without this svelte-check reports thousands of errors from foliate-js.
"exclude": [
"node_modules/**",
"src/service-worker.js",
"src/service-worker/**/*.js",
"src/service-worker.ts",
"src/service-worker/**/*.ts",
"src/service-worker.d.ts",
"src/service-worker/**/*.d.ts",
"src/lib/vendor/**"
]
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
// //
+17 -1
View File
@@ -1,7 +1,23 @@
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { fileURLToPath } from 'node:url';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()] plugins: [tailwindcss(), sveltekit()],
resolve: {
alias: {
// Deliberately a Vite-only alias, and deliberately not in kit.alias or
// tsconfig paths. TypeScript cannot resolve `$foliate/*`, so the ambient
// declaration in src/lib/reader/foliate.d.ts is the only candidate and
// svelte-check never walks the ~11k lines of untyped vendored JS.
$foliate: fileURLToPath(new URL('./src/lib/vendor/foliate-js', import.meta.url))
}
},
optimizeDeps: {
// Behind a dynamic import in foliate's fixed-layout.js, so Vite would
// otherwise discover it mid-session and force a full page reload the first
// time someone opens a fixed-layout EPUB.
include: ['construct-style-sheets-polyfill']
}
}); });