@korzun/epubcheck-ts 0.1.0-beta.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/dist/index.js ADDED
@@ -0,0 +1,1427 @@
1
+ import { unzipSync } from "fflate";
2
+ import { SaxesParser } from "saxes";
3
+ import * as csstree from "css-tree";
4
+ //#region package.json
5
+ var version = "0.1.0-beta.0";
6
+ //#endregion
7
+ //#region src/messages/catalog.ts
8
+ /**
9
+ * Message catalog (id -> severity + template), porting epubcheck's message
10
+ * vocabulary. Templates use positional placeholders: %1$s, %2$s, ...
11
+ * This plan seeds only the OCF/container + internal ids; later plans extend it.
12
+ */
13
+ const CATALOG = {
14
+ "PKG-001": {
15
+ severity: "WARNING",
16
+ template: "Validating the EPUB against version %1$s but detected version %2$s."
17
+ },
18
+ "PKG-003": {
19
+ severity: "FATAL",
20
+ template: "The EPUB could not be read: %1$s"
21
+ },
22
+ "PKG-005": {
23
+ severity: "ERROR",
24
+ template: "The mimetype file must not be compressed."
25
+ },
26
+ "PKG-006": {
27
+ severity: "ERROR",
28
+ template: "The mimetype file entry is missing or is not the first file in the archive."
29
+ },
30
+ "PKG-007": {
31
+ severity: "ERROR",
32
+ template: "The mimetype file contains an incorrect value; expected 'application/epub+zip'."
33
+ },
34
+ "RSC-002": {
35
+ severity: "FATAL",
36
+ template: "The required META-INF/container.xml resource could not be found."
37
+ },
38
+ "RSC-003": {
39
+ severity: "ERROR",
40
+ template: "No rootfile with media type 'application/oebps-package+xml' was found in META-INF/container.xml."
41
+ },
42
+ "RSC-005": {
43
+ severity: "ERROR",
44
+ template: "Error while parsing file '%1$s': %2$s"
45
+ },
46
+ "RSC-006": {
47
+ severity: "ERROR",
48
+ template: "Remote resource reference is not allowed in this context; resource \"%1$s\" must be located in the EPUB container."
49
+ },
50
+ "RSC-007": {
51
+ severity: "ERROR",
52
+ template: "Referenced resource \"%1$s\" could not be found in the EPUB."
53
+ },
54
+ "RSC-008": {
55
+ severity: "ERROR",
56
+ template: "Referenced resource \"%1$s\" is not declared in the OPF manifest."
57
+ },
58
+ "RSC-010": {
59
+ severity: "ERROR",
60
+ template: "Reference to non-standard resource type found."
61
+ },
62
+ "RSC-011": {
63
+ severity: "ERROR",
64
+ template: "Found a reference to a resource that is not a spine item."
65
+ },
66
+ "RSC-012": {
67
+ severity: "ERROR",
68
+ template: "Fragment identifier is not defined."
69
+ },
70
+ "OPF-001": {
71
+ severity: "ERROR",
72
+ template: "There was an error when parsing the EPUB version: %1$s"
73
+ },
74
+ "OPF-003": {
75
+ severity: "USAGE",
76
+ template: "Item \"%1$s\" exists in the EPUB, but is not declared in the OPF manifest."
77
+ },
78
+ "OPF-030": {
79
+ severity: "ERROR",
80
+ template: "The unique-identifier \"%1$s\" was not found."
81
+ },
82
+ "OPF-033": {
83
+ severity: "ERROR",
84
+ template: "The spine contains no linear resources."
85
+ },
86
+ "OPF-048": {
87
+ severity: "ERROR",
88
+ template: "Package tag is missing its required unique-identifier attribute and value."
89
+ },
90
+ "OPF-049": {
91
+ severity: "ERROR",
92
+ template: "Item id \"%1$s\" was not found in the manifest."
93
+ },
94
+ "OPF-074": {
95
+ severity: "ERROR",
96
+ template: "Package resource \"%1$s\" is declared in several manifest item."
97
+ },
98
+ "RSC-001": {
99
+ severity: "ERROR",
100
+ template: "File \"%1$s\" could not be found."
101
+ },
102
+ "NAV-010": {
103
+ severity: "ERROR",
104
+ template: "\"%1$s\" nav must not link to remote resources; found link to \"%2$s\"."
105
+ },
106
+ "NAV-011": {
107
+ severity: "WARNING",
108
+ template: "\"%1$s\" nav must be in reading order; link target \"%2$s\" is before the previous link's target in %3$s order."
109
+ },
110
+ "CHK-001": {
111
+ severity: "FATAL",
112
+ template: "An internal error occurred while validating: %1$s"
113
+ },
114
+ "CSS-001": {
115
+ severity: "ERROR",
116
+ template: "The \"%1$s\" property must not be included in an EPUB Style Sheet."
117
+ },
118
+ "CSS-002": {
119
+ severity: "ERROR",
120
+ template: "Empty or NULL reference found."
121
+ },
122
+ "CSS-003": {
123
+ severity: "WARNING",
124
+ template: "CSS document is encoded in UTF-16. It should be encoded in UTF-8 instead."
125
+ },
126
+ "CSS-004": {
127
+ severity: "ERROR",
128
+ template: "CSS documents must be encoded in UTF-8, detected %1$s;"
129
+ },
130
+ "CSS-005": {
131
+ severity: "USAGE",
132
+ template: "Conflicting alternate style tags found: %1$s."
133
+ },
134
+ "CSS-006": {
135
+ severity: "USAGE",
136
+ template: "CSS selector specifies fixed position."
137
+ },
138
+ "CSS-007": {
139
+ severity: "INFO",
140
+ template: "Font-face reference \"%1$s\" refers to non-standard font type \"%2$s\"."
141
+ },
142
+ "CSS-008": {
143
+ severity: "ERROR",
144
+ template: "An error occurred while parsing the CSS: %1$s."
145
+ },
146
+ "CSS-015": {
147
+ severity: "ERROR",
148
+ template: "Alternative style sheets must have a title."
149
+ },
150
+ "CSS-019": {
151
+ severity: "WARNING",
152
+ template: "CSS font-face declaration has no attributes."
153
+ },
154
+ "RSC-013": {
155
+ severity: "ERROR",
156
+ template: "Fragment identifier is used in a reference to a stylesheet resource."
157
+ },
158
+ "RSC-030": {
159
+ severity: "ERROR",
160
+ template: "File URLs are not allowed in EPUB, but found \"%1$s\"."
161
+ },
162
+ "RSC-031": {
163
+ severity: "WARNING",
164
+ template: "Remote resource references should use HTTPS, but found \"%1$s\"."
165
+ },
166
+ "RSC-032": {
167
+ severity: "ERROR",
168
+ template: "Fallback must be provided for foreign resources, but found none for resource \"%1$s\" of type \"%2$s\"."
169
+ }
170
+ };
171
+ //#endregion
172
+ //#region src/messages/format.ts
173
+ /** Replace %N$s placeholders (1-based) with the corresponding argument. */
174
+ function applyTemplate(template, args) {
175
+ return template.replace(/%(\d+)\$s/g, (_match, n) => {
176
+ const value = args[Number(n) - 1];
177
+ return value === void 0 ? "" : String(value);
178
+ });
179
+ }
180
+ function msg(id, location, ...args) {
181
+ const entry = CATALOG[id];
182
+ if (!entry) return {
183
+ id,
184
+ severity: "ERROR",
185
+ message: `Unknown message id ${id}${args.length ? ` (${args.join(", ")})` : ""}`,
186
+ location
187
+ };
188
+ return {
189
+ id,
190
+ severity: entry.severity,
191
+ message: applyTemplate(entry.template, args),
192
+ location
193
+ };
194
+ }
195
+ //#endregion
196
+ //#region src/io/xml.ts
197
+ function decode(bytes) {
198
+ const text = new TextDecoder("utf-8").decode(bytes);
199
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
200
+ }
201
+ function parseXml(bytes, path) {
202
+ const parser = new SaxesParser({
203
+ xmlns: true,
204
+ position: true
205
+ });
206
+ const messages = [];
207
+ const document = {
208
+ type: "element",
209
+ children: [],
210
+ loc: { path }
211
+ };
212
+ const stack = [document];
213
+ parser.on("opentag", (tag) => {
214
+ const attrs = {};
215
+ for (const [key, value] of Object.entries(tag.attributes)) attrs[key] = value.value;
216
+ const node = {
217
+ type: "element",
218
+ name: tag.local,
219
+ ns: tag.uri || void 0,
220
+ attrs,
221
+ children: [],
222
+ loc: {
223
+ path,
224
+ line: parser.line,
225
+ column: parser.column
226
+ }
227
+ };
228
+ stack[stack.length - 1].children.push(node);
229
+ stack.push(node);
230
+ });
231
+ parser.on("text", (value) => {
232
+ if (value.trim() === "") return;
233
+ stack[stack.length - 1].children.push({
234
+ type: "text",
235
+ text: value,
236
+ loc: { path }
237
+ });
238
+ });
239
+ parser.on("closetag", (_tag) => {
240
+ stack.pop();
241
+ });
242
+ let failed = false;
243
+ parser.on("error", (err) => {
244
+ if (failed) return;
245
+ failed = true;
246
+ messages.push(msg("RSC-005", {
247
+ path,
248
+ line: parser.line,
249
+ column: parser.column
250
+ }, path, err.message));
251
+ });
252
+ try {
253
+ parser.write(decode(bytes)).close();
254
+ } catch {}
255
+ if (failed) return { messages };
256
+ return {
257
+ root: document.children[0],
258
+ messages
259
+ };
260
+ }
261
+ /** Element children of a node (text nodes filtered out). */
262
+ function childElements(node) {
263
+ return (node.children ?? []).filter((c) => c.type === "element");
264
+ }
265
+ /** All descendant elements (any depth) whose local name matches. */
266
+ function findDescendants(node, localName) {
267
+ const out = [];
268
+ const walk = (n) => {
269
+ for (const child of n.children ?? []) if (child.type === "element") {
270
+ if (child.name === localName) out.push(child);
271
+ walk(child);
272
+ }
273
+ };
274
+ walk(node);
275
+ return out;
276
+ }
277
+ /** All descendant text content of a node, concatenated (not trimmed). */
278
+ function textContent(node) {
279
+ let out = "";
280
+ const walk = (n) => {
281
+ for (const child of n.children ?? []) if (child.type === "text") out += child.text ?? "";
282
+ else walk(child);
283
+ };
284
+ walk(node);
285
+ return out;
286
+ }
287
+ //#endregion
288
+ //#region src/io/zip.ts
289
+ async function toBytes(input) {
290
+ if (input instanceof Uint8Array) return input;
291
+ if (input instanceof ArrayBuffer) return new Uint8Array(input);
292
+ const reader = input.getReader();
293
+ const chunks = [];
294
+ let total = 0;
295
+ for (;;) {
296
+ const { done, value } = await reader.read();
297
+ if (done) break;
298
+ chunks.push(value);
299
+ total += value.byteLength;
300
+ }
301
+ const out = new Uint8Array(total);
302
+ let offset = 0;
303
+ for (const chunk of chunks) {
304
+ out.set(chunk, offset);
305
+ offset += chunk.byteLength;
306
+ }
307
+ return out;
308
+ }
309
+ /**
310
+ * Walk the ZIP central directory to recover entry order and compression method.
311
+ * Returns names in directory order with method (0 = stored, 8 = deflate).
312
+ */
313
+ function readCentralDirectory(bytes) {
314
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
315
+ let eocd = -1;
316
+ for (let i = bytes.length - 22; i >= 0; i--) if (view.getUint32(i, true) === 101010256) {
317
+ eocd = i;
318
+ break;
319
+ }
320
+ if (eocd < 0) throw new Error("Not a ZIP archive: missing end-of-central-directory record");
321
+ const count = view.getUint16(eocd + 10, true);
322
+ let p = view.getUint32(eocd + 16, true);
323
+ const decoder = new TextDecoder("utf-8");
324
+ const entries = [];
325
+ for (let i = 0; i < count; i++) {
326
+ if (view.getUint32(p, true) !== 33639248) break;
327
+ const method = view.getUint16(p + 10, true);
328
+ const nameLen = view.getUint16(p + 28, true);
329
+ const extraLen = view.getUint16(p + 30, true);
330
+ const commentLen = view.getUint16(p + 32, true);
331
+ const name = decoder.decode(bytes.subarray(p + 46, p + 46 + nameLen));
332
+ entries.push({
333
+ name,
334
+ method
335
+ });
336
+ p += 46 + nameLen + extraLen + commentLen;
337
+ }
338
+ return entries;
339
+ }
340
+ async function openEpub(input) {
341
+ const bytes = await toBytes(input);
342
+ const order = readCentralDirectory(bytes);
343
+ const content = unzipSync(bytes);
344
+ const resources = /* @__PURE__ */ new Map();
345
+ for (const { name, method } of order) {
346
+ if (name.endsWith("/")) continue;
347
+ const data = content[name];
348
+ if (!data) continue;
349
+ resources.set(name, {
350
+ path: name,
351
+ bytes: data,
352
+ compression: method === 0 ? "stored" : "deflate"
353
+ });
354
+ }
355
+ return {
356
+ resources,
357
+ rootfiles: extractRootfiles(resources),
358
+ hasEncryption: resources.has("META-INF/encryption.xml")
359
+ };
360
+ }
361
+ function extractRootfiles(resources) {
362
+ const container = resources.get("META-INF/container.xml");
363
+ if (!container) return [];
364
+ const { root } = parseXml(container.bytes, "META-INF/container.xml");
365
+ if (!root) return [];
366
+ return findDescendants(root, "rootfile").filter((rf) => rf.attrs?.["media-type"] === "application/oebps-package+xml").map((rf) => rf.attrs?.["full-path"]).filter((path) => typeof path === "string");
367
+ }
368
+ function getResource(container, path) {
369
+ return container.resources.get(path);
370
+ }
371
+ //#endregion
372
+ //#region src/checks/ocf.ts
373
+ const MIMETYPE = "mimetype";
374
+ const EPUB_MEDIA_TYPE = "application/epub+zip";
375
+ const CONTAINER_PATH = "META-INF/container.xml";
376
+ function validateOcf(container) {
377
+ const messages = [];
378
+ const firstKey = container.resources.keys().next().value;
379
+ const mimetype = getResource(container, MIMETYPE);
380
+ if (firstKey !== MIMETYPE || !mimetype) messages.push(msg("PKG-006", { path: MIMETYPE }));
381
+ if (mimetype) {
382
+ if (mimetype.compression !== "stored") messages.push(msg("PKG-005", { path: MIMETYPE }));
383
+ if (new TextDecoder("utf-8").decode(mimetype.bytes) !== EPUB_MEDIA_TYPE) messages.push(msg("PKG-007", { path: MIMETYPE }));
384
+ }
385
+ if (!getResource(container, CONTAINER_PATH)) messages.push(msg("RSC-002", { path: CONTAINER_PATH }));
386
+ else if (container.rootfiles.length === 0) messages.push(msg("RSC-003", { path: CONTAINER_PATH }));
387
+ return messages;
388
+ }
389
+ //#endregion
390
+ //#region src/util/path.ts
391
+ /**
392
+ * Resolve an href that appears inside `fromFile` to a normalized container path.
393
+ * Strips fragment/query, decodes percent-encoding, normalizes "." and "..".
394
+ * A leading "/" is container-root-relative.
395
+ */
396
+ function resolvePath(fromFile, href) {
397
+ const clean = (href.split("#")[0] ?? "").split("?")[0] ?? "";
398
+ let decoded;
399
+ try {
400
+ decoded = decodeURIComponent(clean);
401
+ } catch {
402
+ decoded = clean;
403
+ }
404
+ const absolute = decoded.startsWith("/");
405
+ const target = absolute ? decoded.slice(1) : decoded;
406
+ const baseDir = fromFile.includes("/") ? fromFile.slice(0, fromFile.lastIndexOf("/")) : "";
407
+ const stack = absolute || baseDir === "" ? [] : baseDir.split("/");
408
+ for (const part of target.split("/")) {
409
+ if (part === "" || part === ".") continue;
410
+ if (part === "..") stack.pop();
411
+ else stack.push(part);
412
+ }
413
+ return stack.join("/");
414
+ }
415
+ /** True when `href` is an absolute URL with a scheme (e.g. https://…). */
416
+ function isRemote(href) {
417
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(href);
418
+ }
419
+ /** True when `url` carries any scheme (https:, data:, mailto:, …). */
420
+ function hasScheme(url) {
421
+ return /^[a-z][a-z0-9+.-]*:/i.test(url);
422
+ }
423
+ //#endregion
424
+ //#region src/parse/opf.ts
425
+ const DC_NS = "http://purl.org/dc/elements/1.1/";
426
+ function firstChild(node, localName) {
427
+ return childElements(node).find((c) => c.name === localName);
428
+ }
429
+ function splitProps(value) {
430
+ return value ? value.trim().split(/\s+/).filter(Boolean) : [];
431
+ }
432
+ function textOf(node) {
433
+ return (node.children ?? []).filter((c) => c.type === "text").map((c) => c.text ?? "").join("").trim();
434
+ }
435
+ function parseOpf(container) {
436
+ const messages = [];
437
+ const opfPath = container.rootfiles[0];
438
+ if (!opfPath) return { messages };
439
+ const resource = getResource(container, opfPath);
440
+ if (!resource) {
441
+ messages.push(msg("RSC-001", { path: opfPath }, opfPath));
442
+ return { messages };
443
+ }
444
+ const parsed = parseXml(resource.bytes, opfPath);
445
+ messages.push(...parsed.messages);
446
+ const root = parsed.root;
447
+ if (!root || root.name !== "package") return { messages };
448
+ const metadataEl = firstChild(root, "metadata");
449
+ const manifestEl = firstChild(root, "manifest");
450
+ const spineEl = firstChild(root, "spine");
451
+ const metadata = {
452
+ identifiers: [],
453
+ titles: [],
454
+ languages: [],
455
+ modifiedCount: 0
456
+ };
457
+ if (metadataEl) {
458
+ for (const el of childElements(metadataEl)) if (el.ns === DC_NS && el.name === "identifier") metadata.identifiers.push({
459
+ id: el.attrs?.["id"],
460
+ value: textOf(el)
461
+ });
462
+ else if (el.ns === DC_NS && el.name === "title") metadata.titles.push(textOf(el));
463
+ else if (el.ns === DC_NS && el.name === "language") metadata.languages.push(textOf(el));
464
+ else if (el.name === "meta" && el.attrs?.["property"] === "dcterms:modified" && !el.attrs["refines"]) metadata.modifiedCount++;
465
+ }
466
+ const manifest = manifestEl ? childElements(manifestEl).filter((el) => el.name === "item").map((el) => ({
467
+ id: el.attrs?.["id"],
468
+ href: el.attrs?.["href"],
469
+ mediaType: el.attrs?.["media-type"],
470
+ properties: splitProps(el.attrs?.["properties"]),
471
+ fallback: el.attrs?.["fallback"],
472
+ loc: el.loc
473
+ })) : [];
474
+ const spine = spineEl ? childElements(spineEl).filter((el) => el.name === "itemref").map((el) => ({
475
+ idref: el.attrs?.["idref"],
476
+ linear: el.attrs?.["linear"] !== "no",
477
+ properties: splitProps(el.attrs?.["properties"]),
478
+ loc: el.loc
479
+ })) : [];
480
+ return {
481
+ pkg: {
482
+ path: opfPath,
483
+ version: root.attrs?.["version"],
484
+ uniqueIdentifier: root.attrs?.["unique-identifier"],
485
+ metadata,
486
+ manifest,
487
+ spinePresent: spineEl !== void 0,
488
+ spine,
489
+ loc: root.loc
490
+ },
491
+ messages
492
+ };
493
+ }
494
+ /** Resolved-container-path → manifest item, for non-remote manifest hrefs. */
495
+ function manifestPathMap(pkg) {
496
+ const map = /* @__PURE__ */ new Map();
497
+ for (const item of pkg.manifest) if (item.href && !isRemote(item.href)) map.set(resolvePath(pkg.path, item.href), item);
498
+ return map;
499
+ }
500
+ //#endregion
501
+ //#region src/checks/opf.ts
502
+ const XHTML_MEDIA_TYPE = "application/xhtml+xml";
503
+ function validateOpf(pkg, container) {
504
+ return [
505
+ ...checkPackage(pkg),
506
+ ...checkManifest(pkg, container),
507
+ ...checkSpineAndNav(pkg)
508
+ ];
509
+ }
510
+ /**
511
+ * OPF-003: a container resource that is not declared in the manifest.
512
+ * Excludes `mimetype`, everything under `META-INF/`, and the rootfile package
513
+ * document(s). (epubcheck also exempts EPUB 3 OPF `<link>` resources and the
514
+ * Multiple-Renditions mapping document, which we do not model.)
515
+ */
516
+ function checkUndeclaredResources(pkg, container) {
517
+ const messages = [];
518
+ const declared = manifestPathMap(pkg);
519
+ const rootfiles = new Set(container.rootfiles);
520
+ for (const path of container.resources.keys()) {
521
+ if (path === "mimetype") continue;
522
+ if (path.startsWith("META-INF/")) continue;
523
+ if (rootfiles.has(path)) continue;
524
+ if (declared.has(path)) continue;
525
+ messages.push(msg("OPF-003", { path }, path));
526
+ }
527
+ return messages;
528
+ }
529
+ function checkPackage(pkg) {
530
+ const messages = [];
531
+ const loc = pkg.loc;
532
+ if (!pkg.version) messages.push(msg("OPF-001", loc, "the version attribute is missing"));
533
+ else if (pkg.version !== "2.0" && pkg.version !== "3.0") messages.push(msg("OPF-001", loc, `unsupported version "${pkg.version}"`));
534
+ if (!pkg.uniqueIdentifier) messages.push(msg("OPF-048", loc));
535
+ else if (!pkg.metadata.identifiers.some((i) => i.id === pkg.uniqueIdentifier)) messages.push(msg("OPF-030", loc, pkg.uniqueIdentifier));
536
+ if (pkg.metadata.identifiers.length === 0) messages.push(msg("RSC-005", loc, pkg.path, "The package metadata must include at least one dc:identifier element."));
537
+ if (pkg.metadata.titles.length === 0) messages.push(msg("RSC-005", loc, pkg.path, "The package metadata must include at least one dc:title element."));
538
+ if (pkg.metadata.languages.length === 0) messages.push(msg("RSC-005", loc, pkg.path, "The package metadata must include at least one dc:language element."));
539
+ if (pkg.metadata.modifiedCount !== 1) messages.push(msg("RSC-005", loc, pkg.path, "The package dcterms:modified meta element must occur exactly once."));
540
+ return messages;
541
+ }
542
+ function checkManifest(pkg, container) {
543
+ const messages = [];
544
+ const seenIds = /* @__PURE__ */ new Set();
545
+ const seenPaths = /* @__PURE__ */ new Set();
546
+ for (const item of pkg.manifest) {
547
+ if (!item.id || !item.href || !item.mediaType) messages.push(msg("RSC-005", item.loc, pkg.path, "A manifest item is missing a required attribute (id, href, and media-type are required)."));
548
+ if (item.id) if (seenIds.has(item.id)) messages.push(msg("RSC-005", item.loc, pkg.path, `Duplicate manifest item id "${item.id}".`));
549
+ else seenIds.add(item.id);
550
+ if (item.href && !isRemote(item.href)) {
551
+ const resolved = resolvePath(pkg.path, item.href);
552
+ if (seenPaths.has(resolved)) messages.push(msg("OPF-074", item.loc, resolved));
553
+ else seenPaths.add(resolved);
554
+ if (!getResource(container, resolved)) messages.push(msg("RSC-001", item.loc, resolved));
555
+ }
556
+ }
557
+ return messages;
558
+ }
559
+ function checkSpineAndNav(pkg) {
560
+ const messages = [];
561
+ if (!pkg.spinePresent) messages.push(msg("RSC-005", pkg.loc, pkg.path, "The package document must contain a spine element."));
562
+ else if (pkg.spine.length === 0) messages.push(msg("RSC-005", pkg.loc, pkg.path, "The spine element must contain at least one itemref."));
563
+ else {
564
+ const ids = new Set(pkg.manifest.map((i) => i.id).filter((id) => Boolean(id)));
565
+ for (const ref of pkg.spine) if (ref.idref && !ids.has(ref.idref)) messages.push(msg("OPF-049", ref.loc, ref.idref));
566
+ if (!pkg.spine.some((s) => s.linear)) messages.push(msg("OPF-033", pkg.loc));
567
+ }
568
+ if (pkg.version === "3.0") {
569
+ const navItems = pkg.manifest.filter((i) => i.properties.includes("nav"));
570
+ if (navItems.length !== 1) messages.push(msg("RSC-005", pkg.loc, pkg.path, `Exactly one manifest item must declare the "nav" property (number of "nav" items: ${navItems.length}).`));
571
+ else {
572
+ const nav = navItems[0];
573
+ if (nav && nav.mediaType !== XHTML_MEDIA_TYPE) messages.push(msg("RSC-005", nav.loc, pkg.path, `The manifest item representing the Navigation Document must be of the "${XHTML_MEDIA_TYPE}" type (given type was "${nav.mediaType ?? ""}").`));
574
+ }
575
+ }
576
+ return messages;
577
+ }
578
+ //#endregion
579
+ //#region src/parse/nav.ts
580
+ const EPUB_TYPE_ATTR = "epub:type";
581
+ function tokens(value) {
582
+ return value ? value.trim().split(/\s+/).filter(Boolean) : [];
583
+ }
584
+ function parseNav(navItem, container) {
585
+ const messages = [];
586
+ const opfPath = container.rootfiles[0];
587
+ if (!opfPath || !navItem.href) return { messages };
588
+ const navPath = resolvePath(opfPath, navItem.href);
589
+ const resource = getResource(container, navPath);
590
+ if (!resource) return { messages };
591
+ const parsed = parseXml(resource.bytes, navPath);
592
+ messages.push(...parsed.messages);
593
+ const root = parsed.root;
594
+ if (!root) return { messages };
595
+ return {
596
+ nav: {
597
+ path: navPath,
598
+ root,
599
+ sections: findDescendants(root, "nav").map((node) => ({
600
+ types: tokens(node.attrs?.[EPUB_TYPE_ATTR]),
601
+ node,
602
+ loc: node.loc
603
+ })),
604
+ loc: root.loc
605
+ },
606
+ messages
607
+ };
608
+ }
609
+ //#endregion
610
+ //#region src/parse/content.ts
611
+ /** Extract the URL of each srcset candidate ("url descriptor, url descriptor"). */
612
+ function parseSrcset(value) {
613
+ return value.split(",").map((part) => part.trim().split(/\s+/)[0] ?? "").filter((u) => u !== "");
614
+ }
615
+ function addRefs(el, parent, attrs, refs) {
616
+ const push = (url, type, intrinsic = false) => {
617
+ if (url) refs.push({
618
+ url,
619
+ type,
620
+ loc: el.loc,
621
+ hasIntrinsicFallback: intrinsic
622
+ });
623
+ };
624
+ const pushAll = (urls, type, intrinsic = false) => {
625
+ for (const url of urls) refs.push({
626
+ url,
627
+ type,
628
+ loc: el.loc,
629
+ hasIntrinsicFallback: intrinsic
630
+ });
631
+ };
632
+ const inPicture = parent === "picture";
633
+ switch (el.name) {
634
+ case "a":
635
+ case "area":
636
+ push(attrs["href"] ?? attrs["xlink:href"], "hyperlink");
637
+ break;
638
+ case "img":
639
+ push(attrs["src"], "image", inPicture);
640
+ if (attrs["srcset"]) pushAll(parseSrcset(attrs["srcset"]), "image", inPicture);
641
+ break;
642
+ case "image":
643
+ push(attrs["xlink:href"] ?? attrs["href"], "image");
644
+ break;
645
+ case "source":
646
+ if (attrs["srcset"]) pushAll(parseSrcset(attrs["srcset"]), "image", inPicture);
647
+ else if (parent === "audio") push(attrs["src"], "audio", true);
648
+ else if (parent === "video") push(attrs["src"], "video", false);
649
+ else push(attrs["src"], "image", true);
650
+ break;
651
+ case "audio":
652
+ push(attrs["src"], "audio");
653
+ break;
654
+ case "video":
655
+ push(attrs["src"], "video");
656
+ push(attrs["poster"], "image");
657
+ break;
658
+ case "track":
659
+ push(attrs["src"], "track");
660
+ break;
661
+ case "link":
662
+ if ((attrs["rel"] ?? "").split(/\s+/).includes("stylesheet")) push(attrs["href"], "stylesheet");
663
+ break;
664
+ case "script":
665
+ push(attrs["src"], "generic");
666
+ break;
667
+ case "object":
668
+ push(attrs["data"], "generic", true);
669
+ break;
670
+ case "iframe":
671
+ case "embed":
672
+ case "input":
673
+ push(attrs["src"], "generic");
674
+ break;
675
+ case "blockquote":
676
+ case "q":
677
+ case "ins":
678
+ case "del":
679
+ push(attrs["cite"], "cite");
680
+ break;
681
+ case "math":
682
+ push(attrs["altimg"], "image");
683
+ break;
684
+ default: break;
685
+ }
686
+ }
687
+ function collect(node, parent, refs, ids, idPositions, inlineStyles) {
688
+ for (const child of node.children ?? []) {
689
+ if (child.type !== "element") continue;
690
+ const attrs = child.attrs ?? {};
691
+ const id = attrs["id"];
692
+ if (id) {
693
+ ids.add(id);
694
+ if (!idPositions.has(id)) idPositions.set(id, idPositions.size + 1);
695
+ }
696
+ addRefs(child, parent, attrs, refs);
697
+ if (child.name === "style") inlineStyles.push({
698
+ context: "stylesheet",
699
+ text: textContent(child),
700
+ loc: child.loc
701
+ });
702
+ const styleAttr = attrs["style"];
703
+ if (styleAttr) inlineStyles.push({
704
+ context: "declarationList",
705
+ text: styleAttr,
706
+ loc: child.loc
707
+ });
708
+ collect(child, child.name, refs, ids, idPositions, inlineStyles);
709
+ }
710
+ }
711
+ function parseContent(item, container) {
712
+ const messages = [];
713
+ const opfPath = container.rootfiles[0];
714
+ if (!opfPath || !item.href) return { messages };
715
+ const path = resolvePath(opfPath, item.href);
716
+ const resource = getResource(container, path);
717
+ if (!resource) return { messages };
718
+ const parsed = parseXml(resource.bytes, path);
719
+ messages.push(...parsed.messages);
720
+ const root = parsed.root;
721
+ if (!root) return { messages };
722
+ const refs = [];
723
+ const ids = /* @__PURE__ */ new Set();
724
+ const idPositions = /* @__PURE__ */ new Map();
725
+ const inlineStyles = [];
726
+ collect(root, void 0, refs, ids, idPositions, inlineStyles);
727
+ return {
728
+ doc: {
729
+ path,
730
+ root,
731
+ refs,
732
+ ids,
733
+ idPositions,
734
+ inlineStyles
735
+ },
736
+ messages
737
+ };
738
+ }
739
+ //#endregion
740
+ //#region src/checks/nav.ts
741
+ function hasType(section, type) {
742
+ return section.types.includes(type);
743
+ }
744
+ function validateNav(nav, pkg, container) {
745
+ return [
746
+ ...checkOccurrence(nav),
747
+ ...checkContent(nav),
748
+ ...checkLinks(nav),
749
+ ...checkReadingOrder(nav, pkg, container)
750
+ ];
751
+ }
752
+ function checkOccurrence(nav) {
753
+ const messages = [];
754
+ const tocs = nav.sections.filter((s) => hasType(s, "toc"));
755
+ if (tocs.length !== 1) messages.push(msg("RSC-005", nav.loc, nav.path, "Exactly one \"toc\" nav element must be present."));
756
+ if (nav.sections.filter((s) => hasType(s, "page-list")).length > 1) messages.push(msg("RSC-005", nav.loc, nav.path, "Multiple occurrences of the \"page-list\" nav element."));
757
+ if (nav.sections.filter((s) => hasType(s, "landmarks")).length > 1) messages.push(msg("RSC-005", nav.loc, nav.path, "Multiple occurrences of the \"landmarks\" nav element."));
758
+ const toc = tocs[0];
759
+ if (toc && findDescendants(toc.node, "ol").length === 0) messages.push(msg("RSC-005", toc.loc, nav.path, "The \"toc\" nav element must contain an ol element."));
760
+ return messages;
761
+ }
762
+ function checkLinks(nav) {
763
+ const messages = [];
764
+ for (const section of nav.sections) {
765
+ const label = section.types[0] ?? "toc";
766
+ for (const a of findDescendants(section.node, "a")) {
767
+ const href = a.attrs?.["href"];
768
+ if (href && isRemote(href)) messages.push(msg("NAV-010", a.loc, label, href));
769
+ }
770
+ }
771
+ return messages;
772
+ }
773
+ function anchorPosition(href, idPositions) {
774
+ const hash = href.indexOf("#");
775
+ const fragment = hash < 0 ? "" : href.slice(hash + 1);
776
+ if (fragment.trim() === "") return 0;
777
+ return idPositions.get(fragment) ?? -1;
778
+ }
779
+ function checkReadingOrder(nav, pkg, container) {
780
+ const messages = [];
781
+ const itemById = /* @__PURE__ */ new Map();
782
+ for (const item of pkg.manifest) if (item.id !== void 0) itemById.set(item.id, item);
783
+ const spinePos = /* @__PURE__ */ new Map();
784
+ pkg.spine.forEach((s, i) => {
785
+ if (s.idref === void 0) return;
786
+ const item = itemById.get(s.idref);
787
+ if (item?.href && !isRemote(item.href)) spinePos.set(resolvePath(pkg.path, item.href), i);
788
+ });
789
+ const manifest = manifestPathMap(pkg);
790
+ const idPosCache = /* @__PURE__ */ new Map();
791
+ const idPositionsFor = (path) => {
792
+ const cached = idPosCache.get(path);
793
+ if (cached) return cached;
794
+ const item = manifest.get(path);
795
+ const positions = item ? parseContent(item, container).doc?.idPositions ?? /* @__PURE__ */ new Map() : /* @__PURE__ */ new Map();
796
+ idPosCache.set(path, positions);
797
+ return positions;
798
+ };
799
+ for (const section of nav.sections) {
800
+ if (!hasType(section, "toc")) continue;
801
+ let lastSpinePos = -1;
802
+ let lastAnchorPos = -1;
803
+ for (const a of findDescendants(section.node, "a")) {
804
+ const href = a.attrs?.["href"];
805
+ if (!href || isRemote(href)) continue;
806
+ const target = resolvePath(nav.path, href);
807
+ const pos = spinePos.get(target);
808
+ if (pos === void 0) continue;
809
+ if (pos < lastSpinePos) {
810
+ messages.push(msg("NAV-011", a.loc, "toc", target, "spine"));
811
+ lastSpinePos = pos;
812
+ lastAnchorPos = -1;
813
+ } else {
814
+ if (pos > lastSpinePos) {
815
+ lastSpinePos = pos;
816
+ lastAnchorPos = -1;
817
+ }
818
+ const anchorPos = anchorPosition(href, idPositionsFor(target));
819
+ if (anchorPos > -1) {
820
+ if (anchorPos < lastAnchorPos) messages.push(msg("NAV-011", a.loc, "toc", target, "document"));
821
+ lastAnchorPos = anchorPos;
822
+ }
823
+ }
824
+ }
825
+ }
826
+ return messages;
827
+ }
828
+ function checkContent(nav) {
829
+ const messages = [];
830
+ for (const section of nav.sections) {
831
+ const anchors = findDescendants(section.node, "a");
832
+ for (const a of anchors) {
833
+ if (!a.attrs?.["href"]) messages.push(msg("RSC-005", a.loc, nav.path, "An \"a\" element in the navigation document must have an href attribute."));
834
+ if (textContent(a).trim() === "") messages.push(msg("RSC-005", a.loc, nav.path, "Anchors within nav elements must contain text."));
835
+ }
836
+ for (const span of findDescendants(section.node, "span")) if (textContent(span).trim() === "") messages.push(msg("RSC-005", span.loc, nav.path, "Spans within nav elements must contain text."));
837
+ if (hasType(section, "landmarks")) {
838
+ const seen = /* @__PURE__ */ new Set();
839
+ for (const a of anchors) {
840
+ const type = a.attrs?.["epub:type"];
841
+ if (!type) {
842
+ messages.push(msg("RSC-005", a.loc, nav.path, "Missing epub:type attribute on anchor inside \"landmarks\" nav element."));
843
+ continue;
844
+ }
845
+ const href = a.attrs?.["href"] ?? "";
846
+ const key = `${type}::${href}`;
847
+ if (seen.has(key)) messages.push(msg("RSC-005", a.loc, nav.path, `Another landmark was found with the same epub:type and reference to "${href}".`));
848
+ else seen.add(key);
849
+ }
850
+ }
851
+ }
852
+ return messages;
853
+ }
854
+ //#endregion
855
+ //#region src/util/html-elements.ts
856
+ /** Local names of HTML5 elements (the conforming element vocabulary). */
857
+ const KNOWN_HTML_ELEMENTS = /* @__PURE__ */ new Set([
858
+ "a",
859
+ "abbr",
860
+ "address",
861
+ "area",
862
+ "article",
863
+ "aside",
864
+ "audio",
865
+ "b",
866
+ "base",
867
+ "bdi",
868
+ "bdo",
869
+ "blockquote",
870
+ "body",
871
+ "br",
872
+ "button",
873
+ "canvas",
874
+ "caption",
875
+ "cite",
876
+ "code",
877
+ "col",
878
+ "colgroup",
879
+ "data",
880
+ "datalist",
881
+ "dd",
882
+ "del",
883
+ "details",
884
+ "dfn",
885
+ "dialog",
886
+ "div",
887
+ "dl",
888
+ "dt",
889
+ "em",
890
+ "embed",
891
+ "fieldset",
892
+ "figcaption",
893
+ "figure",
894
+ "footer",
895
+ "form",
896
+ "h1",
897
+ "h2",
898
+ "h3",
899
+ "h4",
900
+ "h5",
901
+ "h6",
902
+ "head",
903
+ "header",
904
+ "hgroup",
905
+ "hr",
906
+ "html",
907
+ "i",
908
+ "iframe",
909
+ "img",
910
+ "input",
911
+ "ins",
912
+ "kbd",
913
+ "label",
914
+ "legend",
915
+ "li",
916
+ "link",
917
+ "main",
918
+ "map",
919
+ "mark",
920
+ "menu",
921
+ "meta",
922
+ "meter",
923
+ "nav",
924
+ "noscript",
925
+ "object",
926
+ "ol",
927
+ "optgroup",
928
+ "option",
929
+ "output",
930
+ "p",
931
+ "param",
932
+ "picture",
933
+ "pre",
934
+ "progress",
935
+ "q",
936
+ "rb",
937
+ "rp",
938
+ "rt",
939
+ "rtc",
940
+ "ruby",
941
+ "s",
942
+ "samp",
943
+ "script",
944
+ "section",
945
+ "select",
946
+ "slot",
947
+ "small",
948
+ "source",
949
+ "span",
950
+ "strong",
951
+ "style",
952
+ "sub",
953
+ "summary",
954
+ "sup",
955
+ "table",
956
+ "tbody",
957
+ "td",
958
+ "template",
959
+ "textarea",
960
+ "tfoot",
961
+ "th",
962
+ "thead",
963
+ "time",
964
+ "title",
965
+ "tr",
966
+ "track",
967
+ "u",
968
+ "ul",
969
+ "var",
970
+ "video",
971
+ "wbr"
972
+ ]);
973
+ function isKnownHtmlElement(name) {
974
+ return KNOWN_HTML_ELEMENTS.has(name);
975
+ }
976
+ //#endregion
977
+ //#region src/parse/css.ts
978
+ function locOf(node, path) {
979
+ const start = node?.loc?.start;
980
+ return start ? {
981
+ path,
982
+ line: start.line,
983
+ column: start.column
984
+ } : { path };
985
+ }
986
+ function stripQuotes(raw) {
987
+ const t = raw.trim();
988
+ if (t.length >= 2 && (t.startsWith("\"") && t.endsWith("\"") || t.startsWith("'") && t.endsWith("'"))) return t.slice(1, -1);
989
+ return t;
990
+ }
991
+ /**
992
+ * Extract the URL string from a css-tree Url node.
993
+ * In v3, node.value is already a plain string with quotes stripped by the parser.
994
+ */
995
+ function urlValue(node) {
996
+ return stripQuotes(node.value);
997
+ }
998
+ /** The import target from an @import at-rule prelude (Url or String). */
999
+ function importTarget(atrule) {
1000
+ const prelude = atrule.prelude;
1001
+ if (!prelude || prelude.type !== "AtrulePrelude") return void 0;
1002
+ let result;
1003
+ csstree.walk(prelude, (n) => {
1004
+ if (result !== void 0) return;
1005
+ if (n.type === "Url") result = urlValue(n);
1006
+ else if (n.type === "String") result = stripQuotes(n.value);
1007
+ });
1008
+ return result;
1009
+ }
1010
+ function countDeclarations(atrule) {
1011
+ let count = 0;
1012
+ if (atrule.block) csstree.walk(atrule.block, (n) => {
1013
+ if (n.type === "Declaration") count++;
1014
+ });
1015
+ return count;
1016
+ }
1017
+ function analyzeCss(text, path, context) {
1018
+ const messages = [];
1019
+ const refs = [];
1020
+ const declarations = [];
1021
+ const fontFaces = [];
1022
+ let ast;
1023
+ try {
1024
+ ast = csstree.parse(text, {
1025
+ positions: true,
1026
+ context,
1027
+ onParseError(error) {
1028
+ messages.push(msg("CSS-008", {
1029
+ path,
1030
+ line: error.line,
1031
+ column: error.column
1032
+ }, error.message));
1033
+ }
1034
+ });
1035
+ } catch (error) {
1036
+ messages.push(msg("CSS-008", { path }, error instanceof Error ? error.message : String(error)));
1037
+ return {
1038
+ parsed: false,
1039
+ refs,
1040
+ declarations,
1041
+ fontFaces,
1042
+ messages
1043
+ };
1044
+ }
1045
+ const atruleStack = [];
1046
+ const pushRef = (url, type, loc) => {
1047
+ if (url.trim() === "") messages.push(msg("CSS-002", loc));
1048
+ else refs.push({
1049
+ url,
1050
+ type,
1051
+ loc
1052
+ });
1053
+ };
1054
+ csstree.walk(ast, {
1055
+ enter: (node) => {
1056
+ if (node.type === "Atrule") {
1057
+ const atrule = node;
1058
+ if (atrule.name === "import") {
1059
+ const url = importTarget(atrule);
1060
+ if (url !== void 0) pushRef(url, "import", locOf(atrule, path));
1061
+ } else if (atrule.name === "font-face") fontFaces.push({
1062
+ declarationCount: countDeclarations(atrule),
1063
+ loc: locOf(atrule, path)
1064
+ });
1065
+ atruleStack.push(atrule.name);
1066
+ } else if (node.type === "Url") {
1067
+ if (atruleStack[atruleStack.length - 1] !== "import") {
1068
+ const urlNode = node;
1069
+ const type = atruleStack[atruleStack.length - 1] === "font-face" ? "font" : "generic";
1070
+ pushRef(urlValue(urlNode), type, locOf(urlNode, path));
1071
+ }
1072
+ } else if (node.type === "Declaration") {
1073
+ const decl = node;
1074
+ declarations.push({
1075
+ property: decl.property.toLowerCase(),
1076
+ value: csstree.generate(decl.value),
1077
+ loc: locOf(decl, path)
1078
+ });
1079
+ }
1080
+ },
1081
+ leave: (node) => {
1082
+ if (node.type === "Atrule") atruleStack.pop();
1083
+ }
1084
+ });
1085
+ return {
1086
+ parsed: true,
1087
+ refs,
1088
+ declarations,
1089
+ fontFaces,
1090
+ messages
1091
+ };
1092
+ }
1093
+ /**
1094
+ * Detect a CSS file's declared encoding from a leading BOM or an `@charset`
1095
+ * rule (which, per the CSS syntax, must be the very first bytes). Returns the
1096
+ * lowercased encoding name, or undefined when none is declared (assume UTF-8).
1097
+ */
1098
+ function detectCssCharset(bytes) {
1099
+ if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) return "utf-16be";
1100
+ if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return "utf-16le";
1101
+ if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) return "utf-8";
1102
+ const prefix = "@charset \"";
1103
+ let head = "";
1104
+ for (let i = 0; i < Math.min(bytes.length, 100); i++) head += String.fromCharCode(bytes[i] ?? 0);
1105
+ if (head.startsWith(prefix)) {
1106
+ const end = head.indexOf("\"", 10);
1107
+ if (end > 10) return head.slice(10, end).toLowerCase();
1108
+ }
1109
+ }
1110
+ function parseCss(item, container) {
1111
+ const opfPath = container.rootfiles[0];
1112
+ if (!opfPath || !item.href) return { messages: [] };
1113
+ const path = resolvePath(opfPath, item.href);
1114
+ const resource = getResource(container, path);
1115
+ if (!resource) return { messages: [] };
1116
+ const charset = detectCssCharset(resource.bytes);
1117
+ if (charset !== void 0 && charset !== "utf-8") return { messages: [charset.startsWith("utf-16") ? msg("CSS-003", { path }) : msg("CSS-004", { path }, charset)] };
1118
+ const a = analyzeCss(new TextDecoder("utf-8").decode(resource.bytes), path, "stylesheet");
1119
+ if (!a.parsed) return { messages: a.messages };
1120
+ return {
1121
+ css: {
1122
+ path,
1123
+ refs: a.refs,
1124
+ declarations: a.declarations,
1125
+ fontFaces: a.fontFaces
1126
+ },
1127
+ messages: a.messages
1128
+ };
1129
+ }
1130
+ //#endregion
1131
+ //#region src/util/media-types.ts
1132
+ /** EPUB 3 blessed font media types (epubcheck OPFChecker30.isBlessedFontType). */
1133
+ const BLESSED_FONT_TYPES = /* @__PURE__ */ new Set([
1134
+ "font/ttf",
1135
+ "font/otf",
1136
+ "font/woff",
1137
+ "font/woff2",
1138
+ "application/font-sfnt",
1139
+ "application/vnd.ms-opentype",
1140
+ "application/font-woff",
1141
+ "application/x-font-ttf"
1142
+ ]);
1143
+ function isBlessedFontType(mediaType) {
1144
+ return mediaType !== void 0 && BLESSED_FONT_TYPES.has(mediaType);
1145
+ }
1146
+ //#endregion
1147
+ //#region src/checks/css.ts
1148
+ function validateCss(css, container, manifest) {
1149
+ return [...checkReferences$1(css, container, manifest), ...checkProperties(css)];
1150
+ }
1151
+ function validateCssDocs(pkg, container) {
1152
+ const messages = [];
1153
+ const manifest = manifestPathMap(pkg);
1154
+ for (const item of pkg.manifest) {
1155
+ if (item.mediaType !== "text/css") continue;
1156
+ const { css, messages: m } = parseCss(item, container);
1157
+ messages.push(...m);
1158
+ if (css) messages.push(...validateCss(css, container, manifest));
1159
+ }
1160
+ return messages;
1161
+ }
1162
+ function checkProperties(css) {
1163
+ const messages = [];
1164
+ for (const decl of css.declarations) if (decl.property === "direction" || decl.property === "unicode-bidi") messages.push(msg("CSS-001", decl.loc, decl.property));
1165
+ else if (decl.property === "position" && /\bfixed\b/i.test(decl.value)) messages.push(msg("CSS-006", decl.loc));
1166
+ for (const fontFace of css.fontFaces) if (fontFace.declarationCount === 0) messages.push(msg("CSS-019", fontFace.loc));
1167
+ return messages;
1168
+ }
1169
+ function checkReferences$1(css, container, manifest) {
1170
+ const messages = [];
1171
+ for (const ref of css.refs) {
1172
+ const url = ref.url;
1173
+ if (/^file:/i.test(url)) {
1174
+ messages.push(msg("RSC-030", ref.loc, url));
1175
+ continue;
1176
+ }
1177
+ if (ref.type === "import" && url.includes("#")) messages.push(msg("RSC-013", ref.loc));
1178
+ if (isRemote(url)) {
1179
+ if (ref.type === "font") {
1180
+ if (!/^https:\/\//i.test(url)) messages.push(msg("RSC-031", ref.loc, url));
1181
+ } else messages.push(msg("RSC-006", ref.loc, url));
1182
+ continue;
1183
+ }
1184
+ if (hasScheme(url)) continue;
1185
+ const target = resolvePath(css.path, url);
1186
+ if (!getResource(container, target)) messages.push(msg("RSC-007", ref.loc, url));
1187
+ else if (!manifest.has(target)) messages.push(msg("RSC-008", ref.loc, url));
1188
+ else if (ref.type === "font") {
1189
+ const item = manifest.get(target);
1190
+ if (item && item.mediaType !== void 0 && !isBlessedFontType(item.mediaType)) messages.push(msg("CSS-007", ref.loc, url, item.mediaType));
1191
+ }
1192
+ }
1193
+ return messages;
1194
+ }
1195
+ //#endregion
1196
+ //#region src/checks/content.ts
1197
+ const REMOTE_ALLOWED = /* @__PURE__ */ new Set([
1198
+ "hyperlink",
1199
+ "cite",
1200
+ "audio",
1201
+ "video"
1202
+ ]);
1203
+ const HTML_NS = "http://www.w3.org/1999/xhtml";
1204
+ const BLESSED_CONTENT_TYPES = /* @__PURE__ */ new Set([
1205
+ "application/xhtml+xml",
1206
+ "image/svg+xml",
1207
+ "text/x-oeb1-document",
1208
+ "text/html"
1209
+ ]);
1210
+ function isBlessedContentType(mediaType) {
1211
+ return mediaType !== void 0 && BLESSED_CONTENT_TYPES.has(mediaType);
1212
+ }
1213
+ const CORE_MEDIA_TYPES = /* @__PURE__ */ new Set([
1214
+ "image/gif",
1215
+ "image/jpeg",
1216
+ "image/png",
1217
+ "image/webp",
1218
+ "image/svg+xml",
1219
+ "audio/mpeg",
1220
+ "audio/mp4",
1221
+ ...BLESSED_FONT_TYPES,
1222
+ "application/xhtml+xml",
1223
+ "text/javascript",
1224
+ "application/javascript",
1225
+ "application/ecmascript",
1226
+ "text/css",
1227
+ "application/pls+xml",
1228
+ "application/smil+xml"
1229
+ ]);
1230
+ function isCoreMediaType(mediaType) {
1231
+ if (mediaType === void 0) return false;
1232
+ if (CORE_MEDIA_TYPES.has(mediaType)) return true;
1233
+ if (mediaType.startsWith("video/")) return true;
1234
+ if (/^audio\/ogg\s*;\s*codecs=opus$/i.test(mediaType)) return true;
1235
+ return false;
1236
+ }
1237
+ function hasFallbackTo(item, byId, predicate) {
1238
+ const seen = /* @__PURE__ */ new Set();
1239
+ let current = item.fallback;
1240
+ while (current !== void 0 && !seen.has(current)) {
1241
+ seen.add(current);
1242
+ const next = byId.get(current);
1243
+ if (next === void 0) return false;
1244
+ if (predicate(next)) return true;
1245
+ current = next.fallback;
1246
+ }
1247
+ return false;
1248
+ }
1249
+ function hasFallbackToBlessed(item, byId) {
1250
+ return hasFallbackTo(item, byId, (i) => isBlessedContentType(i.mediaType));
1251
+ }
1252
+ function validateContentDocs(pkg, container) {
1253
+ const messages = [];
1254
+ const manifest = manifestPathMap(pkg);
1255
+ const byId = /* @__PURE__ */ new Map();
1256
+ for (const item of pkg.manifest) if (item.id !== void 0) byId.set(item.id, item);
1257
+ const spinePaths = /* @__PURE__ */ new Set();
1258
+ for (const s of pkg.spine) {
1259
+ if (s.idref === void 0) continue;
1260
+ const item = byId.get(s.idref);
1261
+ if (item?.href && !isRemote(item.href)) spinePaths.add(resolvePath(pkg.path, item.href));
1262
+ }
1263
+ const docs = /* @__PURE__ */ new Map();
1264
+ for (const item of pkg.manifest) {
1265
+ if (item.mediaType !== "application/xhtml+xml") continue;
1266
+ const { doc, messages: m } = parseContent(item, container);
1267
+ messages.push(...m);
1268
+ if (doc) docs.set(doc.path, doc);
1269
+ }
1270
+ for (const doc of docs.values()) {
1271
+ messages.push(...checkReferences(doc, container, manifest, byId, spinePaths));
1272
+ messages.push(...checkFragments(doc, docs, manifest));
1273
+ messages.push(...checkElements(doc));
1274
+ messages.push(...checkLinkElements(doc));
1275
+ for (const style of doc.inlineStyles) {
1276
+ const a = analyzeCss(style.text, doc.path, style.context);
1277
+ messages.push(...a.messages);
1278
+ messages.push(...validateCss({
1279
+ path: doc.path,
1280
+ refs: a.refs,
1281
+ declarations: a.declarations,
1282
+ fontFaces: a.fontFaces
1283
+ }, container, manifest));
1284
+ }
1285
+ }
1286
+ return messages;
1287
+ }
1288
+ function isFragmentCheckable(mediaType) {
1289
+ return mediaType === "application/xhtml+xml";
1290
+ }
1291
+ function checkFragments(doc, docs, manifest) {
1292
+ const messages = [];
1293
+ for (const ref of doc.refs) {
1294
+ const hash = ref.url.indexOf("#");
1295
+ if (hash < 0) continue;
1296
+ const frag = ref.url.slice(hash + 1);
1297
+ if (frag === "") continue;
1298
+ const base = ref.url.slice(0, hash);
1299
+ let ids;
1300
+ if (base === "") ids = doc.ids;
1301
+ else {
1302
+ if (isRemote(ref.url) || hasScheme(base)) continue;
1303
+ const target = resolvePath(doc.path, base);
1304
+ const item = manifest.get(target);
1305
+ if (!item || !isFragmentCheckable(item.mediaType)) continue;
1306
+ ids = docs.get(target)?.ids;
1307
+ if (!ids) continue;
1308
+ }
1309
+ if (!ids.has(frag)) messages.push(msg("RSC-012", ref.loc));
1310
+ }
1311
+ return messages;
1312
+ }
1313
+ function checkElements(doc) {
1314
+ const messages = [];
1315
+ const walk = (node) => {
1316
+ for (const child of node.children ?? []) {
1317
+ if (child.type !== "element") continue;
1318
+ const name = child.name ?? "";
1319
+ if (child.ns === HTML_NS && !name.includes("-") && !isKnownHtmlElement(name)) messages.push(msg("RSC-005", child.loc, doc.path, `Unknown element "${name}" in the XHTML namespace.`));
1320
+ walk(child);
1321
+ }
1322
+ };
1323
+ walk(doc.root);
1324
+ return messages;
1325
+ }
1326
+ const ALTCSS_CONFLICTS = [["vertical", "horizontal"], ["day", "night"]];
1327
+ function checkLinkElements(doc) {
1328
+ const messages = [];
1329
+ for (const link of findDescendants(doc.root, "link")) {
1330
+ if (link.ns !== HTML_NS) continue;
1331
+ const attrs = link.attrs ?? {};
1332
+ const rel = (attrs["rel"] ?? "").split(/\s+/).filter(Boolean);
1333
+ if (rel.includes("alternate") && rel.includes("stylesheet") && (attrs["title"] ?? "").trim() === "") messages.push(msg("CSS-015", link.loc));
1334
+ const classes = new Set((attrs["class"] ?? "").split(/\s+/).filter(Boolean));
1335
+ if (ALTCSS_CONFLICTS.some(([a, b]) => classes.has(a) && classes.has(b))) messages.push(msg("CSS-005", link.loc, attrs["class"] ?? ""));
1336
+ }
1337
+ return messages;
1338
+ }
1339
+ function checkReferences(doc, container, manifest, byId, spinePaths) {
1340
+ const messages = [];
1341
+ for (const ref of doc.refs) {
1342
+ const url = ref.url;
1343
+ if (url.startsWith("#")) continue;
1344
+ if (isRemote(url)) {
1345
+ if (!REMOTE_ALLOWED.has(ref.type)) messages.push(msg("RSC-006", ref.loc, url));
1346
+ else if (ref.type !== "hyperlink") {
1347
+ const scheme = url.slice(0, url.indexOf(":")).toLowerCase();
1348
+ if (scheme !== "https" && scheme !== "file") messages.push(msg("RSC-031", ref.loc, url));
1349
+ }
1350
+ continue;
1351
+ }
1352
+ if (hasScheme(url)) continue;
1353
+ const target = resolvePath(doc.path, url);
1354
+ if (!getResource(container, target)) messages.push(msg("RSC-007", ref.loc, url));
1355
+ else if (!manifest.has(target)) messages.push(msg("RSC-008", ref.loc, url));
1356
+ else if (ref.type === "hyperlink") {
1357
+ const item = manifest.get(target);
1358
+ if (item) {
1359
+ if (!isBlessedContentType(item.mediaType) && !hasFallbackToBlessed(item, byId)) messages.push(msg("RSC-010", ref.loc));
1360
+ else if (!spinePaths.has(target)) messages.push(msg("RSC-011", ref.loc));
1361
+ }
1362
+ } else if (ref.type === "image" || ref.type === "audio" || ref.type === "video" || ref.type === "generic") {
1363
+ const item = manifest.get(target);
1364
+ if (item && !ref.hasIntrinsicFallback && !isCoreMediaType(item.mediaType) && !hasFallbackTo(item, byId, (i) => isCoreMediaType(i.mediaType))) messages.push(msg("RSC-032", ref.loc, target, item.mediaType ?? ""));
1365
+ }
1366
+ }
1367
+ return messages;
1368
+ }
1369
+ //#endregion
1370
+ //#region src/report.ts
1371
+ function buildReport(messages, epubVersion) {
1372
+ const counts = {
1373
+ FATAL: 0,
1374
+ ERROR: 0,
1375
+ WARNING: 0,
1376
+ INFO: 0,
1377
+ USAGE: 0
1378
+ };
1379
+ for (const message of messages) counts[message.severity]++;
1380
+ return {
1381
+ messages,
1382
+ epubVersion,
1383
+ counts,
1384
+ fatal: counts.FATAL > 0,
1385
+ valid: counts.FATAL === 0 && counts.ERROR === 0
1386
+ };
1387
+ }
1388
+ //#endregion
1389
+ //#region src/validate.ts
1390
+ async function validateEpub(input, options = {}) {
1391
+ const messages = [];
1392
+ try {
1393
+ const container = await openEpub(input);
1394
+ messages.push(...validateOcf(container));
1395
+ const { pkg, messages: opfMessages } = parseOpf(container);
1396
+ messages.push(...opfMessages);
1397
+ let detectedVersion;
1398
+ if (pkg) {
1399
+ messages.push(...validateOpf(pkg, container));
1400
+ messages.push(...checkUndeclaredResources(pkg, container));
1401
+ if (pkg.version === "2.0") detectedVersion = "2.0";
1402
+ else if (pkg.version === "3.0") detectedVersion = "3.0";
1403
+ if (options.version && detectedVersion && options.version !== detectedVersion) messages.push(msg("PKG-001", pkg.loc, options.version, detectedVersion));
1404
+ if (detectedVersion === "3.0") {
1405
+ const navItem = pkg.manifest.find((i) => i.properties.includes("nav"));
1406
+ if (navItem) {
1407
+ const { nav, messages: navMessages } = parseNav(navItem, container);
1408
+ messages.push(...navMessages);
1409
+ if (nav) messages.push(...validateNav(nav, pkg, container));
1410
+ }
1411
+ messages.push(...validateContentDocs(pkg, container));
1412
+ messages.push(...validateCssDocs(pkg, container));
1413
+ }
1414
+ }
1415
+ return buildReport(messages, options.version ?? detectedVersion);
1416
+ } catch (error) {
1417
+ const reason = error instanceof Error ? error.message : String(error);
1418
+ const id = /zip/i.test(reason) ? "PKG-003" : "CHK-001";
1419
+ messages.push(msg(id, void 0, reason));
1420
+ return buildReport(messages, options.version);
1421
+ }
1422
+ }
1423
+ //#endregion
1424
+ //#region src/index.ts
1425
+ const VERSION = version;
1426
+ //#endregion
1427
+ export { VERSION, analyzeCss, buildReport, childElements, findDescendants, getResource, msg, openEpub, parseContent, parseCss, parseNav, parseOpf, parseXml, textContent, validateContentDocs, validateCss, validateCssDocs, validateEpub, validateNav, validateOcf, validateOpf };