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.
Files changed (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +277 -0
  3. package/dist/core/book.d.ts +75 -0
  4. package/dist/core/book.d.ts.map +1 -0
  5. package/dist/core/book.js +2 -0
  6. package/dist/core/book.js.map +1 -0
  7. package/dist/core/errors.d.ts +17 -0
  8. package/dist/core/errors.d.ts.map +1 -0
  9. package/dist/core/errors.js +29 -0
  10. package/dist/core/errors.js.map +1 -0
  11. package/dist/core/format.d.ts +13 -0
  12. package/dist/core/format.d.ts.map +1 -0
  13. package/dist/core/format.js +2 -0
  14. package/dist/core/format.js.map +1 -0
  15. package/dist/core/graphemes.d.ts +36 -0
  16. package/dist/core/graphemes.d.ts.map +1 -0
  17. package/dist/core/graphemes.js +62 -0
  18. package/dist/core/graphemes.js.map +1 -0
  19. package/dist/core/index.d.ts +11 -0
  20. package/dist/core/index.d.ts.map +1 -0
  21. package/dist/core/index.js +6 -0
  22. package/dist/core/index.js.map +1 -0
  23. package/dist/core/lookup.d.ts +10 -0
  24. package/dist/core/lookup.d.ts.map +1 -0
  25. package/dist/core/lookup.js +16 -0
  26. package/dist/core/lookup.js.map +1 -0
  27. package/dist/core/open.d.ts +12 -0
  28. package/dist/core/open.d.ts.map +1 -0
  29. package/dist/core/open.js +23 -0
  30. package/dist/core/open.js.map +1 -0
  31. package/dist/core/position.d.ts +121 -0
  32. package/dist/core/position.d.ts.map +1 -0
  33. package/dist/core/position.js +327 -0
  34. package/dist/core/position.js.map +1 -0
  35. package/dist/core/reading-text.d.ts +77 -0
  36. package/dist/core/reading-text.d.ts.map +1 -0
  37. package/dist/core/reading-text.js +144 -0
  38. package/dist/core/reading-text.js.map +1 -0
  39. package/dist/core/source.d.ts +17 -0
  40. package/dist/core/source.d.ts.map +1 -0
  41. package/dist/core/source.js +39 -0
  42. package/dist/core/source.js.map +1 -0
  43. package/dist/core/storage.d.ts +7 -0
  44. package/dist/core/storage.d.ts.map +1 -0
  45. package/dist/core/storage.js +2 -0
  46. package/dist/core/storage.js.map +1 -0
  47. package/dist/core/text.d.ts +18 -0
  48. package/dist/core/text.d.ts.map +1 -0
  49. package/dist/core/text.js +32 -0
  50. package/dist/core/text.js.map +1 -0
  51. package/dist/formats/epub/href.d.ts +14 -0
  52. package/dist/formats/epub/href.d.ts.map +1 -0
  53. package/dist/formats/epub/href.js +52 -0
  54. package/dist/formats/epub/href.js.map +1 -0
  55. package/dist/formats/epub/index.d.ts +3 -0
  56. package/dist/formats/epub/index.d.ts.map +1 -0
  57. package/dist/formats/epub/index.js +223 -0
  58. package/dist/formats/epub/index.js.map +1 -0
  59. package/dist/formats/epub/opf.d.ts +27 -0
  60. package/dist/formats/epub/opf.d.ts.map +1 -0
  61. package/dist/formats/epub/opf.js +110 -0
  62. package/dist/formats/epub/opf.js.map +1 -0
  63. package/dist/formats/epub/toc.d.ts +6 -0
  64. package/dist/formats/epub/toc.d.ts.map +1 -0
  65. package/dist/formats/epub/toc.js +88 -0
  66. package/dist/formats/epub/toc.js.map +1 -0
  67. package/dist/formats/fb2/index.d.ts +13 -0
  68. package/dist/formats/fb2/index.d.ts.map +1 -0
  69. package/dist/formats/fb2/index.js +317 -0
  70. package/dist/formats/fb2/index.js.map +1 -0
  71. package/dist/formats/index.d.ts +4 -0
  72. package/dist/formats/index.d.ts.map +1 -0
  73. package/dist/formats/index.js +4 -0
  74. package/dist/formats/index.js.map +1 -0
  75. package/dist/formats/text/index.d.ts +11 -0
  76. package/dist/formats/text/index.d.ts.map +1 -0
  77. package/dist/formats/text/index.js +201 -0
  78. package/dist/formats/text/index.js.map +1 -0
  79. package/dist/formats/xml.d.ts +33 -0
  80. package/dist/formats/xml.d.ts.map +1 -0
  81. package/dist/formats/xml.js +321 -0
  82. package/dist/formats/xml.js.map +1 -0
  83. package/dist/index.d.ts +27 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +28 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/layout/chunk.d.ts +51 -0
  88. package/dist/layout/chunk.d.ts.map +1 -0
  89. package/dist/layout/chunk.js +131 -0
  90. package/dist/layout/chunk.js.map +1 -0
  91. package/dist/layout/index.d.ts +238 -0
  92. package/dist/layout/index.d.ts.map +1 -0
  93. package/dist/layout/index.js +476 -0
  94. package/dist/layout/index.js.map +1 -0
  95. package/dist/reader/index.d.ts +231 -0
  96. package/dist/reader/index.d.ts.map +1 -0
  97. package/dist/reader/index.js +958 -0
  98. package/dist/reader/index.js.map +1 -0
  99. package/dist/reader/input.d.ts +69 -0
  100. package/dist/reader/input.d.ts.map +1 -0
  101. package/dist/reader/input.js +75 -0
  102. package/dist/reader/input.js.map +1 -0
  103. package/dist/search/extract.d.ts +51 -0
  104. package/dist/search/extract.d.ts.map +1 -0
  105. package/dist/search/extract.js +401 -0
  106. package/dist/search/extract.js.map +1 -0
  107. package/dist/search/index.d.ts +11 -0
  108. package/dist/search/index.d.ts.map +1 -0
  109. package/dist/search/index.js +11 -0
  110. package/dist/search/index.js.map +1 -0
  111. package/dist/search/matcher.d.ts +56 -0
  112. package/dist/search/matcher.d.ts.map +1 -0
  113. package/dist/search/matcher.js +128 -0
  114. package/dist/search/matcher.js.map +1 -0
  115. package/dist/search/normalize.d.ts +48 -0
  116. package/dist/search/normalize.d.ts.map +1 -0
  117. package/dist/search/normalize.js +103 -0
  118. package/dist/search/normalize.js.map +1 -0
  119. package/dist/view/allowlist.d.ts +59 -0
  120. package/dist/view/allowlist.d.ts.map +1 -0
  121. package/dist/view/allowlist.js +198 -0
  122. package/dist/view/allowlist.js.map +1 -0
  123. package/dist/view/appearance.d.ts +137 -0
  124. package/dist/view/appearance.d.ts.map +1 -0
  125. package/dist/view/appearance.js +274 -0
  126. package/dist/view/appearance.js.map +1 -0
  127. package/dist/view/css.d.ts +24 -0
  128. package/dist/view/css.d.ts.map +1 -0
  129. package/dist/view/css.js +153 -0
  130. package/dist/view/css.js.map +1 -0
  131. package/dist/view/frame.d.ts +54 -0
  132. package/dist/view/frame.d.ts.map +1 -0
  133. package/dist/view/frame.js +980 -0
  134. package/dist/view/frame.js.map +1 -0
  135. package/dist/view/host.d.ts +160 -0
  136. package/dist/view/host.d.ts.map +1 -0
  137. package/dist/view/host.js +385 -0
  138. package/dist/view/host.js.map +1 -0
  139. package/dist/view/index.d.ts +12 -0
  140. package/dist/view/index.d.ts.map +1 -0
  141. package/dist/view/index.js +7 -0
  142. package/dist/view/index.js.map +1 -0
  143. package/dist/view/protocol.d.ts +220 -0
  144. package/dist/view/protocol.d.ts.map +1 -0
  145. package/dist/view/protocol.js +246 -0
  146. package/dist/view/protocol.js.map +1 -0
  147. package/dist/view/reference.d.ts +18 -0
  148. package/dist/view/reference.d.ts.map +1 -0
  149. package/dist/view/reference.js +25 -0
  150. package/dist/view/reference.js.map +1 -0
  151. package/dist/view/resources.d.ts +51 -0
  152. package/dist/view/resources.d.ts.map +1 -0
  153. package/dist/view/resources.js +242 -0
  154. package/dist/view/resources.js.map +1 -0
  155. package/dist/view/sanitize.d.ts +32 -0
  156. package/dist/view/sanitize.d.ts.map +1 -0
  157. package/dist/view/sanitize.js +226 -0
  158. package/dist/view/sanitize.js.map +1 -0
  159. package/dist/zip/central.d.ts +12 -0
  160. package/dist/zip/central.d.ts.map +1 -0
  161. package/dist/zip/central.js +72 -0
  162. package/dist/zip/central.js.map +1 -0
  163. package/dist/zip/crc32.d.ts +3 -0
  164. package/dist/zip/crc32.d.ts.map +1 -0
  165. package/dist/zip/crc32.js +28 -0
  166. package/dist/zip/crc32.js.map +1 -0
  167. package/dist/zip/eocd.d.ts +9 -0
  168. package/dist/zip/eocd.d.ts.map +1 -0
  169. package/dist/zip/eocd.js +84 -0
  170. package/dist/zip/eocd.js.map +1 -0
  171. package/dist/zip/errors.d.ts +19 -0
  172. package/dist/zip/errors.d.ts.map +1 -0
  173. package/dist/zip/errors.js +37 -0
  174. package/dist/zip/errors.js.map +1 -0
  175. package/dist/zip/index.d.ts +18 -0
  176. package/dist/zip/index.d.ts.map +1 -0
  177. package/dist/zip/index.js +78 -0
  178. package/dist/zip/index.js.map +1 -0
  179. package/dist/zip/inflate.d.ts +2 -0
  180. package/dist/zip/inflate.d.ts.map +1 -0
  181. package/dist/zip/inflate.js +43 -0
  182. package/dist/zip/inflate.js.map +1 -0
  183. package/dist/zip/source.d.ts +15 -0
  184. package/dist/zip/source.d.ts.map +1 -0
  185. package/dist/zip/source.js +34 -0
  186. package/dist/zip/source.js.map +1 -0
  187. package/package.json +75 -0
  188. package/src/core/book.ts +80 -0
  189. package/src/core/errors.ts +31 -0
  190. package/src/core/format.ts +14 -0
  191. package/src/core/graphemes.ts +73 -0
  192. package/src/core/index.ts +27 -0
  193. package/src/core/lookup.ts +16 -0
  194. package/src/core/open.ts +31 -0
  195. package/src/core/position.ts +485 -0
  196. package/src/core/reading-text.ts +200 -0
  197. package/src/core/source.ts +61 -0
  198. package/src/core/storage.ts +6 -0
  199. package/src/core/text.ts +35 -0
  200. package/src/formats/epub/href.ts +54 -0
  201. package/src/formats/epub/index.ts +235 -0
  202. package/src/formats/epub/opf.ts +143 -0
  203. package/src/formats/epub/toc.ts +120 -0
  204. package/src/formats/fb2/index.ts +348 -0
  205. package/src/formats/index.ts +3 -0
  206. package/src/formats/text/index.ts +208 -0
  207. package/src/formats/xml.ts +326 -0
  208. package/src/index.ts +26 -0
  209. package/src/layout/chunk.ts +169 -0
  210. package/src/layout/index.ts +573 -0
  211. package/src/reader/index.ts +1206 -0
  212. package/src/reader/input.ts +122 -0
  213. package/src/search/extract.ts +403 -0
  214. package/src/search/index.ts +10 -0
  215. package/src/search/matcher.ts +187 -0
  216. package/src/search/normalize.ts +122 -0
  217. package/src/view/allowlist.ts +273 -0
  218. package/src/view/appearance.ts +380 -0
  219. package/src/view/css.ts +172 -0
  220. package/src/view/frame.ts +1018 -0
  221. package/src/view/host.ts +527 -0
  222. package/src/view/index.ts +24 -0
  223. package/src/view/protocol.ts +412 -0
  224. package/src/view/reference.ts +30 -0
  225. package/src/view/resources.ts +265 -0
  226. package/src/view/sanitize.ts +266 -0
  227. package/src/zip/central.ts +85 -0
  228. package/src/zip/crc32.ts +30 -0
  229. package/src/zip/eocd.ts +97 -0
  230. package/src/zip/errors.ts +41 -0
  231. package/src/zip/index.ts +114 -0
  232. package/src/zip/inflate.ts +41 -0
  233. package/src/zip/source.ts +51 -0
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The headless full-text matcher. Normalized literal substring search over a
3
+ * section's extracted text, yielding jumpable hits. Imports only `src/core`
4
+ * (`capturePosition`, `Position`, `Book`, `Section`) and platform primitives, so it
5
+ * runs under `node:test` with no browser.
6
+ *
7
+ * The whole-book scan is a lazy async generator: it decodes and scans one section at
8
+ * a time and yields hits as it goes, so the book is never buffered whole and a
9
+ * consumer that `break`s stops the scan (the generator's cleanup runs on return).
10
+ */
11
+ import { capturePosition, type Book, type Position, type Section } from '../core/index.ts';
12
+ import { collapseWhitespace as collapse } from '../core/text.ts';
13
+
14
+ import { extractSectionText } from './extract.ts';
15
+ import { normalizeQuery, normalizeText } from './normalize.ts';
16
+
17
+ /** One search hit: the matched text, a clean context window, and a jumpable position. */
18
+ export interface SearchHit {
19
+ /** The verbatim matched run, in the section's raw text. */
20
+ readonly text: string;
21
+ /**
22
+ * The match surrounded by context, trimmed to whole words at both ends via
23
+ * `Intl.Segmenter` — no leading/trailing partial word.
24
+ */
25
+ readonly context: string;
26
+ /**
27
+ * A content-addressed `Position` at the match, built with `capturePosition`. Pass
28
+ * it to `reader.goTo(hit.position)` to land on the hit's page.
29
+ */
30
+ readonly position: Position;
31
+ /** 0-based index of the section this hit falls in, in reading order. */
32
+ readonly sectionIndex: number;
33
+ }
34
+
35
+ export interface SearchOptions {
36
+ /**
37
+ * Characters of surrounding context to aim for on each side of the match before
38
+ * word-boundary trimming. Default 40.
39
+ */
40
+ readonly contextRadius?: number;
41
+ /** Locale for word segmentation of the context. Defaults to the runtime default. */
42
+ readonly locale?: string;
43
+ }
44
+
45
+ const DEFAULT_CONTEXT_RADIUS = 40;
46
+
47
+ /**
48
+ * All hits for `query` within one section's already-extracted `rawText`. Pure over
49
+ * strings — no I/O — so it is the unit the section scan and the tests both drive.
50
+ * A blank (whitespace-only or empty) query yields nothing.
51
+ */
52
+ export function* matchText(
53
+ rawText: string,
54
+ query: string,
55
+ sectionId: string,
56
+ sectionIndex: number,
57
+ options: SearchOptions = {},
58
+ ): Generator<SearchHit> {
59
+ const needle = normalizeQuery(query);
60
+ if (needle === '') return;
61
+
62
+ const { text: haystack, map } = normalizeText(rawText);
63
+ const radius = options.contextRadius ?? DEFAULT_CONTEXT_RADIUS;
64
+
65
+ let from = 0;
66
+ for (;;) {
67
+ const at = haystack.indexOf(needle, from);
68
+ if (at === -1) return;
69
+ const end = at + needle.length;
70
+
71
+ // Map the normalized match span back onto the raw text. `map[end]` points at the
72
+ // start of the character *after* the match, which — when that character is a
73
+ // collapsed space — lies past a run of raw whitespace; trim it so the matched
74
+ // text ends on real content. The anchor still starts at `rawStart`.
75
+ const rawStart = map[at]!;
76
+ const rawEnd = trimEnd(rawText, rawStart, map[end]!);
77
+ const matched = rawText.slice(rawStart, rawEnd);
78
+
79
+ // Context window in normalized space, then mapped back and word-trimmed on the
80
+ // raw text so the shown context carries no partial word at either edge.
81
+ const ctxNormStart = Math.max(0, at - radius);
82
+ const ctxNormEnd = Math.min(haystack.length, end + radius);
83
+ const rawCtxStart = map[ctxNormStart]!;
84
+ const rawCtxEnd = map[ctxNormEnd]!;
85
+ const context = trimToWords(
86
+ rawText,
87
+ rawCtxStart,
88
+ rawCtxEnd,
89
+ rawStart,
90
+ rawEnd,
91
+ options.locale,
92
+ );
93
+
94
+ yield {
95
+ text: matched,
96
+ context,
97
+ position: capturePosition(rawText, rawStart, sectionId),
98
+ sectionIndex,
99
+ };
100
+
101
+ // Advance past this match; guard against a zero-length needle (impossible here,
102
+ // an empty needle returned above) so the loop always progresses.
103
+ from = end > at ? end : at + 1;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Trims a raw context window `[start, end)` inward to whole-word boundaries via
109
+ * `Intl.Segmenter`, never cutting into the match span `[matchStart, matchEnd)`. A
110
+ * word straddling the left edge is dropped forward to its end (the next word's
111
+ * start); a word straddling the right edge is dropped back to its start. The result
112
+ * is whitespace-collapsed for a compact one-line context.
113
+ */
114
+ function trimToWords(
115
+ text: string,
116
+ start: number,
117
+ end: number,
118
+ matchStart: number,
119
+ matchEnd: number,
120
+ locale: string | undefined,
121
+ ): string {
122
+ // Segment a slice wider than the window so a word straddling an edge is visible
123
+ // whole. Offsets are relative to the wide slice, so add `lo`.
124
+ const lo = Math.max(0, Math.min(start, matchStart) - 24);
125
+ const hi = Math.min(text.length, Math.max(end, matchEnd) + 24);
126
+ let head = start;
127
+ let tail = end;
128
+ for (const seg of wordSegmenter(locale).segment(text.slice(lo, hi))) {
129
+ if (!seg.isWordLike) continue;
130
+ const segStart = lo + seg.index;
131
+ const segEnd = segStart + seg.segment.length;
132
+ // A word-like segment straddling the left edge: move head to its end (the next
133
+ // word's start), but never past the match start.
134
+ if (segStart < start && segEnd > start) head = Math.min(matchStart, segEnd);
135
+ // A word-like segment straddling the right edge: move tail to its start, but
136
+ // never before the match end.
137
+ if (segStart < end && segEnd > end) tail = Math.max(matchEnd, segStart);
138
+ }
139
+ return collapse(text.slice(head, tail));
140
+ }
141
+
142
+ /** Pull `end` back past any trailing whitespace, never before `start`. */
143
+ function trimEnd(text: string, start: number, end: number): number {
144
+ let at = end;
145
+ while (at > start && /\s/.test(text[at - 1]!)) at -= 1;
146
+ return at;
147
+ }
148
+
149
+ let cachedWordSegmenter: { locale: string | undefined; segmenter: Intl.Segmenter } | undefined;
150
+
151
+ function wordSegmenter(locale: string | undefined): Intl.Segmenter {
152
+ if (cachedWordSegmenter === undefined || cachedWordSegmenter.locale !== locale) {
153
+ cachedWordSegmenter = { locale, segmenter: new Intl.Segmenter(locale, { granularity: 'word' }) };
154
+ }
155
+ return cachedWordSegmenter.segmenter;
156
+ }
157
+
158
+ /**
159
+ * Lazily scans every section of `book` for `query`, one section at a time, yielding
160
+ * each hit as it is found. The book is never buffered whole: each section's bytes are
161
+ * loaded, extracted, matched and released before the next is touched. A consumer that
162
+ * stops early (`break`/`return`) halts the scan — no later section is loaded.
163
+ *
164
+ * `onSection` (test-only instrumentation seam) fires with each section's index the
165
+ * moment before it is decoded, so a test can prove laziness by counting decodes.
166
+ */
167
+ export async function* searchBook(
168
+ book: Book,
169
+ query: string,
170
+ options: SearchOptions & { onSection?: (index: number) => void } = {},
171
+ ): AsyncGenerator<SearchHit> {
172
+ const needle = normalizeQuery(query);
173
+ if (needle === '') return;
174
+
175
+ const sections = book.sections;
176
+ for (let index = 0; index < sections.length; index += 1) {
177
+ const section: Section = sections[index]!;
178
+ options.onSection?.(index);
179
+ const bytes = await section.load();
180
+ // The section's own resolver rides along so extraction shows an <img> the
181
+ // way the frame will: rendered (no text) or substituted by its alt.
182
+ const rawText = extractSectionText(bytes, section.resolve?.bind(section));
183
+ for (const hit of matchText(rawText, query, section.id, index, options)) {
184
+ yield hit;
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Search normalization — the folding policy that decides when two strings "match"
3
+ * for the purpose of full-text search. Applied to both the query and the extracted
4
+ * section text before literal substring matching.
5
+ *
6
+ * Policy (see docs/domains/search.md ## Key decisions):
7
+ * - **Case-insensitive.** Folded with `toLowerCase()`.
8
+ * - **Unicode NFC.** Each grapheme is canonically composed, so a pre-composed `é`
9
+ * and a decomposed `e`+combining-acute match.
10
+ * - **Diacritics are preserved.** `café` does not match `cafe`. Stripping marks is a
11
+ * locale-sensitive judgement (German ä, Turkish dotless ı) better left to a later,
12
+ * opt-in dimension than baked into the default.
13
+ * - **Smart punctuation folded to ASCII.** Curly quotes → straight, en/em dashes and
14
+ * the minus sign → hyphen-minus, non-breaking and other Unicode spaces → a normal
15
+ * space. A reader types `don't`, the book prints `don’t`.
16
+ * - **Whitespace collapsed.** Every run of whitespace (including the newlines that
17
+ * land between block elements during extraction) becomes a single space, and
18
+ * leading/trailing space folds off, so a query never fails on invisible layout
19
+ * whitespace.
20
+ *
21
+ * The fold must stay anchorable: the matcher works on the normalized string but each
22
+ * hit's text and `Position` anchor into the **raw extracted** text. So the fold is
23
+ * built grapheme by grapheme alongside an offset map from every normalized index
24
+ * back to the raw UTF-16 offset it came from. The raw text handed to
25
+ * `capturePosition` is never NFC-rewritten (it must stay byte-identical to what the
26
+ * paginator measures against), so composition happens only inside the folded copy.
27
+ */
28
+
29
+ /** A normalized string plus the map from its indices back to raw UTF-16 offsets. */
30
+ export interface NormalizedText {
31
+ /** The folded, whitespace-collapsed text the matcher searches. */
32
+ readonly text: string;
33
+ /**
34
+ * `map[i]` is the raw UTF-16 offset of the character that produced normalized
35
+ * code unit `i`; `map[text.length]` is the raw text length. Monotonic
36
+ * non-decreasing, so a normalized span `[a, b)` maps to the raw span
37
+ * `[map[a], map[b])`.
38
+ */
39
+ readonly map: readonly number[];
40
+ }
41
+
42
+ const SMART: ReadonlyMap<string, string> = new Map([
43
+ ['‘', "'"], // ‘ left single quote
44
+ ['’', "'"], // ’ right single quote / apostrophe
45
+ ['‚', "'"], // ‚ single low-9 quote
46
+ ['‛', "'"], // ‛ single high-reversed-9 quote
47
+ ['“', '"'], // “ left double quote
48
+ ['”', '"'], // ” right double quote
49
+ ['„', '"'], // „ double low-9 quote
50
+ ['‟', '"'], // ‟ double high-reversed-9 quote
51
+ ['′', "'"], // ′ prime
52
+ ['″', '"'], // ″ double prime
53
+ ['‐', '-'], // ‐ hyphen
54
+ ['‑', '-'], // ‑ non-breaking hyphen
55
+ ['‒', '-'], // ‒ figure dash
56
+ ['–', '-'], // – en dash
57
+ ['—', '-'], // — em dash
58
+ ['―', '-'], // ― horizontal bar
59
+ ['−', '-'], // − minus sign
60
+ ]);
61
+
62
+ const WHITESPACE = /^\s$/;
63
+
64
+ let graphemeSegmenter: Intl.Segmenter | undefined;
65
+
66
+ function segmenter(): Intl.Segmenter {
67
+ graphemeSegmenter ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
68
+ return graphemeSegmenter;
69
+ }
70
+
71
+ /** True when every code point in a grapheme is whitespace. */
72
+ function isWhitespace(grapheme: string): boolean {
73
+ for (const ch of grapheme) {
74
+ if (!WHITESPACE.test(ch) && ch !== '​' && ch !== '') return false;
75
+ }
76
+ return true;
77
+ }
78
+
79
+ /** Folds a single grapheme under the punctuation/NFC/case policy. */
80
+ function foldGrapheme(grapheme: string): string {
81
+ const mapped = grapheme.length === 1 ? (SMART.get(grapheme) ?? grapheme) : grapheme;
82
+ return mapped.normalize('NFC').toLowerCase();
83
+ }
84
+
85
+ /**
86
+ * Folds `raw` under the search policy and returns the normalized text plus the
87
+ * index map back to raw UTF-16 offsets. A match found in `text` maps cleanly onto a
88
+ * raw span for `capturePosition`.
89
+ */
90
+ export function normalizeText(raw: string): NormalizedText {
91
+ let text = '';
92
+ const map: number[] = [];
93
+ let pendingSpace = false;
94
+ let sawNonSpace = false;
95
+ let rawOffset = 0;
96
+
97
+ for (const { segment } of segmenter().segment(raw)) {
98
+ if (isWhitespace(segment)) {
99
+ if (sawNonSpace) pendingSpace = true;
100
+ rawOffset += segment.length;
101
+ continue;
102
+ }
103
+ if (pendingSpace) {
104
+ map.push(rawOffset); // the collapsed space anchors at the start of the text it precedes
105
+ text += ' ';
106
+ pendingSpace = false;
107
+ }
108
+ const folded = foldGrapheme(segment);
109
+ for (let unit = 0; unit < folded.length; unit += 1) map.push(rawOffset);
110
+ text += folded;
111
+ sawNonSpace = true;
112
+ rawOffset += segment.length;
113
+ }
114
+
115
+ map.push(raw.length); // trailing whitespace folds off; the terminal maps to raw end
116
+ return { text, map };
117
+ }
118
+
119
+ /** The query folded to the same space as {@link normalizeText}'s `text`. */
120
+ export function normalizeQuery(query: string): string {
121
+ return normalizeText(query).text;
122
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * What is allowed to survive sanitization, stated as allowlists.
3
+ *
4
+ * A blocklist loses to the next vector the platform invents; an allowlist only
5
+ * ever loses formatting. Anything absent from these tables is either unwrapped
6
+ * (the element goes, its text stays — a book is text before it is markup) or,
7
+ * when its children are not readable content, discarded whole.
8
+ */
9
+
10
+ function set(...names: string[]): ReadonlySet<string> {
11
+ return new Set(names);
12
+ }
13
+
14
+ const NONE: ReadonlySet<string> = new Set<string>();
15
+
16
+ export const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
17
+ export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
18
+ export const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink';
19
+
20
+ export const HTML_GLOBAL_ATTRIBUTES = set(
21
+ 'class',
22
+ 'dir',
23
+ 'epub:type',
24
+ 'hidden',
25
+ 'id',
26
+ 'lang',
27
+ 'role',
28
+ 'style',
29
+ 'title',
30
+ 'translate',
31
+ 'xml:lang',
32
+ );
33
+
34
+ const CELL = set('abbr', 'align', 'colspan', 'headers', 'rowspan', 'scope', 'valign', 'width');
35
+
36
+ export const HTML_ELEMENTS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
37
+ ['a', set('href')],
38
+ ['abbr', NONE],
39
+ ['address', NONE],
40
+ ['article', NONE],
41
+ ['aside', NONE],
42
+ ['b', NONE],
43
+ ['bdi', NONE],
44
+ ['bdo', NONE],
45
+ ['big', NONE],
46
+ ['blockquote', set('cite')],
47
+ ['br', NONE],
48
+ ['caption', set('align')],
49
+ ['center', NONE],
50
+ ['cite', NONE],
51
+ ['code', NONE],
52
+ ['col', set('align', 'span', 'valign', 'width')],
53
+ ['colgroup', set('align', 'span', 'valign', 'width')],
54
+ ['dd', NONE],
55
+ ['del', set('cite', 'datetime')],
56
+ ['details', set('open')],
57
+ ['dfn', NONE],
58
+ ['div', NONE],
59
+ ['dl', NONE],
60
+ ['dt', NONE],
61
+ ['em', NONE],
62
+ ['figcaption', NONE],
63
+ ['figure', NONE],
64
+ ['footer', NONE],
65
+ ['h1', NONE],
66
+ ['h2', NONE],
67
+ ['h3', NONE],
68
+ ['h4', NONE],
69
+ ['h5', NONE],
70
+ ['h6', NONE],
71
+ ['header', NONE],
72
+ ['hgroup', NONE],
73
+ ['hr', NONE],
74
+ ['i', NONE],
75
+ ['img', set('alt', 'height', 'src', 'width')],
76
+ ['ins', set('cite', 'datetime')],
77
+ ['kbd', NONE],
78
+ ['li', set('value')],
79
+ ['link', set('href', 'media', 'rel', 'type')],
80
+ ['main', NONE],
81
+ ['mark', NONE],
82
+ ['nav', NONE],
83
+ ['ol', set('reversed', 'start', 'type')],
84
+ ['p', NONE],
85
+ ['pre', NONE],
86
+ ['q', set('cite')],
87
+ ['rp', NONE],
88
+ ['rt', NONE],
89
+ ['ruby', NONE],
90
+ ['s', NONE],
91
+ ['samp', NONE],
92
+ ['section', NONE],
93
+ ['small', NONE],
94
+ ['span', NONE],
95
+ ['strike', NONE],
96
+ ['strong', NONE],
97
+ ['style', set('media', 'type')],
98
+ ['sub', NONE],
99
+ ['summary', NONE],
100
+ ['sup', NONE],
101
+ ['table', set('align', 'border', 'cellpadding', 'cellspacing', 'summary', 'width')],
102
+ ['tbody', set('align', 'valign')],
103
+ ['td', CELL],
104
+ ['tfoot', set('align', 'valign')],
105
+ ['th', CELL],
106
+ ['thead', set('align', 'valign')],
107
+ ['time', set('datetime')],
108
+ ['tr', set('align', 'valign')],
109
+ ['tt', NONE],
110
+ ['u', NONE],
111
+ ['ul', NONE],
112
+ ['var', NONE],
113
+ ['wbr', NONE],
114
+ ]);
115
+
116
+ /**
117
+ * Elements that go with everything inside them, because their children are not
118
+ * prose: raw-text elements whose content is source rather than text, form
119
+ * controls, and void elements with no children to keep.
120
+ *
121
+ * Everything else absent from the allowlist is *unwrapped* instead — the
122
+ * element goes, its text stays. That distinction is load-bearing twice over. A
123
+ * book is text before it is markup, and a malformed one nests half a chapter
124
+ * inside a stray element: an </noscript> end tag after an open <p> is ignored
125
+ * by the HTML parser, so an unclosed <noscript> swallows everything after it,
126
+ * and discarding that element would delete the rest of the chapter.
127
+ *
128
+ * <noscript> is also why unwrapping is the safer option and not merely the
129
+ * kinder one. Its content is markup when a document is parsed with scripting
130
+ * off and raw text when it is parsed with scripting on, so an element that
131
+ * survives into the frame is re-read in the other mode and attribute
132
+ * boundaries move under it — the mutation-XSS shape this pipeline exists to
133
+ * defeat. Removing the element removes the reinterpretation.
134
+ *
135
+ * The table itself lives in `src/core/reading-text.ts` (re-exported here under
136
+ * its frozen name): discarding decides not only what renders but what the
137
+ * frame's *text* is, and the headless search extractor must mirror it exactly
138
+ * so a hit's anchor exists in the text the frame resolves against. One table,
139
+ * two consumers — never fork it.
140
+ */
141
+ export { DISCARDED_HTML_ELEMENTS as HTML_DISCARDED } from '../core/reading-text.ts';
142
+
143
+ export const SVG_GLOBAL_ATTRIBUTES = set(
144
+ 'class',
145
+ 'clip-path',
146
+ 'clip-rule',
147
+ 'color',
148
+ 'display',
149
+ 'dominant-baseline',
150
+ 'fill',
151
+ 'fill-opacity',
152
+ 'fill-rule',
153
+ 'font-family',
154
+ 'font-size',
155
+ 'font-style',
156
+ 'font-weight',
157
+ 'id',
158
+ 'lang',
159
+ 'letter-spacing',
160
+ 'mask',
161
+ 'opacity',
162
+ 'overflow',
163
+ 'paint-order',
164
+ 'role',
165
+ 'shape-rendering',
166
+ 'space',
167
+ 'stop-color',
168
+ 'stop-opacity',
169
+ 'stroke',
170
+ 'stroke-dasharray',
171
+ 'stroke-dashoffset',
172
+ 'stroke-linecap',
173
+ 'stroke-linejoin',
174
+ 'stroke-opacity',
175
+ 'stroke-width',
176
+ 'style',
177
+ 'text-anchor',
178
+ 'transform',
179
+ 'vector-effect',
180
+ 'visibility',
181
+ 'word-spacing',
182
+ );
183
+
184
+ const GRADIENT = set(
185
+ 'cx',
186
+ 'cy',
187
+ 'fr',
188
+ 'fx',
189
+ 'fy',
190
+ 'gradientTransform',
191
+ 'gradientUnits',
192
+ 'r',
193
+ 'spreadMethod',
194
+ 'x1',
195
+ 'x2',
196
+ 'y1',
197
+ 'y2',
198
+ );
199
+
200
+ const TEXT_POSITION = set('dx', 'dy', 'rotate', 'textLength', 'x', 'y');
201
+
202
+ export const SVG_ELEMENTS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
203
+ ['a', set('href')],
204
+ ['circle', set('cx', 'cy', 'r')],
205
+ ['clipPath', set('clipPathUnits')],
206
+ ['defs', NONE],
207
+ ['desc', NONE],
208
+ ['ellipse', set('cx', 'cy', 'rx', 'ry')],
209
+ ['g', NONE],
210
+ ['image', set('height', 'href', 'preserveAspectRatio', 'width', 'x', 'y')],
211
+ ['line', set('x1', 'x2', 'y1', 'y2')],
212
+ ['linearGradient', GRADIENT],
213
+ ['marker', set('markerHeight', 'markerUnits', 'markerWidth', 'orient', 'refX', 'refY', 'viewBox')],
214
+ ['mask', set('height', 'maskContentUnits', 'maskUnits', 'width', 'x', 'y')],
215
+ ['path', set('d', 'pathLength')],
216
+ ['pattern', set('height', 'patternContentUnits', 'patternTransform', 'patternUnits', 'viewBox', 'width', 'x', 'y')],
217
+ ['polygon', set('points')],
218
+ ['polyline', set('points')],
219
+ ['radialGradient', GRADIENT],
220
+ ['rect', set('height', 'rx', 'ry', 'width', 'x', 'y')],
221
+ ['stop', set('offset')],
222
+ ['style', set('media', 'type')],
223
+ ['svg', set('height', 'preserveAspectRatio', 'version', 'viewBox', 'width', 'x', 'y')],
224
+ ['symbol', set('preserveAspectRatio', 'viewBox')],
225
+ ['text', TEXT_POSITION],
226
+ ['title', NONE],
227
+ ['tspan', TEXT_POSITION],
228
+ ['use', set('height', 'href', 'width', 'x', 'y')],
229
+ ]);
230
+
231
+ /**
232
+ * SMIL animation is on this list, not merely absent from the allowlist, because
233
+ * `<animate attributeName="href" to="javascript:...">` turns an inert element
234
+ * into a live one after sanitization has already run.
235
+ *
236
+ * Shared from `src/core/reading-text.ts` for the same reason as HTML_DISCARDED:
237
+ * the search extractor mirrors what discarding removes from the frame's text.
238
+ */
239
+ export { DISCARDED_SVG_ELEMENTS as SVG_DISCARDED } from '../core/reading-text.ts';
240
+
241
+ /** Attributes carrying a reference, by element local name. */
242
+ export const URL_ATTRIBUTES: ReadonlyMap<string, ReadonlySet<string>> = new Map([
243
+ ['a', set('href')],
244
+ ['blockquote', set('cite')],
245
+ ['del', set('cite')],
246
+ ['image', set('href')],
247
+ ['img', set('src')],
248
+ ['ins', set('cite')],
249
+ ['link', set('href')],
250
+ ['q', set('cite')],
251
+ ['use', set('href')],
252
+ ]);
253
+
254
+ const LINK_SCHEMES = set('http', 'https', 'mailto', 'tel');
255
+ const MEDIA_SCHEMES = set('data', 'http', 'https');
256
+
257
+ /**
258
+ * Whether a reference may stay on the attribute at all. Relative references are
259
+ * kept so they can be resolved to blob URLs; remote ones are kept so CSP is the
260
+ * layer that refuses them, and refuses them observably.
261
+ */
262
+ export function isAllowedReference(element: string, attribute: string, kind: string, scheme: string | undefined): boolean {
263
+ // <use href> reaches into the same document and nowhere else: a data: URI
264
+ // there is a document-injection vector, not an image reference.
265
+ if (element === 'use') return kind === 'fragment';
266
+ if (kind === 'fragment') return element === 'a';
267
+ if (kind === 'relative') return true;
268
+ if (kind !== 'scheme' || scheme === undefined) return false;
269
+ if (attribute === 'src' || (element === 'image' && attribute === 'href') || element === 'link') {
270
+ return MEDIA_SCHEMES.has(scheme);
271
+ }
272
+ return LINK_SCHEMES.has(scheme);
273
+ }