wolfy-reader 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/dist/core/book.d.ts +75 -0
- package/dist/core/book.d.ts.map +1 -0
- package/dist/core/book.js +2 -0
- package/dist/core/book.js.map +1 -0
- package/dist/core/errors.d.ts +17 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +29 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/format.d.ts +13 -0
- package/dist/core/format.d.ts.map +1 -0
- package/dist/core/format.js +2 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/graphemes.d.ts +36 -0
- package/dist/core/graphemes.d.ts.map +1 -0
- package/dist/core/graphemes.js +62 -0
- package/dist/core/graphemes.js.map +1 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +6 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/lookup.d.ts +10 -0
- package/dist/core/lookup.d.ts.map +1 -0
- package/dist/core/lookup.js +16 -0
- package/dist/core/lookup.js.map +1 -0
- package/dist/core/open.d.ts +12 -0
- package/dist/core/open.d.ts.map +1 -0
- package/dist/core/open.js +23 -0
- package/dist/core/open.js.map +1 -0
- package/dist/core/position.d.ts +121 -0
- package/dist/core/position.d.ts.map +1 -0
- package/dist/core/position.js +327 -0
- package/dist/core/position.js.map +1 -0
- package/dist/core/reading-text.d.ts +77 -0
- package/dist/core/reading-text.d.ts.map +1 -0
- package/dist/core/reading-text.js +144 -0
- package/dist/core/reading-text.js.map +1 -0
- package/dist/core/source.d.ts +17 -0
- package/dist/core/source.d.ts.map +1 -0
- package/dist/core/source.js +39 -0
- package/dist/core/source.js.map +1 -0
- package/dist/core/storage.d.ts +7 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +2 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/text.d.ts +18 -0
- package/dist/core/text.d.ts.map +1 -0
- package/dist/core/text.js +32 -0
- package/dist/core/text.js.map +1 -0
- package/dist/formats/epub/href.d.ts +14 -0
- package/dist/formats/epub/href.d.ts.map +1 -0
- package/dist/formats/epub/href.js +52 -0
- package/dist/formats/epub/href.js.map +1 -0
- package/dist/formats/epub/index.d.ts +3 -0
- package/dist/formats/epub/index.d.ts.map +1 -0
- package/dist/formats/epub/index.js +223 -0
- package/dist/formats/epub/index.js.map +1 -0
- package/dist/formats/epub/opf.d.ts +27 -0
- package/dist/formats/epub/opf.d.ts.map +1 -0
- package/dist/formats/epub/opf.js +110 -0
- package/dist/formats/epub/opf.js.map +1 -0
- package/dist/formats/epub/toc.d.ts +6 -0
- package/dist/formats/epub/toc.d.ts.map +1 -0
- package/dist/formats/epub/toc.js +88 -0
- package/dist/formats/epub/toc.js.map +1 -0
- package/dist/formats/fb2/index.d.ts +13 -0
- package/dist/formats/fb2/index.d.ts.map +1 -0
- package/dist/formats/fb2/index.js +317 -0
- package/dist/formats/fb2/index.js.map +1 -0
- package/dist/formats/index.d.ts +4 -0
- package/dist/formats/index.d.ts.map +1 -0
- package/dist/formats/index.js +4 -0
- package/dist/formats/index.js.map +1 -0
- package/dist/formats/text/index.d.ts +11 -0
- package/dist/formats/text/index.d.ts.map +1 -0
- package/dist/formats/text/index.js +201 -0
- package/dist/formats/text/index.js.map +1 -0
- package/dist/formats/xml.d.ts +33 -0
- package/dist/formats/xml.d.ts.map +1 -0
- package/dist/formats/xml.js +321 -0
- package/dist/formats/xml.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/chunk.d.ts +51 -0
- package/dist/layout/chunk.d.ts.map +1 -0
- package/dist/layout/chunk.js +131 -0
- package/dist/layout/chunk.js.map +1 -0
- package/dist/layout/index.d.ts +238 -0
- package/dist/layout/index.d.ts.map +1 -0
- package/dist/layout/index.js +476 -0
- package/dist/layout/index.js.map +1 -0
- package/dist/reader/index.d.ts +231 -0
- package/dist/reader/index.d.ts.map +1 -0
- package/dist/reader/index.js +958 -0
- package/dist/reader/index.js.map +1 -0
- package/dist/reader/input.d.ts +69 -0
- package/dist/reader/input.d.ts.map +1 -0
- package/dist/reader/input.js +75 -0
- package/dist/reader/input.js.map +1 -0
- package/dist/search/extract.d.ts +51 -0
- package/dist/search/extract.d.ts.map +1 -0
- package/dist/search/extract.js +401 -0
- package/dist/search/extract.js.map +1 -0
- package/dist/search/index.d.ts +11 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +11 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/matcher.d.ts +56 -0
- package/dist/search/matcher.d.ts.map +1 -0
- package/dist/search/matcher.js +128 -0
- package/dist/search/matcher.js.map +1 -0
- package/dist/search/normalize.d.ts +48 -0
- package/dist/search/normalize.d.ts.map +1 -0
- package/dist/search/normalize.js +103 -0
- package/dist/search/normalize.js.map +1 -0
- package/dist/view/allowlist.d.ts +59 -0
- package/dist/view/allowlist.d.ts.map +1 -0
- package/dist/view/allowlist.js +198 -0
- package/dist/view/allowlist.js.map +1 -0
- package/dist/view/appearance.d.ts +137 -0
- package/dist/view/appearance.d.ts.map +1 -0
- package/dist/view/appearance.js +274 -0
- package/dist/view/appearance.js.map +1 -0
- package/dist/view/css.d.ts +24 -0
- package/dist/view/css.d.ts.map +1 -0
- package/dist/view/css.js +153 -0
- package/dist/view/css.js.map +1 -0
- package/dist/view/frame.d.ts +54 -0
- package/dist/view/frame.d.ts.map +1 -0
- package/dist/view/frame.js +980 -0
- package/dist/view/frame.js.map +1 -0
- package/dist/view/host.d.ts +160 -0
- package/dist/view/host.d.ts.map +1 -0
- package/dist/view/host.js +385 -0
- package/dist/view/host.js.map +1 -0
- package/dist/view/index.d.ts +12 -0
- package/dist/view/index.d.ts.map +1 -0
- package/dist/view/index.js +7 -0
- package/dist/view/index.js.map +1 -0
- package/dist/view/protocol.d.ts +220 -0
- package/dist/view/protocol.d.ts.map +1 -0
- package/dist/view/protocol.js +246 -0
- package/dist/view/protocol.js.map +1 -0
- package/dist/view/reference.d.ts +18 -0
- package/dist/view/reference.d.ts.map +1 -0
- package/dist/view/reference.js +25 -0
- package/dist/view/reference.js.map +1 -0
- package/dist/view/resources.d.ts +51 -0
- package/dist/view/resources.d.ts.map +1 -0
- package/dist/view/resources.js +242 -0
- package/dist/view/resources.js.map +1 -0
- package/dist/view/sanitize.d.ts +32 -0
- package/dist/view/sanitize.d.ts.map +1 -0
- package/dist/view/sanitize.js +226 -0
- package/dist/view/sanitize.js.map +1 -0
- package/dist/zip/central.d.ts +12 -0
- package/dist/zip/central.d.ts.map +1 -0
- package/dist/zip/central.js +72 -0
- package/dist/zip/central.js.map +1 -0
- package/dist/zip/crc32.d.ts +3 -0
- package/dist/zip/crc32.d.ts.map +1 -0
- package/dist/zip/crc32.js +28 -0
- package/dist/zip/crc32.js.map +1 -0
- package/dist/zip/eocd.d.ts +9 -0
- package/dist/zip/eocd.d.ts.map +1 -0
- package/dist/zip/eocd.js +84 -0
- package/dist/zip/eocd.js.map +1 -0
- package/dist/zip/errors.d.ts +19 -0
- package/dist/zip/errors.d.ts.map +1 -0
- package/dist/zip/errors.js +37 -0
- package/dist/zip/errors.js.map +1 -0
- package/dist/zip/index.d.ts +18 -0
- package/dist/zip/index.d.ts.map +1 -0
- package/dist/zip/index.js +78 -0
- package/dist/zip/index.js.map +1 -0
- package/dist/zip/inflate.d.ts +2 -0
- package/dist/zip/inflate.d.ts.map +1 -0
- package/dist/zip/inflate.js +43 -0
- package/dist/zip/inflate.js.map +1 -0
- package/dist/zip/source.d.ts +15 -0
- package/dist/zip/source.d.ts.map +1 -0
- package/dist/zip/source.js +34 -0
- package/dist/zip/source.js.map +1 -0
- package/package.json +75 -0
- package/src/core/book.ts +80 -0
- package/src/core/errors.ts +31 -0
- package/src/core/format.ts +14 -0
- package/src/core/graphemes.ts +73 -0
- package/src/core/index.ts +27 -0
- package/src/core/lookup.ts +16 -0
- package/src/core/open.ts +31 -0
- package/src/core/position.ts +485 -0
- package/src/core/reading-text.ts +200 -0
- package/src/core/source.ts +61 -0
- package/src/core/storage.ts +6 -0
- package/src/core/text.ts +35 -0
- package/src/formats/epub/href.ts +54 -0
- package/src/formats/epub/index.ts +235 -0
- package/src/formats/epub/opf.ts +143 -0
- package/src/formats/epub/toc.ts +120 -0
- package/src/formats/fb2/index.ts +348 -0
- package/src/formats/index.ts +3 -0
- package/src/formats/text/index.ts +208 -0
- package/src/formats/xml.ts +326 -0
- package/src/index.ts +26 -0
- package/src/layout/chunk.ts +169 -0
- package/src/layout/index.ts +573 -0
- package/src/reader/index.ts +1206 -0
- package/src/reader/input.ts +122 -0
- package/src/search/extract.ts +403 -0
- package/src/search/index.ts +10 -0
- package/src/search/matcher.ts +187 -0
- package/src/search/normalize.ts +122 -0
- package/src/view/allowlist.ts +273 -0
- package/src/view/appearance.ts +380 -0
- package/src/view/css.ts +172 -0
- package/src/view/frame.ts +1018 -0
- package/src/view/host.ts +527 -0
- package/src/view/index.ts +24 -0
- package/src/view/protocol.ts +412 -0
- package/src/view/reference.ts +30 -0
- package/src/view/resources.ts +265 -0
- package/src/view/sanitize.ts +266 -0
- package/src/zip/central.ts +85 -0
- package/src/zip/crc32.ts +30 -0
- package/src/zip/eocd.ts +97 -0
- package/src/zip/errors.ts +41 -0
- package/src/zip/index.ts +114 -0
- package/src/zip/inflate.ts +41 -0
- package/src/zip/source.ts +51 -0
package/src/view/host.ts
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
import type { Section } from '../core/index.ts';
|
|
2
|
+
import { chunkElement } from '../layout/chunk.ts';
|
|
3
|
+
import { assembleChunkedBody, assembleFrameDocument, createNonce } from './frame.ts';
|
|
4
|
+
import {
|
|
5
|
+
asFrameMessage,
|
|
6
|
+
PROTOCOL_VERSION,
|
|
7
|
+
type FrameMessage,
|
|
8
|
+
type Measurement,
|
|
9
|
+
type PaginateOptions,
|
|
10
|
+
type PaginationState,
|
|
11
|
+
} from './protocol.ts';
|
|
12
|
+
import { applyResources, ResourceRegistry, type ResourceSummary } from './resources.ts';
|
|
13
|
+
import { sanitizeSection, type SanitizationSummary } from './sanitize.ts';
|
|
14
|
+
import { decodeText } from '../core/text.ts';
|
|
15
|
+
|
|
16
|
+
/** Something went wrong hosting content; never a book decoding failure. */
|
|
17
|
+
export class ContentHostError extends Error {
|
|
18
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
19
|
+
super(message, options);
|
|
20
|
+
this.name = 'ContentHostError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ViolationReport {
|
|
25
|
+
readonly directive: string;
|
|
26
|
+
readonly blockedUri: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ContentHostOptions {
|
|
30
|
+
readonly onViolation?: (report: ViolationReport) => void;
|
|
31
|
+
readonly onError?: (message: string) => void;
|
|
32
|
+
/** An internal link was clicked in the frame; carries the raw authored href. */
|
|
33
|
+
readonly onLinkClick?: (href: string) => void;
|
|
34
|
+
/**
|
|
35
|
+
* A navigation-relevant keydown fired in the frame; carries the
|
|
36
|
+
* `KeyboardEvent.key`, except Space, which arrives as the normalized tokens
|
|
37
|
+
* `'Space'` / `'Shift+Space'` (the key is `' '` for both and the wire carries
|
|
38
|
+
* no modifier field).
|
|
39
|
+
*/
|
|
40
|
+
readonly onKey?: (key: string) => void;
|
|
41
|
+
/** A completed horizontal swipe in the frame; carries the net delta in CSS px. */
|
|
42
|
+
readonly onSwipe?: (dx: number, dy: number) => void;
|
|
43
|
+
/** A tap in the frame that was not on a link; carries tap coords + frame viewport size. */
|
|
44
|
+
readonly onTap?: (tap: { x: number; y: number; width: number; height: number }) => void;
|
|
45
|
+
/**
|
|
46
|
+
* A tap on a book image in the frame (not a link, not a page-turn). Carries the
|
|
47
|
+
* image's already-substituted `data:` URL and its `alt`, so a host can open a
|
|
48
|
+
* zoom overlay over the same full-resolution bytes. Never a `blob:` URL.
|
|
49
|
+
*/
|
|
50
|
+
readonly onImageTap?: (image: { src: string; alt: string }) => void;
|
|
51
|
+
/**
|
|
52
|
+
* A completed text selection in the frame; carries the selected text and its
|
|
53
|
+
* UTF-16 offset range over the section text (`sectionText`). Empty and collapsed
|
|
54
|
+
* selections are dropped in the frame and never reach here.
|
|
55
|
+
*/
|
|
56
|
+
readonly onSelection?: (selection: { start: number; end: number; text: string }) => void;
|
|
57
|
+
/**
|
|
58
|
+
* The appearance theme stylesheet injected at document assembly (see
|
|
59
|
+
* {@link themeStyleSheet}). Applied to every render; update it live with
|
|
60
|
+
* {@link ContentHost.setThemeCss} and re-render. Omit for an unthemed frame.
|
|
61
|
+
*/
|
|
62
|
+
readonly themeCss?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Whether the consumer acts on the nav keydowns the frame forwards (`onKey`).
|
|
65
|
+
* Baked into the coordination script at document assembly — not a wire
|
|
66
|
+
* message, so no protocol change. When `false` the frame still forwards nav
|
|
67
|
+
* keys (the wire is unconditional) but stops `preventDefault`ing them, so a
|
|
68
|
+
* key the host will ignore keeps its default action. Defaults to `true`.
|
|
69
|
+
*/
|
|
70
|
+
readonly keyboardNav?: boolean;
|
|
71
|
+
/** How long the frame has to answer, in milliseconds. Defaults to 10000. */
|
|
72
|
+
readonly timeoutMs?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RenderReport {
|
|
76
|
+
readonly sectionId: string;
|
|
77
|
+
/**
|
|
78
|
+
* The section's own `scripted` declaration. Diagnostic only: it is an author
|
|
79
|
+
* declaration, not a detection, so nothing in the pipeline depends on it.
|
|
80
|
+
*/
|
|
81
|
+
readonly declaredScripted: boolean;
|
|
82
|
+
readonly sanitization: SanitizationSummary;
|
|
83
|
+
readonly resources: ResourceSummary;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface Pending {
|
|
87
|
+
readonly settle: (message: FrameMessage) => void;
|
|
88
|
+
readonly fail: (error: Error) => void;
|
|
89
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Section-invariant render inputs, cached so a re-pagination of the *same*
|
|
94
|
+
* section (a font-size tick, a mode switch — the theme rides the srcdoc, so
|
|
95
|
+
* every appearance change re-assembles the document) reuses them instead of
|
|
96
|
+
* re-running decode → sanitize → resource minting. No image is re-base64'd on
|
|
97
|
+
* a reflow.
|
|
98
|
+
*
|
|
99
|
+
* This must not — and does not — weaken any of the three defences: everything
|
|
100
|
+
* cached sits strictly *downstream* of the sanitizer (the same sanitized
|
|
101
|
+
* output, byte for byte), resources stay the `data:` URLs the registry minted
|
|
102
|
+
* (never `blob:`), and the CSP nonce is still created fresh for every
|
|
103
|
+
* document assembly. Only the inputs are reused; the assembly is not.
|
|
104
|
+
*
|
|
105
|
+
* Invalidated when a different section renders and on {@link ContentHost.destroy}.
|
|
106
|
+
*/
|
|
107
|
+
interface SectionRenderCache {
|
|
108
|
+
readonly section: Section;
|
|
109
|
+
/** The sanitized, resource-applied document. Never mutated after creation. */
|
|
110
|
+
readonly document: Document;
|
|
111
|
+
readonly headHtml: string;
|
|
112
|
+
readonly sanitization: SanitizationSummary;
|
|
113
|
+
readonly resources: ResourceSummary;
|
|
114
|
+
readonly registry: ResourceRegistry;
|
|
115
|
+
/** Assembled body markup, memoized per body-assembly key (plain / chunked@N). */
|
|
116
|
+
readonly bodyHtml: Map<string, string>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Renders decoded sections as live markup inside a hardened sandboxed frame.
|
|
123
|
+
*
|
|
124
|
+
* Three independent defences, none of them redundant. The sandbox denies reach:
|
|
125
|
+
* without `allow-same-origin` the document has an opaque origin and cannot see
|
|
126
|
+
* the host page, its cookies or its storage. Sanitization denies execution:
|
|
127
|
+
* `allow-scripts` is granted to the document, not to a party, so book script
|
|
128
|
+
* would run under the same grant as the coordination script — and could then
|
|
129
|
+
* impersonate it on this very message channel, which no sandbox can prevent.
|
|
130
|
+
* CSP denies egress: nothing may be fetched at all except the `data:` resources
|
|
131
|
+
* this host minted, which reach nothing.
|
|
132
|
+
*/
|
|
133
|
+
export class ContentHost {
|
|
134
|
+
readonly frame: HTMLIFrameElement;
|
|
135
|
+
readonly #window: Window;
|
|
136
|
+
readonly #options: ContentHostOptions;
|
|
137
|
+
readonly #pending = new Map<number, Pending>();
|
|
138
|
+
#cache: SectionRenderCache | null = null;
|
|
139
|
+
#ready: (() => void) | null = null;
|
|
140
|
+
#nextId = 1;
|
|
141
|
+
#generation = 0;
|
|
142
|
+
#destroyed = false;
|
|
143
|
+
/** The theme stylesheet injected at document assembly; updated live by the facade. */
|
|
144
|
+
#themeCss: string | undefined;
|
|
145
|
+
|
|
146
|
+
readonly #receive = (event: MessageEvent): void => {
|
|
147
|
+
// The frame's origin is 'null' under an opaque origin, so origin checking
|
|
148
|
+
// authenticates nothing. Identity comes from the source window; meaning
|
|
149
|
+
// comes from validation. Anything else is ignored, never dispatched.
|
|
150
|
+
if (event.source === null || event.source !== this.frame.contentWindow) return;
|
|
151
|
+
const message = asFrameMessage(event.data);
|
|
152
|
+
if (message === null) return;
|
|
153
|
+
switch (message.type) {
|
|
154
|
+
case 'ready':
|
|
155
|
+
this.#ready?.();
|
|
156
|
+
return;
|
|
157
|
+
case 'violation':
|
|
158
|
+
this.#options.onViolation?.({ directive: message.directive, blockedUri: message.blockedUri });
|
|
159
|
+
return;
|
|
160
|
+
case 'error':
|
|
161
|
+
this.#options.onError?.(message.message);
|
|
162
|
+
return;
|
|
163
|
+
case 'linkclick':
|
|
164
|
+
this.#options.onLinkClick?.(message.href);
|
|
165
|
+
return;
|
|
166
|
+
case 'key':
|
|
167
|
+
this.#options.onKey?.(message.key);
|
|
168
|
+
return;
|
|
169
|
+
case 'swipe':
|
|
170
|
+
this.#options.onSwipe?.(message.dx, message.dy);
|
|
171
|
+
return;
|
|
172
|
+
case 'tap':
|
|
173
|
+
this.#options.onTap?.({ x: message.x, y: message.y, width: message.width, height: message.height });
|
|
174
|
+
return;
|
|
175
|
+
case 'imagetap':
|
|
176
|
+
this.#options.onImageTap?.({ src: message.src, alt: message.alt });
|
|
177
|
+
return;
|
|
178
|
+
case 'selection':
|
|
179
|
+
this.#options.onSelection?.({ start: message.start, end: message.end, text: message.text });
|
|
180
|
+
return;
|
|
181
|
+
case 'pong':
|
|
182
|
+
case 'measured':
|
|
183
|
+
case 'paginated':
|
|
184
|
+
case 'movedToPage':
|
|
185
|
+
case 'offset':
|
|
186
|
+
case 'page':
|
|
187
|
+
case 'text':
|
|
188
|
+
case 'decorated':
|
|
189
|
+
case 'diagnosticsReport': {
|
|
190
|
+
const pending = this.#pending.get(message.id);
|
|
191
|
+
if (pending === undefined) return;
|
|
192
|
+
this.#pending.delete(message.id);
|
|
193
|
+
clearTimeout(pending.timer);
|
|
194
|
+
pending.settle(message);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
constructor(container: HTMLElement, options: ContentHostOptions = {}) {
|
|
201
|
+
this.#options = options;
|
|
202
|
+
this.#themeCss = options.themeCss;
|
|
203
|
+
const view = container.ownerDocument.defaultView;
|
|
204
|
+
if (view === null) throw new ContentHostError('the container is not in a rendered document');
|
|
205
|
+
this.#window = view;
|
|
206
|
+
const frame = container.ownerDocument.createElement('iframe');
|
|
207
|
+
// Set before insertion. A frame that reaches the document without its
|
|
208
|
+
// sandbox attribute has already had one unsandboxed document.
|
|
209
|
+
frame.setAttribute('sandbox', 'allow-scripts');
|
|
210
|
+
frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
211
|
+
frame.setAttribute('title', 'book content');
|
|
212
|
+
frame.style.display = 'block';
|
|
213
|
+
frame.style.border = '0';
|
|
214
|
+
frame.style.width = '100%';
|
|
215
|
+
frame.style.height = '100%';
|
|
216
|
+
container.append(frame);
|
|
217
|
+
this.frame = frame;
|
|
218
|
+
view.addEventListener('message', this.#receive);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async render(section: Section): Promise<RenderReport> {
|
|
222
|
+
return this.#renderSection(section, 'plain', (document) => document.body.innerHTML);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Sets the theme stylesheet injected at the next document assembly. Does not
|
|
227
|
+
* re-render on its own — the caller re-renders (or re-paginates) to apply it.
|
|
228
|
+
* The theme lands in the srcdoc, not over the wire, so this changes no protocol.
|
|
229
|
+
*/
|
|
230
|
+
setThemeCss(themeCss: string | undefined): void {
|
|
231
|
+
this.#themeCss = themeCss;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The theme stylesheet currently applied to renders, or `undefined`. */
|
|
235
|
+
get themeCss(): string | undefined {
|
|
236
|
+
return this.#themeCss;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Renders a section chunked for pagination: each top-level chunk becomes its
|
|
241
|
+
* own container element carrying the character range it covers, so the frame
|
|
242
|
+
* can build a per-chunk multi-column context and map pages to offsets. The
|
|
243
|
+
* sanitize + resources pipeline is identical to {@link render}; only the body
|
|
244
|
+
* assembly differs. Call {@link paginate} afterward to lay it out.
|
|
245
|
+
*/
|
|
246
|
+
async renderChunked(section: Section, chunkChars?: number): Promise<RenderReport> {
|
|
247
|
+
return this.#renderSection(section, `chunked@${chunkChars ?? 'default'}`, (document) => {
|
|
248
|
+
const { chunks } = chunkElement(document.body, chunkChars);
|
|
249
|
+
return assembleChunkedBody(chunks.map((chunk) => ({ html: chunk.html, chars: chunk.chars })));
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The shared render path: bump the generation, obtain the section-invariant
|
|
255
|
+
* render inputs (from the cache when the same section renders again — a
|
|
256
|
+
* reflow — or by running the sanitize + resources pipeline), then hand the
|
|
257
|
+
* sanitized document's body to `buildBody` to produce the frame body markup.
|
|
258
|
+
* `render` passes it through verbatim; `renderChunked` re-wraps it in chunk
|
|
259
|
+
* containers; both memoize their output under `bodyKey`. The CSP nonce is
|
|
260
|
+
* minted fresh for every assembly regardless of cache hits.
|
|
261
|
+
*/
|
|
262
|
+
async #renderSection(
|
|
263
|
+
section: Section,
|
|
264
|
+
bodyKey: string,
|
|
265
|
+
buildBody: (document: Document) => string,
|
|
266
|
+
): Promise<RenderReport> {
|
|
267
|
+
if (this.#destroyed) throw new ContentHostError('the host has been destroyed');
|
|
268
|
+
const generation = (this.#generation += 1);
|
|
269
|
+
|
|
270
|
+
let cache = this.#cache;
|
|
271
|
+
if (cache === null || cache.section !== section) {
|
|
272
|
+
// A different section: the outgoing section's inputs are invalidated and
|
|
273
|
+
// its registry released before anything new is built.
|
|
274
|
+
this.#cache?.registry.release();
|
|
275
|
+
this.#cache = null;
|
|
276
|
+
|
|
277
|
+
const source = decodeText(await section.load());
|
|
278
|
+
this.#checkGeneration(generation);
|
|
279
|
+
// Sanitization is unconditional. Section.scripted is an author declaration
|
|
280
|
+
// that a hostile book simply omits, so gating on it would skip exactly the
|
|
281
|
+
// files that need it most.
|
|
282
|
+
const sanitized = sanitizeSection(source, section.mediaType);
|
|
283
|
+
const registry = new ResourceRegistry(section.resolve?.bind(section));
|
|
284
|
+
let resources: ResourceSummary;
|
|
285
|
+
try {
|
|
286
|
+
resources = await applyResources(sanitized.document, registry);
|
|
287
|
+
// Checked before the cache is written, so a superseded render can never
|
|
288
|
+
// clobber the cache a later render installed.
|
|
289
|
+
this.#checkGeneration(generation);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
registry.release();
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
cache = {
|
|
295
|
+
section,
|
|
296
|
+
document: sanitized.document,
|
|
297
|
+
headHtml: sanitized.document.head.innerHTML,
|
|
298
|
+
sanitization: sanitized.summary,
|
|
299
|
+
resources,
|
|
300
|
+
registry,
|
|
301
|
+
bodyHtml: new Map(),
|
|
302
|
+
};
|
|
303
|
+
this.#cache = cache;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let bodyHtml = cache.bodyHtml.get(bodyKey);
|
|
307
|
+
if (bodyHtml === undefined) {
|
|
308
|
+
bodyHtml = buildBody(cache.document);
|
|
309
|
+
cache.bodyHtml.set(bodyKey, bodyHtml);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await this.#load(
|
|
313
|
+
assembleFrameDocument({
|
|
314
|
+
headHtml: cache.headHtml,
|
|
315
|
+
bodyHtml,
|
|
316
|
+
nonce: createNonce(),
|
|
317
|
+
hostOrigin: this.#targetOrigin(),
|
|
318
|
+
...(this.#themeCss !== undefined ? { themeCss: this.#themeCss } : {}),
|
|
319
|
+
...(this.#options.keyboardNav !== undefined ? { keyboardNav: this.#options.keyboardNav } : {}),
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
this.#checkGeneration(generation);
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
sectionId: section.id,
|
|
326
|
+
declaredScripted: section.scripted === true,
|
|
327
|
+
sanitization: cache.sanitization,
|
|
328
|
+
resources: cache.resources,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Lays the currently-rendered chunked section out under `options` and returns
|
|
334
|
+
* the resulting pagination state. In paginated mode each chunk becomes an
|
|
335
|
+
* absolutely-positioned multi-column context and the page count is the sum of
|
|
336
|
+
* per-chunk page counts; in scrolled mode chunks stack with no paging. Requires
|
|
337
|
+
* a prior {@link renderChunked}.
|
|
338
|
+
*/
|
|
339
|
+
async paginate(section: Section, options: PaginateOptions): Promise<PaginationState> {
|
|
340
|
+
await this.renderChunked(section, options.chunkChars);
|
|
341
|
+
const reply = await this.#request('paginate', { options });
|
|
342
|
+
if (reply.type !== 'paginated') {
|
|
343
|
+
throw new ContentHostError('the frame answered paginate with the wrong message');
|
|
344
|
+
}
|
|
345
|
+
return reply.state;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Re-measures the current layout (e.g. after a viewport or typography change). */
|
|
349
|
+
async relayout(): Promise<PaginationState> {
|
|
350
|
+
const reply = await this.#request('relayout');
|
|
351
|
+
if (reply.type !== 'paginated') {
|
|
352
|
+
throw new ContentHostError('the frame answered relayout with the wrong message');
|
|
353
|
+
}
|
|
354
|
+
return reply.state;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Scrolls to a page (0-based). Returns the page actually shown after clamping. */
|
|
358
|
+
async goToPage(page: number): Promise<number> {
|
|
359
|
+
const reply = await this.#request('goToPage', { page });
|
|
360
|
+
if (reply.type !== 'movedToPage') {
|
|
361
|
+
throw new ContentHostError('the frame answered goToPage with the wrong message');
|
|
362
|
+
}
|
|
363
|
+
return reply.page;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Character offset of the first glyph painted on a page, or -1 if none. */
|
|
367
|
+
async offsetOfPage(page: number): Promise<number> {
|
|
368
|
+
const reply = await this.#request('offsetOfPage', { page });
|
|
369
|
+
if (reply.type !== 'offset') {
|
|
370
|
+
throw new ContentHostError('the frame answered offsetOfPage with the wrong message');
|
|
371
|
+
}
|
|
372
|
+
return reply.offset;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Character offset of the element carrying `elementId` into the section text,
|
|
377
|
+
* or -1 if no element carries the id. The seam behind fragment anchoring.
|
|
378
|
+
*/
|
|
379
|
+
async offsetOfElementId(elementId: string): Promise<number> {
|
|
380
|
+
const reply = await this.#request('offsetOfElementId', { elementId });
|
|
381
|
+
if (reply.type !== 'offset') {
|
|
382
|
+
throw new ContentHostError('the frame answered offsetOfElementId with the wrong message');
|
|
383
|
+
}
|
|
384
|
+
return reply.offset;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The page (0-based) painting the glyph at a section-text character offset. */
|
|
388
|
+
async pageOfOffset(offset: number): Promise<number> {
|
|
389
|
+
const reply = await this.#request('pageOfOffset', { offset });
|
|
390
|
+
if (reply.type !== 'page') {
|
|
391
|
+
throw new ContentHostError('the frame answered pageOfOffset with the wrong message');
|
|
392
|
+
}
|
|
393
|
+
return reply.page;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The section's concatenated chunk text, as the frame measures it. */
|
|
397
|
+
async sectionText(): Promise<string> {
|
|
398
|
+
const reply = await this.#request('sectionText');
|
|
399
|
+
if (reply.type !== 'text') {
|
|
400
|
+
throw new ContentHostError('the frame answered sectionText with the wrong message');
|
|
401
|
+
}
|
|
402
|
+
return reply.text;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Frame-side counts for eviction/memory checks. */
|
|
406
|
+
async diagnostics(): Promise<{ domNodes: number; realizedChunks: number; totalChunks: number }> {
|
|
407
|
+
const reply = await this.#request('diagnostics');
|
|
408
|
+
if (reply.type !== 'diagnosticsReport') {
|
|
409
|
+
throw new ContentHostError('the frame answered diagnostics with the wrong message');
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
domNodes: reply.domNodes,
|
|
413
|
+
realizedChunks: reply.realizedChunks,
|
|
414
|
+
totalChunks: reply.totalChunks,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Draws a decoration over the section-text UTF-16 offset range `[start, end)`:
|
|
420
|
+
* the frame maps the range to client rects and paints pointer-transparent,
|
|
421
|
+
* layout-neutral overlay boxes carrying `className`. Re-issuing the same
|
|
422
|
+
* `decorationId` replaces that overlay. Returns the number of boxes painted — 0
|
|
423
|
+
* on a soft-miss (the range is not in the realized section text, or its rects are
|
|
424
|
+
* empty); a soft-miss draws nothing and is not an error.
|
|
425
|
+
*/
|
|
426
|
+
async decorate(decorationId: string, start: number, end: number, className: string): Promise<number> {
|
|
427
|
+
const reply = await this.#request('decorate', { decorationId, start, end, className });
|
|
428
|
+
if (reply.type !== 'decorated') {
|
|
429
|
+
throw new ContentHostError('the frame answered decorate with the wrong message');
|
|
430
|
+
}
|
|
431
|
+
return reply.boxes;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Removes the decoration painted for `decorationId`, leaving nothing behind. */
|
|
435
|
+
async undecorate(decorationId: string): Promise<void> {
|
|
436
|
+
const reply = await this.#request('undecorate', { decorationId });
|
|
437
|
+
if (reply.type !== 'decorated') {
|
|
438
|
+
throw new ContentHostError('the frame answered undecorate with the wrong message');
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Content size as the frame measures it. */
|
|
443
|
+
async measure(): Promise<Measurement> {
|
|
444
|
+
const reply = await this.#request('measure');
|
|
445
|
+
if (reply.type !== 'measured') throw new ContentHostError('the frame answered measure with the wrong message');
|
|
446
|
+
return { width: reply.width, height: reply.height };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Round-trips the protocol; the cheapest proof the channel is live. */
|
|
450
|
+
async ping(): Promise<void> {
|
|
451
|
+
await this.#request('ping');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Releases this render's resources and removes the frame. */
|
|
455
|
+
destroy(): void {
|
|
456
|
+
if (this.#destroyed) return;
|
|
457
|
+
this.#destroyed = true;
|
|
458
|
+
this.#generation += 1;
|
|
459
|
+
this.#window.removeEventListener('message', this.#receive);
|
|
460
|
+
for (const [id, pending] of [...this.#pending]) {
|
|
461
|
+
clearTimeout(pending.timer);
|
|
462
|
+
this.#pending.delete(id);
|
|
463
|
+
pending.fail(new ContentHostError('the host has been destroyed'));
|
|
464
|
+
}
|
|
465
|
+
this.#ready = null;
|
|
466
|
+
this.#cache?.registry.release();
|
|
467
|
+
this.#cache = null;
|
|
468
|
+
this.frame.remove();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
#timeout(): number {
|
|
472
|
+
return this.#options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#targetOrigin(): string {
|
|
476
|
+
const origin = this.#window.origin;
|
|
477
|
+
return origin === '' || origin === 'null' ? '*' : origin;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#checkGeneration(generation: number): void {
|
|
481
|
+
if (generation !== this.#generation) throw new ContentHostError('the render was superseded by a later one');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#load(html: string): Promise<void> {
|
|
485
|
+
return new Promise((resolve, reject) => {
|
|
486
|
+
const timer = setTimeout(() => {
|
|
487
|
+
this.#ready = null;
|
|
488
|
+
reject(new ContentHostError('the frame did not report ready'));
|
|
489
|
+
}, this.#timeout());
|
|
490
|
+
this.#ready = (): void => {
|
|
491
|
+
clearTimeout(timer);
|
|
492
|
+
this.#ready = null;
|
|
493
|
+
resolve();
|
|
494
|
+
};
|
|
495
|
+
this.frame.srcdoc = html;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#request(
|
|
500
|
+
type: 'measure' | 'ping' | 'relayout' | 'sectionText' | 'diagnostics',
|
|
501
|
+
): Promise<FrameMessage>;
|
|
502
|
+
#request(type: 'paginate', extras: { options: PaginateOptions }): Promise<FrameMessage>;
|
|
503
|
+
#request(type: 'goToPage' | 'offsetOfPage', extras: { page: number }): Promise<FrameMessage>;
|
|
504
|
+
#request(type: 'pageOfOffset', extras: { offset: number }): Promise<FrameMessage>;
|
|
505
|
+
#request(type: 'offsetOfElementId', extras: { elementId: string }): Promise<FrameMessage>;
|
|
506
|
+
#request(
|
|
507
|
+
type: 'decorate',
|
|
508
|
+
extras: { decorationId: string; start: number; end: number; className: string },
|
|
509
|
+
): Promise<FrameMessage>;
|
|
510
|
+
#request(type: 'undecorate', extras: { decorationId: string }): Promise<FrameMessage>;
|
|
511
|
+
#request(type: string, extras: Record<string, unknown> = {}): Promise<FrameMessage> {
|
|
512
|
+
if (this.#destroyed) return Promise.reject(new ContentHostError('the host has been destroyed'));
|
|
513
|
+
const target = this.frame.contentWindow;
|
|
514
|
+
if (target === null) return Promise.reject(new ContentHostError('the frame has no document yet'));
|
|
515
|
+
const id = (this.#nextId += 1);
|
|
516
|
+
return new Promise<FrameMessage>((settle, fail) => {
|
|
517
|
+
const timer = setTimeout(() => {
|
|
518
|
+
this.#pending.delete(id);
|
|
519
|
+
fail(new ContentHostError(`the frame did not answer ${type}`));
|
|
520
|
+
}, this.#timeout());
|
|
521
|
+
this.#pending.set(id, { settle, fail, timer });
|
|
522
|
+
// The frame's origin is opaque, so '*' is the only targetOrigin that can
|
|
523
|
+
// reach it. The payload carries nothing confidential for that reason.
|
|
524
|
+
target.postMessage({ v: PROTOCOL_VERSION, type, id, ...extras }, '*');
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { CONTENT_ROOT_ID } from './frame.ts';
|
|
2
|
+
export { ContentHost, ContentHostError } from './host.ts';
|
|
3
|
+
export type { ContentHostOptions, RenderReport, ViolationReport } from './host.ts';
|
|
4
|
+
export { asFrameMessage, asHostMessage, PROTOCOL_VERSION } from './protocol.ts';
|
|
5
|
+
export type { FrameMessage, HostMessage, Measurement } from './protocol.ts';
|
|
6
|
+
export { applyResources, ResourceRegistry, UNRESOLVABLE_URL } from './resources.ts';
|
|
7
|
+
export type { ReferenceResolver, ResourceSummary } from './resources.ts';
|
|
8
|
+
export { sanitizeSection } from './sanitize.ts';
|
|
9
|
+
export type { RemovalCount, SanitizationSummary, SanitizedSection } from './sanitize.ts';
|
|
10
|
+
export {
|
|
11
|
+
isReflowingUpdate,
|
|
12
|
+
mergeAppearance,
|
|
13
|
+
resolveThemeProperties,
|
|
14
|
+
resolveTypographyProperties,
|
|
15
|
+
THEMES,
|
|
16
|
+
themeStyleSheet,
|
|
17
|
+
} from './appearance.ts';
|
|
18
|
+
export type {
|
|
19
|
+
Appearance,
|
|
20
|
+
TextAlign,
|
|
21
|
+
ThemeName,
|
|
22
|
+
ThemeVariables,
|
|
23
|
+
TypographyVariables,
|
|
24
|
+
} from './appearance.ts';
|