@likecoin/epubcheck-ts 0.1.0 → 0.2.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 CHANGED
@@ -2,8 +2,25 @@ import { XmlDocument } from 'libxml2-wasm';
2
2
  import { unzipSync, strFromU8, gunzipSync } from 'fflate';
3
3
 
4
4
  // src/content/validator.ts
5
+
6
+ // src/references/types.ts
7
+ function isPublicationResourceReference(type) {
8
+ return [
9
+ "generic" /* GENERIC */,
10
+ "stylesheet" /* STYLESHEET */,
11
+ "font" /* FONT */,
12
+ "image" /* IMAGE */,
13
+ "audio" /* AUDIO */,
14
+ "video" /* VIDEO */,
15
+ "track" /* TRACK */,
16
+ "media-overlay" /* MEDIA_OVERLAY */
17
+ ].includes(type);
18
+ }
19
+
20
+ // src/content/validator.ts
21
+ var DISCOURAGED_ELEMENTS = /* @__PURE__ */ new Set(["base", "embed"]);
5
22
  var ContentValidator = class {
6
- validate(context) {
23
+ validate(context, registry, refValidator) {
7
24
  const packageDoc = context.packageDocument;
8
25
  if (!packageDoc) {
9
26
  return;
@@ -13,11 +30,22 @@ var ContentValidator = class {
13
30
  for (const item of packageDoc.manifest) {
14
31
  if (item.mediaType === "application/xhtml+xml") {
15
32
  const fullPath = opfDir ? `${opfDir}/${item.href}` : item.href;
16
- this.validateXHTMLDocument(context, fullPath, item.id);
33
+ this.validateXHTMLDocument(context, fullPath, item.id, opfDir, registry, refValidator);
34
+ } else if (item.mediaType === "text/css" && refValidator) {
35
+ const fullPath = opfDir ? `${opfDir}/${item.href}` : item.href;
36
+ this.validateCSSDocument(context, fullPath, opfDir, refValidator);
17
37
  }
18
38
  }
19
39
  }
20
- validateXHTMLDocument(context, path, itemId) {
40
+ validateCSSDocument(context, path, opfDir, refValidator) {
41
+ const cssData = context.files.get(path);
42
+ if (!cssData) {
43
+ return;
44
+ }
45
+ const cssContent = new TextDecoder().decode(cssData);
46
+ this.extractCSSImports(path, cssContent, opfDir, refValidator);
47
+ }
48
+ validateXHTMLDocument(context, path, itemId, opfDir, registry, refValidator) {
21
49
  const data = context.files.get(path);
22
50
  if (!data) {
23
51
  return;
@@ -33,12 +61,19 @@ var ContentValidator = class {
33
61
  doc = XmlDocument.fromString(content);
34
62
  } catch (error) {
35
63
  if (error instanceof Error) {
36
- const message = this.normalizeErrorMessage(error.message);
64
+ const { message, line, column } = this.parseLibxmlError(error.message);
65
+ const location = { path };
66
+ if (line !== void 0) {
67
+ location.line = line;
68
+ }
69
+ if (column !== void 0) {
70
+ location.column = column;
71
+ }
37
72
  context.messages.push({
38
73
  id: "HTM-004",
39
74
  severity: "error",
40
75
  message,
41
- location: { path }
76
+ location
42
77
  });
43
78
  }
44
79
  return;
@@ -82,33 +117,100 @@ var ContentValidator = class {
82
117
  location: { path }
83
118
  });
84
119
  }
85
- const isNavItem = packageDoc.manifest.find(
86
- (item) => item.id === itemId && item.properties?.includes("nav")
120
+ const manifestItem = packageDoc.manifest.find(
121
+ (item) => item.id === itemId
87
122
  );
123
+ const isNavItem = manifestItem?.properties?.includes("nav");
88
124
  if (isNavItem) {
89
125
  this.checkNavDocument(context, path, doc, root);
90
126
  }
127
+ if (context.version.startsWith("3")) {
128
+ const hasScripts = this.detectScripts(context, path, root);
129
+ if (hasScripts && !manifestItem?.properties?.includes("scripted")) {
130
+ context.messages.push({
131
+ id: "OPF-014",
132
+ severity: "error",
133
+ message: 'Content document contains scripts but manifest item is missing "scripted" property',
134
+ location: { path }
135
+ });
136
+ }
137
+ const hasMathML = this.detectMathML(context, path, root);
138
+ if (hasMathML && !manifestItem?.properties?.includes("mathml")) {
139
+ context.messages.push({
140
+ id: "OPF-014",
141
+ severity: "error",
142
+ message: 'Content document contains MathML but manifest item is missing "mathml" property',
143
+ location: { path }
144
+ });
145
+ }
146
+ const hasSVG = this.detectSVG(context, path, root);
147
+ if (hasSVG && !manifestItem?.properties?.includes("svg")) {
148
+ context.messages.push({
149
+ id: "OPF-014",
150
+ severity: "error",
151
+ message: 'Content document contains SVG but manifest item is missing "svg" property',
152
+ location: { path }
153
+ });
154
+ }
155
+ const hasRemoteResources = this.detectRemoteResources(context, path, root);
156
+ if (hasRemoteResources && !manifestItem?.properties?.includes("remote-resources")) {
157
+ context.messages.push({
158
+ id: "OPF-014",
159
+ severity: "error",
160
+ message: 'Content document references remote resources but manifest item is missing "remote-resources" property',
161
+ location: { path }
162
+ });
163
+ }
164
+ }
165
+ this.checkDiscouragedElements(context, path, root);
166
+ this.checkAccessibility(context, path, root);
167
+ this.validateImages(context, path, root);
168
+ if (context.version.startsWith("3")) {
169
+ this.validateEpubTypes(context, path, root);
170
+ }
171
+ this.validateStylesheetLinks(context, path, root);
172
+ this.validateViewportMeta(context, path, root, manifestItem);
173
+ if (registry) {
174
+ this.extractAndRegisterIDs(path, root, registry);
175
+ }
176
+ if (refValidator && opfDir !== void 0) {
177
+ this.extractAndRegisterHyperlinks(path, root, opfDir, refValidator);
178
+ this.extractAndRegisterStylesheets(path, root, opfDir, refValidator);
179
+ this.extractAndRegisterImages(path, root, opfDir, refValidator);
180
+ }
91
181
  } finally {
92
182
  doc.dispose();
93
183
  }
94
184
  }
95
- normalizeErrorMessage(error) {
185
+ parseLibxmlError(error) {
186
+ const lineRegex = /(?:Entity:\s*)?line\s+(\d+):/;
187
+ const lineMatch = lineRegex.exec(error);
188
+ const line = lineMatch?.[1] ? Number.parseInt(lineMatch[1], 10) : void 0;
189
+ const columnRegex = /line\s+\d+:\s*(\d+):/;
190
+ const columnMatch = columnRegex.exec(error);
191
+ const column = columnMatch?.[1] ? Number.parseInt(columnMatch[1], 10) : void 0;
192
+ let message = error;
96
193
  if (error.includes("Opening and ending tag mismatch")) {
97
- return `Mismatched closing tag: ${error.replace("Opening and ending tag mismatch: ", "")}`;
98
- }
99
- if (error.includes("mismatch")) {
100
- return `Mismatched closing tag: ${error}`;
194
+ message = `Mismatched closing tag: ${error.replace("Opening and ending tag mismatch: ", "")}`;
195
+ } else if (error.includes("mismatch")) {
196
+ message = `Mismatched closing tag: ${error}`;
197
+ } else {
198
+ message = error.replace(/^Entity:\s*line\s+\d+:\s*(parser\s+error\s*:)?\s*/, "");
101
199
  }
102
- return error;
200
+ return { message, line, column };
103
201
  }
104
202
  checkUnescapedAmpersands(context, path, content) {
105
- const ampersandPattern = /&(?!(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);)/g;
106
- if (ampersandPattern.test(content)) {
203
+ const ampersandRegex = /&(?!(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);)/g;
204
+ let match;
205
+ while ((match = ampersandRegex.exec(content)) !== null) {
206
+ const index = match.index ?? 0;
207
+ const before = content.substring(0, index);
208
+ const line = before.split("\n").length;
107
209
  context.messages.push({
108
210
  id: "HTM-012",
109
211
  severity: "error",
110
212
  message: "Unescaped ampersand",
111
- location: { path }
213
+ location: { path, line }
112
214
  });
113
215
  }
114
216
  }
@@ -146,6 +248,633 @@ var ContentValidator = class {
146
248
  location: { path }
147
249
  });
148
250
  }
251
+ this.checkNavRemoteLinks(context, path, root, epubTypeAttr?.value ?? "");
252
+ }
253
+ checkNavRemoteLinks(context, path, root, epubTypeValue) {
254
+ const navTypes = epubTypeValue.split(/\s+/);
255
+ const isToc = navTypes.includes("toc");
256
+ const isLandmarks = navTypes.includes("landmarks");
257
+ const isPageList = navTypes.includes("page-list");
258
+ if (!isToc && !isLandmarks && !isPageList) {
259
+ return;
260
+ }
261
+ const links = root.find(".//html:a[@href]", { html: "http://www.w3.org/1999/xhtml" });
262
+ for (const link of links) {
263
+ const href = this.getAttribute(link, "href");
264
+ if (href && (href.startsWith("http://") || href.startsWith("https://"))) {
265
+ const navType = isToc ? "toc" : isLandmarks ? "landmarks" : "page-list";
266
+ context.messages.push({
267
+ id: "NAV-010",
268
+ severity: "error",
269
+ message: `"${navType}" nav must not link to remote resources; found link to "${href}"`,
270
+ location: { path }
271
+ });
272
+ }
273
+ }
274
+ }
275
+ detectScripts(_context, _path, root) {
276
+ const htmlScript = root.get(".//html:script", { html: "http://www.w3.org/1999/xhtml" });
277
+ if (htmlScript) return true;
278
+ const svgScript = root.get(".//svg:script", { svg: "http://www.w3.org/2000/svg" });
279
+ if (svgScript) return true;
280
+ const form = root.get(".//html:form", { html: "http://www.w3.org/1999/xhtml" });
281
+ if (form) return true;
282
+ const elementsWithEvents = root.find(
283
+ ".//*[@onclick or @onload or @onmouseover or @onmouseout or @onchange or @onsubmit or @onfocus or @onblur]"
284
+ );
285
+ if (elementsWithEvents.length > 0) return true;
286
+ return false;
287
+ }
288
+ detectMathML(_context, _path, root) {
289
+ const mathMLElements = root.find(".//math:*", { math: "http://www.w3.org/1998/Math/MathML" });
290
+ return mathMLElements.length > 0;
291
+ }
292
+ detectSVG(_context, _path, root) {
293
+ const svgElement = root.get(".//html:svg", { html: "http://www.w3.org/1999/xhtml" });
294
+ if (svgElement) return true;
295
+ const rootSvg = root.get(".//svg:svg", { svg: "http://www.w3.org/2000/svg" });
296
+ if (rootSvg) return true;
297
+ return false;
298
+ }
299
+ /**
300
+ * Detect if the content document references remote resources that require
301
+ * the "remote-resources" property in the manifest.
302
+ * Per EPUB spec and Java EPUBCheck behavior:
303
+ * - Remote images, audio, video, fonts REQUIRE the property
304
+ * - Remote hyperlinks (<a href>) do NOT require the property
305
+ * - Remote scripts do NOT require the property (scripted property is used instead)
306
+ * - Remote stylesheets DO require the property
307
+ */
308
+ detectRemoteResources(_context, _path, root) {
309
+ const images = root.find(".//html:img[@src]", { html: "http://www.w3.org/1999/xhtml" });
310
+ for (const img of images) {
311
+ const src = this.getAttribute(img, "src");
312
+ if (src && (src.startsWith("http://") || src.startsWith("https://"))) {
313
+ return true;
314
+ }
315
+ }
316
+ const audio = root.find(".//html:audio[@src]", { html: "http://www.w3.org/1999/xhtml" });
317
+ for (const elem of audio) {
318
+ const src = this.getAttribute(elem, "src");
319
+ if (src && (src.startsWith("http://") || src.startsWith("https://"))) {
320
+ return true;
321
+ }
322
+ }
323
+ const video = root.find(".//html:video[@src]", { html: "http://www.w3.org/1999/xhtml" });
324
+ for (const elem of video) {
325
+ const src = this.getAttribute(elem, "src");
326
+ if (src && (src.startsWith("http://") || src.startsWith("https://"))) {
327
+ return true;
328
+ }
329
+ }
330
+ const sources = root.find(".//html:source[@src]", { html: "http://www.w3.org/1999/xhtml" });
331
+ for (const source of sources) {
332
+ const src = this.getAttribute(source, "src");
333
+ if (src && (src.startsWith("http://") || src.startsWith("https://"))) {
334
+ return true;
335
+ }
336
+ }
337
+ const linkElements = root.find(".//html:link[@rel and @href]", {
338
+ html: "http://www.w3.org/1999/xhtml"
339
+ });
340
+ for (const linkElem of linkElements) {
341
+ const rel = this.getAttribute(linkElem, "rel");
342
+ const href = this.getAttribute(linkElem, "href");
343
+ if (href && rel?.toLowerCase().includes("stylesheet")) {
344
+ if (href.startsWith("http://") || href.startsWith("https://")) {
345
+ return true;
346
+ }
347
+ }
348
+ }
349
+ return false;
350
+ }
351
+ checkDiscouragedElements(context, path, root) {
352
+ for (const elemName of DISCOURAGED_ELEMENTS) {
353
+ const element = root.get(`.//html:${elemName}`, { html: "http://www.w3.org/1999/xhtml" });
354
+ if (element) {
355
+ context.messages.push({
356
+ id: "HTM-055",
357
+ severity: "usage",
358
+ message: `The "${elemName}" element is discouraged in EPUB`,
359
+ location: { path }
360
+ });
361
+ }
362
+ }
363
+ }
364
+ checkAccessibility(context, path, root) {
365
+ const links = root.find(".//html:a", { html: "http://www.w3.org/1999/xhtml" });
366
+ for (const link of links) {
367
+ if (!this.hasAccessibleContent(link)) {
368
+ context.messages.push({
369
+ id: "ACC-004",
370
+ severity: "warning",
371
+ message: "Hyperlink has no accessible text content",
372
+ location: { path }
373
+ });
374
+ }
375
+ }
376
+ const images = root.find(".//html:img", { html: "http://www.w3.org/1999/xhtml" });
377
+ for (const img of images) {
378
+ const altAttr = this.getAttribute(img, "alt");
379
+ if (altAttr === null) {
380
+ context.messages.push({
381
+ id: "ACC-005",
382
+ severity: "warning",
383
+ message: "Image is missing alt attribute",
384
+ location: { path }
385
+ });
386
+ }
387
+ }
388
+ const svgLinks = root.find(".//svg:a", {
389
+ svg: "http://www.w3.org/2000/svg",
390
+ xlink: "http://www.w3.org/1999/xlink"
391
+ });
392
+ for (const svgLink of svgLinks) {
393
+ const svgElem = svgLink;
394
+ const title = svgElem.get("./svg:title", { svg: "http://www.w3.org/2000/svg" });
395
+ const ariaLabel = this.getAttribute(svgElem, "aria-label");
396
+ if (!title && !ariaLabel) {
397
+ context.messages.push({
398
+ id: "ACC-011",
399
+ severity: "usage",
400
+ message: "SVG hyperlink has no accessible name (missing title element or aria-label)",
401
+ location: { path }
402
+ });
403
+ }
404
+ }
405
+ const mathElements = root.find(".//math:math", { math: "http://www.w3.org/1998/Math/MathML" });
406
+ for (const mathElem of mathElements) {
407
+ const elem = mathElem;
408
+ const alttext = elem.attr("alttext");
409
+ const annotation = elem.get('./math:annotation[@encoding="application/x-tex"]', {
410
+ math: "http://www.w3.org/1998/Math/MathML"
411
+ });
412
+ const ariaLabel = this.getAttribute(elem, "aria-label");
413
+ if (!alttext?.value && !annotation && !ariaLabel) {
414
+ context.messages.push({
415
+ id: "ACC-009",
416
+ severity: "usage",
417
+ message: "MathML element should have alttext attribute or annotation for accessibility",
418
+ location: { path }
419
+ });
420
+ }
421
+ }
422
+ }
423
+ validateImages(context, path, root) {
424
+ const packageDoc = context.packageDocument;
425
+ if (!packageDoc) return;
426
+ const images = root.find(".//html:img[@src]", { html: "http://www.w3.org/1999/xhtml" });
427
+ for (const img of images) {
428
+ const imgElem = img;
429
+ const srcAttr = this.getAttribute(imgElem, "src");
430
+ if (!srcAttr) continue;
431
+ const src = srcAttr;
432
+ const opfDir = context.opfPath?.includes("/") ? context.opfPath.substring(0, context.opfPath.lastIndexOf("/")) : "";
433
+ let fullPath = src;
434
+ if (opfDir && !src.startsWith("http://") && !src.startsWith("https://")) {
435
+ if (src.startsWith("/")) {
436
+ fullPath = src.slice(1);
437
+ } else {
438
+ const parts = opfDir.split("/");
439
+ const relParts = src.split("/");
440
+ for (const part of relParts) {
441
+ if (part === "..") {
442
+ parts.pop();
443
+ } else if (part !== ".") {
444
+ parts.push(part);
445
+ }
446
+ }
447
+ fullPath = parts.join("/");
448
+ }
449
+ }
450
+ if (src.startsWith("http://") || src.startsWith("https://")) {
451
+ continue;
452
+ }
453
+ const manifestItem = packageDoc.manifest.find(
454
+ (item) => fullPath.endsWith(item.href) || item.href.endsWith(fullPath)
455
+ );
456
+ if (!manifestItem) {
457
+ context.messages.push({
458
+ id: "MED-001",
459
+ severity: "error",
460
+ message: `Image src references missing manifest item: ${src}`,
461
+ location: { path }
462
+ });
463
+ continue;
464
+ }
465
+ const imageMediaTypes = /* @__PURE__ */ new Set([
466
+ "image/gif",
467
+ "image/jpeg",
468
+ "image/jpg",
469
+ "image/png",
470
+ "image/svg+xml",
471
+ "image/webp"
472
+ ]);
473
+ if (!imageMediaTypes.has(manifestItem.mediaType)) {
474
+ context.messages.push({
475
+ id: "OPF-051",
476
+ severity: "error",
477
+ message: `Image has invalid media type "${manifestItem.mediaType}": ${src}`,
478
+ location: { path }
479
+ });
480
+ }
481
+ }
482
+ }
483
+ validateEpubTypes(context, path, root) {
484
+ const epubTypeElements = root.find(".//*[@epub:type]", {
485
+ epub: "http://www.idpf.org/2007/ops"
486
+ });
487
+ const knownPrefixes = /* @__PURE__ */ new Set([
488
+ "",
489
+ "http://idpf.org/epub/structure/v1/",
490
+ "http://idpf.org/epub/vocab/structure/",
491
+ "http://www.idpf.org/2007/ops"
492
+ ]);
493
+ for (const elem of epubTypeElements) {
494
+ const elemTyped = elem;
495
+ const epubTypeAttr = elemTyped.attr("epub:type");
496
+ if (!epubTypeAttr?.value) continue;
497
+ const epubTypeValue = epubTypeAttr.value;
498
+ for (const part of epubTypeValue.split(/\s+/)) {
499
+ const prefix = part.includes(":") ? part.substring(0, part.indexOf(":")) : "";
500
+ if (!knownPrefixes.has(prefix) && !prefix.startsWith("http://") && !prefix.startsWith("https://")) {
501
+ context.messages.push({
502
+ id: "OPF-088",
503
+ severity: "usage",
504
+ message: `Unknown epub:type prefix "${prefix}": ${epubTypeValue}`,
505
+ location: { path }
506
+ });
507
+ }
508
+ }
509
+ }
510
+ }
511
+ validateStylesheetLinks(context, path, root) {
512
+ const linkElements = root.find(".//html:link[@rel]", { html: "http://www.w3.org/1999/xhtml" });
513
+ const stylesheetTitles = /* @__PURE__ */ new Map();
514
+ for (const linkElem of linkElements) {
515
+ const elem = linkElem;
516
+ const relAttr = this.getAttribute(elem, "rel");
517
+ const titleAttr = this.getAttribute(elem, "title");
518
+ const hrefAttr = this.getAttribute(elem, "href");
519
+ if (!relAttr || !hrefAttr) continue;
520
+ const rel = relAttr.toLowerCase();
521
+ const rels = rel.split(/\s+/);
522
+ if (rels.includes("stylesheet")) {
523
+ const isAlternate = rels.includes("alternate");
524
+ if (isAlternate && !titleAttr) {
525
+ context.messages.push({
526
+ id: "CSS-015",
527
+ severity: "error",
528
+ message: "Alternate stylesheet must have a title attribute",
529
+ location: { path }
530
+ });
531
+ }
532
+ if (titleAttr) {
533
+ const key = `${titleAttr}:${isAlternate ? "alt" : "persistent"}`;
534
+ const expectedRel = isAlternate ? "alternate" : "persistent";
535
+ const existing = stylesheetTitles.get(key);
536
+ if (existing) {
537
+ if (!existing.has(expectedRel)) {
538
+ context.messages.push({
539
+ id: "CSS-005",
540
+ severity: "error",
541
+ message: `Stylesheet with title "${titleAttr}" conflicts with another stylesheet with same title`,
542
+ location: { path }
543
+ });
544
+ }
545
+ existing.add(expectedRel);
546
+ } else {
547
+ stylesheetTitles.set(key, /* @__PURE__ */ new Set([expectedRel]));
548
+ }
549
+ }
550
+ }
551
+ }
552
+ }
553
+ hasAccessibleContent(element) {
554
+ const textContent = element.content;
555
+ if (textContent && textContent.trim().length > 0) {
556
+ return true;
557
+ }
558
+ const ariaLabel = this.getAttribute(element, "aria-label");
559
+ if (ariaLabel && ariaLabel.trim().length > 0) {
560
+ return true;
561
+ }
562
+ const img = element.get("./html:img[@alt]", { html: "http://www.w3.org/1999/xhtml" });
563
+ if (img) {
564
+ const alt = this.getAttribute(img, "alt");
565
+ if (alt && alt.trim().length > 0) {
566
+ return true;
567
+ }
568
+ }
569
+ const title = this.getAttribute(element, "title");
570
+ if (title && title.trim().length > 0) {
571
+ return true;
572
+ }
573
+ return false;
574
+ }
575
+ getAttribute(element, name) {
576
+ if (!("attrs" in element)) return null;
577
+ const attrs = element.attrs;
578
+ const attr = attrs.find((a) => a.name === name);
579
+ return attr?.value ?? null;
580
+ }
581
+ validateViewportMeta(context, path, root, manifestItem) {
582
+ const isFixedLayout = manifestItem?.properties?.includes("fixed-layout");
583
+ const metaTags = root.find(".//html:meta[@name]", { html: "http://www.w3.org/1999/xhtml" });
584
+ let hasViewportMeta = false;
585
+ for (const meta of metaTags) {
586
+ const nameAttr = this.getAttribute(meta, "name");
587
+ if (nameAttr === "viewport") {
588
+ hasViewportMeta = true;
589
+ const contentAttr = this.getAttribute(meta, "content");
590
+ if (isFixedLayout) {
591
+ if (!contentAttr) {
592
+ context.messages.push({
593
+ id: "HTM-046",
594
+ severity: "error",
595
+ message: "Viewport meta element should have a content attribute in fixed-layout documents",
596
+ location: { path }
597
+ });
598
+ continue;
599
+ }
600
+ const contentLower = contentAttr.toLowerCase();
601
+ if (contentLower.includes("width=device-width")) {
602
+ context.messages.push({
603
+ id: "HTM-047",
604
+ severity: "error",
605
+ message: 'Viewport width should not be set to "device-width" in fixed-layout documents',
606
+ location: { path }
607
+ });
608
+ }
609
+ if (contentLower.includes("height=device-height")) {
610
+ context.messages.push({
611
+ id: "HTM-048",
612
+ severity: "error",
613
+ message: 'Viewport height should not be set to "device-height" in fixed-layout documents',
614
+ location: { path }
615
+ });
616
+ }
617
+ } else {
618
+ context.messages.push({
619
+ id: "HTM-060b",
620
+ severity: "usage",
621
+ message: `EPUB reading systems must ignore viewport meta elements in reflowable documents; viewport declaration "${contentAttr ?? ""}" will be ignored`,
622
+ location: { path }
623
+ });
624
+ }
625
+ }
626
+ }
627
+ if (isFixedLayout && !hasViewportMeta) {
628
+ context.messages.push({
629
+ id: "HTM-049",
630
+ severity: "info",
631
+ message: "Fixed-layout document should include a viewport meta element",
632
+ location: { path }
633
+ });
634
+ }
635
+ }
636
+ extractAndRegisterIDs(path, root, registry) {
637
+ const elementsWithId = root.find(".//*[@id]");
638
+ for (const elem of elementsWithId) {
639
+ const id = this.getAttribute(elem, "id");
640
+ if (id) {
641
+ registry.registerID(path, id);
642
+ }
643
+ }
644
+ }
645
+ extractAndRegisterHyperlinks(path, root, opfDir, refValidator) {
646
+ const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
647
+ const links = root.find(".//html:a[@href]", { html: "http://www.w3.org/1999/xhtml" });
648
+ for (const link of links) {
649
+ const href = this.getAttribute(link, "href");
650
+ if (!href) continue;
651
+ if (href.startsWith("http://") || href.startsWith("https://")) {
652
+ continue;
653
+ }
654
+ if (href.startsWith("mailto:") || href.startsWith("tel:")) {
655
+ continue;
656
+ }
657
+ if (href.startsWith("#")) {
658
+ const targetResource2 = path;
659
+ const fragment = href.slice(1);
660
+ refValidator.addReference({
661
+ url: href,
662
+ targetResource: targetResource2,
663
+ fragment,
664
+ type: "hyperlink" /* HYPERLINK */,
665
+ location: { path }
666
+ });
667
+ continue;
668
+ }
669
+ const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
670
+ const hashIndex = resolvedPath.indexOf("#");
671
+ const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
672
+ const fragmentPart = hashIndex >= 0 ? resolvedPath.slice(hashIndex + 1) : void 0;
673
+ const ref = {
674
+ url: href,
675
+ targetResource,
676
+ type: "hyperlink" /* HYPERLINK */,
677
+ location: { path }
678
+ };
679
+ if (fragmentPart) {
680
+ ref.fragment = fragmentPart;
681
+ }
682
+ refValidator.addReference(ref);
683
+ }
684
+ const svgLinks = root.find(".//svg:a", {
685
+ svg: "http://www.w3.org/2000/svg",
686
+ xlink: "http://www.w3.org/1999/xlink"
687
+ });
688
+ for (const link of svgLinks) {
689
+ const elem = link;
690
+ const href = this.getAttribute(elem, "xlink:href") ?? this.getAttribute(elem, "href");
691
+ if (!href) continue;
692
+ if (href.startsWith("http://") || href.startsWith("https://")) {
693
+ continue;
694
+ }
695
+ if (href.startsWith("#")) {
696
+ const targetResource2 = path;
697
+ const fragment = href.slice(1);
698
+ refValidator.addReference({
699
+ url: href,
700
+ targetResource: targetResource2,
701
+ fragment,
702
+ type: "hyperlink" /* HYPERLINK */,
703
+ location: { path }
704
+ });
705
+ continue;
706
+ }
707
+ const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
708
+ const hashIndex = resolvedPath.indexOf("#");
709
+ const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
710
+ const svgFragment = hashIndex >= 0 ? resolvedPath.slice(hashIndex + 1) : void 0;
711
+ const svgRef = {
712
+ url: href,
713
+ targetResource,
714
+ type: "hyperlink" /* HYPERLINK */,
715
+ location: { path }
716
+ };
717
+ if (svgFragment) {
718
+ svgRef.fragment = svgFragment;
719
+ }
720
+ refValidator.addReference(svgRef);
721
+ }
722
+ }
723
+ extractAndRegisterStylesheets(path, root, opfDir, refValidator) {
724
+ const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
725
+ const linkElements = root.find(".//html:link[@href]", { html: "http://www.w3.org/1999/xhtml" });
726
+ for (const linkElem of linkElements) {
727
+ const href = this.getAttribute(linkElem, "href");
728
+ const rel = this.getAttribute(linkElem, "rel");
729
+ if (!href) continue;
730
+ const isStylesheet = rel?.toLowerCase().includes("stylesheet");
731
+ const type = isStylesheet ? "stylesheet" /* STYLESHEET */ : "link" /* LINK */;
732
+ if (href.startsWith("http://") || href.startsWith("https://")) {
733
+ refValidator.addReference({
734
+ url: href,
735
+ targetResource: href,
736
+ type,
737
+ location: { path }
738
+ });
739
+ continue;
740
+ }
741
+ const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
742
+ const hashIndex = resolvedPath.indexOf("#");
743
+ const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
744
+ refValidator.addReference({
745
+ url: href,
746
+ targetResource,
747
+ type,
748
+ location: { path }
749
+ });
750
+ }
751
+ }
752
+ /**
753
+ * Parse CSS content and extract @import statements
754
+ */
755
+ extractCSSImports(cssPath, cssContent, opfDir, refValidator) {
756
+ const cssDir = cssPath.includes("/") ? cssPath.substring(0, cssPath.lastIndexOf("/")) : "";
757
+ const cleanedCSS = cssContent.replace(/\/\*[\s\S]*?\*\//g, "");
758
+ const importRegex = /@import\s+(?:url\s*\(\s*)?["']([^"']+)["']\s*\)?[^;]*;/gi;
759
+ let match;
760
+ while ((match = importRegex.exec(cleanedCSS)) !== null) {
761
+ const importUrl = match[1];
762
+ if (!importUrl) continue;
763
+ if (importUrl.startsWith("http://") || importUrl.startsWith("https://")) {
764
+ refValidator.addReference({
765
+ url: importUrl,
766
+ targetResource: importUrl,
767
+ type: "stylesheet" /* STYLESHEET */,
768
+ location: { path: cssPath }
769
+ });
770
+ continue;
771
+ }
772
+ const resolvedPath = this.resolveRelativePath(cssDir, importUrl, opfDir);
773
+ refValidator.addReference({
774
+ url: importUrl,
775
+ targetResource: resolvedPath,
776
+ type: "stylesheet" /* STYLESHEET */,
777
+ location: { path: cssPath }
778
+ });
779
+ }
780
+ }
781
+ extractAndRegisterImages(path, root, opfDir, refValidator) {
782
+ const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
783
+ const images = root.find(".//html:img[@src]", { html: "http://www.w3.org/1999/xhtml" });
784
+ for (const img of images) {
785
+ const src = this.getAttribute(img, "src");
786
+ if (!src) continue;
787
+ if (src.startsWith("http://") || src.startsWith("https://")) {
788
+ refValidator.addReference({
789
+ url: src,
790
+ targetResource: src,
791
+ type: "image" /* IMAGE */,
792
+ location: { path }
793
+ });
794
+ continue;
795
+ }
796
+ const resolvedPath = this.resolveRelativePath(docDir, src, opfDir);
797
+ refValidator.addReference({
798
+ url: src,
799
+ targetResource: resolvedPath,
800
+ type: "image" /* IMAGE */,
801
+ location: { path }
802
+ });
803
+ }
804
+ let svgImages = [];
805
+ try {
806
+ const svgImagesXlink = root.find(".//svg:image[@xlink:href]", {
807
+ svg: "http://www.w3.org/2000/svg",
808
+ xlink: "http://www.w3.org/1999/xlink"
809
+ });
810
+ const svgImagesHref = root.find(".//svg:image[@href]", {
811
+ svg: "http://www.w3.org/2000/svg"
812
+ });
813
+ svgImages = [...svgImagesXlink, ...svgImagesHref];
814
+ } catch {
815
+ svgImages = [];
816
+ }
817
+ for (const svgImg of svgImages) {
818
+ const elem = svgImg;
819
+ const href = this.getAttribute(elem, "xlink:href") ?? this.getAttribute(elem, "href");
820
+ if (!href) continue;
821
+ if (href.startsWith("http://") || href.startsWith("https://")) {
822
+ refValidator.addReference({
823
+ url: href,
824
+ targetResource: href,
825
+ type: "image" /* IMAGE */,
826
+ location: { path }
827
+ });
828
+ continue;
829
+ }
830
+ const resolvedPath = this.resolveRelativePath(docDir, href, opfDir);
831
+ refValidator.addReference({
832
+ url: href,
833
+ targetResource: resolvedPath,
834
+ type: "image" /* IMAGE */,
835
+ location: { path }
836
+ });
837
+ }
838
+ const videos = root.find(".//html:video[@poster]", { html: "http://www.w3.org/1999/xhtml" });
839
+ for (const video of videos) {
840
+ const poster = this.getAttribute(video, "poster");
841
+ if (!poster) continue;
842
+ if (poster.startsWith("http://") || poster.startsWith("https://")) {
843
+ refValidator.addReference({
844
+ url: poster,
845
+ targetResource: poster,
846
+ type: "image" /* IMAGE */,
847
+ location: { path }
848
+ });
849
+ continue;
850
+ }
851
+ const resolvedPath = this.resolveRelativePath(docDir, poster, opfDir);
852
+ refValidator.addReference({
853
+ url: poster,
854
+ targetResource: resolvedPath,
855
+ type: "image" /* IMAGE */,
856
+ location: { path }
857
+ });
858
+ }
859
+ }
860
+ resolveRelativePath(docDir, href, _opfDir) {
861
+ const hrefWithoutFragment = href.split("#")[0] ?? href;
862
+ const fragment = href.includes("#") ? href.split("#")[1] : "";
863
+ if (hrefWithoutFragment.startsWith("/")) {
864
+ const result2 = hrefWithoutFragment.slice(1);
865
+ return fragment ? `${result2}#${fragment}` : result2;
866
+ }
867
+ const parts = docDir ? docDir.split("/") : [];
868
+ const relParts = hrefWithoutFragment.split("/");
869
+ for (const part of relParts) {
870
+ if (part === "..") {
871
+ parts.pop();
872
+ } else if (part !== "." && part !== "") {
873
+ parts.push(part);
874
+ }
875
+ }
876
+ const result = parts.join("/");
877
+ return fragment ? `${result}#${fragment}` : result;
149
878
  }
150
879
  };
151
880
 
@@ -228,9 +957,6 @@ function toJSONReport(result) {
228
957
  );
229
958
  }
230
959
  var NCXValidator = class {
231
- /**
232
- * Validate NCX document
233
- */
234
960
  validate(context, ncxContent, ncxPath) {
235
961
  let doc = null;
236
962
  try {
@@ -268,15 +994,11 @@ var NCXValidator = class {
268
994
  }
269
995
  this.checkUid(context, root, ncxPath);
270
996
  this.checkNavMap(context, root, ncxPath);
997
+ this.checkContentSrc(context, root, ncxPath);
271
998
  } finally {
272
999
  doc.dispose();
273
1000
  }
274
1001
  }
275
- /**
276
- * Check for dtb:uid meta element and validate it
277
- * Note: dtb:uid is recommended but not strictly required by the NCX spec.
278
- * The original epubcheck does not report an error if it's missing.
279
- */
280
1002
  checkUid(context, root, path) {
281
1003
  const uidMeta = root.get('.//ncx:head/ncx:meta[@name="dtb:uid"]', {
282
1004
  ncx: "http://www.daisy.org/z3986/2005/ncx/"
@@ -298,9 +1020,6 @@ var NCXValidator = class {
298
1020
  }
299
1021
  context.ncxUid = uidContent.trim();
300
1022
  }
301
- /**
302
- * Check for navMap element
303
- */
304
1023
  checkNavMap(context, root, path) {
305
1024
  const navMapNode = root.get(".//ncx:navMap", { ncx: "http://www.daisy.org/z3986/2005/ncx/" });
306
1025
  if (!navMapNode) {
@@ -312,15 +1031,54 @@ var NCXValidator = class {
312
1031
  });
313
1032
  }
314
1033
  }
1034
+ checkContentSrc(context, root, ncxPath) {
1035
+ const contentElements = root.find(".//ncx:content[@src]", {
1036
+ ncx: "http://www.daisy.org/z3986/2005/ncx/"
1037
+ });
1038
+ const ncxDir = ncxPath.includes("/") ? ncxPath.substring(0, ncxPath.lastIndexOf("/")) : "";
1039
+ for (const contentElem of contentElements) {
1040
+ const srcAttr = contentElem.attr("src");
1041
+ const src = srcAttr?.value;
1042
+ if (!src) continue;
1043
+ const srcBase = src.split("#")[0] ?? src;
1044
+ let fullPath = srcBase;
1045
+ if (ncxDir) {
1046
+ if (srcBase.startsWith("/")) {
1047
+ fullPath = srcBase.slice(1);
1048
+ } else {
1049
+ const parts = ncxDir.split("/");
1050
+ const relParts = srcBase.split("/");
1051
+ for (const part of relParts) {
1052
+ if (part === "..") {
1053
+ parts.pop();
1054
+ } else if (part !== ".") {
1055
+ parts.push(part);
1056
+ }
1057
+ }
1058
+ fullPath = parts.join("/");
1059
+ }
1060
+ }
1061
+ if (!context.files.has(fullPath) && !srcBase.startsWith("http://") && !srcBase.startsWith("https://")) {
1062
+ context.messages.push({
1063
+ id: "NCX-006",
1064
+ severity: "error",
1065
+ message: `NCX content src references missing file: ${src}`,
1066
+ location: { path: ncxPath }
1067
+ });
1068
+ }
1069
+ }
1070
+ }
315
1071
  };
316
1072
  var ZipReader = class _ZipReader {
317
1073
  files;
318
1074
  _paths;
319
1075
  _originalOrder;
320
- constructor(files, originalOrder) {
1076
+ _rawData;
1077
+ constructor(files, originalOrder, rawData) {
321
1078
  this.files = files;
322
1079
  this._originalOrder = originalOrder;
323
1080
  this._paths = Object.keys(files).sort();
1081
+ this._rawData = rawData;
324
1082
  }
325
1083
  /**
326
1084
  * Open a ZIP file from binary data
@@ -328,7 +1086,45 @@ var ZipReader = class _ZipReader {
328
1086
  static open(data) {
329
1087
  const files = unzipSync(data);
330
1088
  const originalOrder = Object.keys(files);
331
- return new _ZipReader(files, originalOrder);
1089
+ return new _ZipReader(files, originalOrder, data);
1090
+ }
1091
+ /**
1092
+ * Get compression info for the first entry (mimetype) from raw ZIP header
1093
+ * ZIP Local File Header format:
1094
+ * - Offset 0-3: Signature (0x04034b50)
1095
+ * - Offset 4-5: Version needed
1096
+ * - Offset 6-7: General purpose bit flag
1097
+ * - Offset 8-9: Compression method (0=stored, 8=deflated)
1098
+ * - Offset 10-13: Last mod time/date
1099
+ * - Offset 14-17: CRC-32
1100
+ * - Offset 18-21: Compressed size
1101
+ * - Offset 22-25: Uncompressed size
1102
+ * - Offset 26-27: Filename length
1103
+ * - Offset 28-29: Extra field length
1104
+ * - Offset 30+: Filename
1105
+ */
1106
+ getMimetypeCompressionInfo() {
1107
+ const data = this._rawData;
1108
+ if (data.length < 30) {
1109
+ return null;
1110
+ }
1111
+ if (data[0] !== 80 || data[1] !== 75 || data[2] !== 3 || data[3] !== 4) {
1112
+ return null;
1113
+ }
1114
+ const compressionMethod = (data[8] ?? 0) | (data[9] ?? 0) << 8;
1115
+ const filenameLength = (data[26] ?? 0) | (data[27] ?? 0) << 8;
1116
+ const extraFieldLength = (data[28] ?? 0) | (data[29] ?? 0) << 8;
1117
+ if (data.length < 30 + filenameLength) {
1118
+ return null;
1119
+ }
1120
+ const filenameBytes = data.slice(30, 30 + filenameLength);
1121
+ const filename = strFromU8(filenameBytes);
1122
+ return {
1123
+ compressionMethod,
1124
+ extraFieldLength,
1125
+ filenameLength,
1126
+ filename
1127
+ };
332
1128
  }
333
1129
  /**
334
1130
  * Get all file paths in the ZIP (sorted alphabetically)
@@ -406,6 +1202,8 @@ var OCFValidator = class {
406
1202
  this.validateMimetype(zip, context.messages);
407
1203
  this.validateContainer(zip, context);
408
1204
  this.validateMetaInf(zip, context.messages);
1205
+ this.validateFilenames(zip, context.messages);
1206
+ this.validateEmptyDirectories(zip, context.messages);
409
1207
  }
410
1208
  /**
411
1209
  * Validate the mimetype file
@@ -413,28 +1211,54 @@ var OCFValidator = class {
413
1211
  * Requirements:
414
1212
  * - Must exist
415
1213
  * - Must be first file in ZIP
416
- * - Must be uncompressed
1214
+ * - Must be uncompressed (compression method = 0)
1215
+ * - Must have no extra field
417
1216
  * - Must contain exactly "application/epub+zip"
418
1217
  */
419
1218
  validateMimetype(zip, messages) {
420
- if (!zip.has("mimetype")) {
1219
+ const compressionInfo = zip.getMimetypeCompressionInfo();
1220
+ if (compressionInfo === null) {
421
1221
  messages.push({
422
1222
  id: "PKG-006",
423
1223
  severity: "error",
424
- message: "Missing mimetype file",
1224
+ message: "Could not read ZIP header",
425
1225
  location: { path: "mimetype" }
426
1226
  });
427
1227
  return;
428
1228
  }
429
- const originalOrder = zip.originalOrder;
430
- if (originalOrder.length > 0 && originalOrder[0] !== "mimetype") {
1229
+ if (compressionInfo.filename !== "mimetype") {
1230
+ messages.push({
1231
+ id: "PKG-006",
1232
+ severity: "error",
1233
+ message: "Mimetype file must be the first file in the ZIP archive",
1234
+ location: { path: "mimetype" }
1235
+ });
1236
+ }
1237
+ if (compressionInfo.extraFieldLength !== 0) {
431
1238
  messages.push({
432
1239
  id: "PKG-005",
433
1240
  severity: "error",
434
- message: "The mimetype file must be the first file in the ZIP archive",
1241
+ message: `Mimetype entry must not have an extra field (found ${String(compressionInfo.extraFieldLength)} bytes)`,
435
1242
  location: { path: "mimetype" }
436
1243
  });
437
1244
  }
1245
+ if (compressionInfo.compressionMethod !== 0) {
1246
+ messages.push({
1247
+ id: "PKG-006",
1248
+ severity: "error",
1249
+ message: "Mimetype file must be uncompressed",
1250
+ location: { path: "mimetype" }
1251
+ });
1252
+ }
1253
+ if (!zip.has("mimetype")) {
1254
+ messages.push({
1255
+ id: "PKG-006",
1256
+ severity: "error",
1257
+ message: "Missing mimetype file",
1258
+ location: { path: "mimetype" }
1259
+ });
1260
+ return;
1261
+ }
438
1262
  const content = zip.readText("mimetype");
439
1263
  if (content === void 0) {
440
1264
  messages.push({
@@ -545,6 +1369,92 @@ var OCFValidator = class {
545
1369
  message: "Missing META-INF directory",
546
1370
  location: { path: "META-INF/" }
547
1371
  });
1372
+ return;
1373
+ }
1374
+ const allowedFiles = /* @__PURE__ */ new Set([
1375
+ "container.xml",
1376
+ "encryption.xml",
1377
+ "signatures.xml",
1378
+ "metadata.xml"
1379
+ ]);
1380
+ for (const file of metaInfFiles) {
1381
+ const filename = file.replace("META-INF/", "");
1382
+ if (!allowedFiles.has(filename)) {
1383
+ messages.push({
1384
+ id: "PKG-025",
1385
+ severity: "error",
1386
+ message: `File not allowed in META-INF directory: ${filename}`,
1387
+ location: { path: file }
1388
+ });
1389
+ }
1390
+ }
1391
+ }
1392
+ /**
1393
+ * Validate filenames for invalid characters
1394
+ */
1395
+ validateFilenames(zip, messages) {
1396
+ for (const path of zip.paths) {
1397
+ if (path === "mimetype") continue;
1398
+ const filename = path.includes("/") ? path.split("/").pop() ?? path : path;
1399
+ if (filename === "" || filename === "." || filename === "..") {
1400
+ messages.push({
1401
+ id: "PKG-009",
1402
+ severity: "error",
1403
+ message: `Invalid filename: "${path}"`,
1404
+ location: { path }
1405
+ });
1406
+ continue;
1407
+ }
1408
+ for (let i = 0; i < filename.length; i++) {
1409
+ const code = filename.charCodeAt(i);
1410
+ if (code < 32 || code === 127 || code >= 128 && code <= 159) {
1411
+ messages.push({
1412
+ id: "PKG-010",
1413
+ severity: "error",
1414
+ message: `Filename contains control character: "${path}"`,
1415
+ location: { path }
1416
+ });
1417
+ break;
1418
+ }
1419
+ }
1420
+ const specialChars = '<>:"|?*';
1421
+ for (const char of specialChars) {
1422
+ if (filename.includes(char)) {
1423
+ messages.push({
1424
+ id: "PKG-011",
1425
+ severity: "error",
1426
+ message: `Filename contains special character: "${path}"`,
1427
+ location: { path }
1428
+ });
1429
+ break;
1430
+ }
1431
+ }
1432
+ }
1433
+ }
1434
+ /**
1435
+ * Validate empty directories
1436
+ */
1437
+ validateEmptyDirectories(zip, messages) {
1438
+ const directories = /* @__PURE__ */ new Set();
1439
+ for (const path of zip.paths) {
1440
+ const parts = path.split("/");
1441
+ for (let i = 1; i < parts.length; i++) {
1442
+ const dir = parts.slice(0, i).join("/") + "/";
1443
+ directories.add(dir);
1444
+ }
1445
+ }
1446
+ for (const dir of directories) {
1447
+ if (dir !== "META-INF/" && dir !== "OEBPS/" && dir !== "OPS/") {
1448
+ const filesInDir = zip.paths.filter((p) => p.startsWith(dir) && p !== dir);
1449
+ if (filesInDir.length === 0) {
1450
+ messages.push({
1451
+ id: "PKG-014",
1452
+ severity: "warning",
1453
+ message: `Empty directory found: ${dir}`,
1454
+ location: { path: dir }
1455
+ });
1456
+ }
1457
+ }
548
1458
  }
549
1459
  }
550
1460
  };
@@ -581,6 +1491,7 @@ function parseOPF(xml) {
581
1491
  const spineResult = parseSpine(spineSection, spineAttrs);
582
1492
  const guideSection = extractSection(xml, "guide");
583
1493
  const guide = parseGuide(guideSection);
1494
+ const collections = parseCollections(xml);
584
1495
  const result = {
585
1496
  version,
586
1497
  uniqueIdentifier,
@@ -589,7 +1500,8 @@ function parseOPF(xml) {
589
1500
  linkElements,
590
1501
  manifest,
591
1502
  spine: spineResult.spine,
592
- guide
1503
+ guide,
1504
+ collections
593
1505
  };
594
1506
  if (Object.keys(prefixes).length > 0) {
595
1507
  result.prefixes = prefixes;
@@ -823,6 +1735,42 @@ function parseAttributes(attrsStr) {
823
1735
  function decodeXmlEntities(str) {
824
1736
  return str.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&apos;/g, "'").replace(/&quot;/g, '"');
825
1737
  }
1738
+ function parseCollections(xml) {
1739
+ const collections = [];
1740
+ const collectionRegex = /<collection[^>]*\srole=["']([^"']+)["'][^>]*>/g;
1741
+ let collectionMatch;
1742
+ while ((collectionMatch = collectionRegex.exec(xml)) !== null) {
1743
+ const role = collectionMatch[1];
1744
+ if (!role) continue;
1745
+ const collectionStart = collectionMatch.index;
1746
+ const collectionStartTag = collectionMatch[0];
1747
+ const idMatch = /id=["']([^"']+)["']/.exec(collectionStartTag);
1748
+ const id = idMatch?.[1];
1749
+ const nameMatch = /name=["']([^"']+)["']/.exec(collectionStartTag);
1750
+ const name = nameMatch?.[1];
1751
+ const links = [];
1752
+ const linkRegex = /<link[^>]*\shref=["']([^"']+)["'][^>]*\/?>/g;
1753
+ const closingTag = `</collection>`;
1754
+ const closingIndex = xml.indexOf(closingTag, collectionStart);
1755
+ const collectionEnd = closingIndex >= 0 ? closingIndex + closingTag.length : xml.length;
1756
+ const collectionXml = xml.slice(collectionStart, collectionEnd);
1757
+ let linkMatch;
1758
+ while ((linkMatch = linkRegex.exec(collectionXml)) !== null) {
1759
+ const href = linkMatch[1];
1760
+ if (href) {
1761
+ links.push(decodeXmlEntities(href));
1762
+ }
1763
+ }
1764
+ const collection = {
1765
+ role,
1766
+ links
1767
+ };
1768
+ if (id) collection.id = id;
1769
+ if (name) collection.name = name;
1770
+ collections.push(collection);
1771
+ }
1772
+ return collections;
1773
+ }
826
1774
 
827
1775
  // src/opf/types.ts
828
1776
  var ITEM_PROPERTIES = /* @__PURE__ */ new Set([
@@ -883,7 +1831,7 @@ var OPFValidator = class {
883
1831
  this.packageDoc = parseOPF(opfXml);
884
1832
  } catch (error) {
885
1833
  context.messages.push({
886
- id: "OPF-001",
1834
+ id: "OPF-002",
887
1835
  severity: "fatal",
888
1836
  message: `Failed to parse package document: ${error instanceof Error ? error.message : "Unknown error"}`,
889
1837
  location: { path: opfPath }
@@ -901,6 +1849,9 @@ var OPFValidator = class {
901
1849
  if (this.packageDoc.version === "2.0") {
902
1850
  this.validateGuide(context, opfPath);
903
1851
  }
1852
+ if (this.packageDoc.version.startsWith("3.")) {
1853
+ this.validateCollections(context, opfPath);
1854
+ }
904
1855
  }
905
1856
  /**
906
1857
  * Build lookup maps for manifest items
@@ -918,6 +1869,15 @@ var OPFValidator = class {
918
1869
  */
919
1870
  validatePackageAttributes(context, opfPath) {
920
1871
  if (!this.packageDoc) return;
1872
+ const validVersions = /* @__PURE__ */ new Set(["2.0", "3.0", "3.1", "3.2", "3.3"]);
1873
+ if (!validVersions.has(this.packageDoc.version)) {
1874
+ context.messages.push({
1875
+ id: "OPF-001",
1876
+ severity: "error",
1877
+ message: `Invalid package version "${this.packageDoc.version}"; must be one of: ${Array.from(validVersions).join(", ")}`,
1878
+ location: { path: opfPath }
1879
+ });
1880
+ }
921
1881
  if (!this.packageDoc.uniqueIdentifier) {
922
1882
  context.messages.push({
923
1883
  id: "OPF-048",
@@ -946,6 +1906,15 @@ var OPFValidator = class {
946
1906
  validateMetadata(context, opfPath) {
947
1907
  if (!this.packageDoc) return;
948
1908
  const dcElements = this.packageDoc.dcElements;
1909
+ if (dcElements.length === 0 && this.packageDoc.metaElements.length === 0) {
1910
+ context.messages.push({
1911
+ id: "OPF-072",
1912
+ severity: "error",
1913
+ message: "Metadata section is empty",
1914
+ location: { path: opfPath }
1915
+ });
1916
+ return;
1917
+ }
949
1918
  const hasIdentifier = dcElements.some((dc) => dc.name === "identifier");
950
1919
  const hasTitle = dcElements.some((dc) => dc.name === "title");
951
1920
  const hasLanguage = dcElements.some((dc) => dc.name === "language");
@@ -977,25 +1946,151 @@ var OPFValidator = class {
977
1946
  if (dc.name === "language" && dc.value) {
978
1947
  if (!isValidLanguageTag(dc.value)) {
979
1948
  context.messages.push({
980
- id: "OPF-092",
1949
+ id: "OPF-092",
1950
+ severity: "error",
1951
+ message: `Invalid language tag: "${dc.value}"`,
1952
+ location: { path: opfPath }
1953
+ });
1954
+ }
1955
+ }
1956
+ if (dc.name === "date" && dc.value) {
1957
+ if (!isValidW3CDateFormat(dc.value)) {
1958
+ context.messages.push({
1959
+ id: "OPF-053",
981
1960
  severity: "error",
982
- message: `Invalid language tag: "${dc.value}"`,
1961
+ message: `Invalid date format "${dc.value}"; must be W3C date format (ISO 8601)`,
983
1962
  location: { path: opfPath }
984
1963
  });
985
1964
  }
986
1965
  }
1966
+ if (dc.name === "creator" && dc.attributes) {
1967
+ const opfRole = dc.attributes["opf:role"];
1968
+ if (opfRole?.startsWith("marc:")) {
1969
+ const relatorCode = opfRole.substring(5);
1970
+ const validRelatorCodes = /* @__PURE__ */ new Set([
1971
+ "arr",
1972
+ "aut",
1973
+ "aut",
1974
+ "ccp",
1975
+ "com",
1976
+ "ctb",
1977
+ "csl",
1978
+ "edt",
1979
+ "ill",
1980
+ "itr",
1981
+ "pbl",
1982
+ "pdr",
1983
+ "prt",
1984
+ "trl",
1985
+ "cre",
1986
+ "art",
1987
+ "ctb",
1988
+ "edt",
1989
+ "pfr",
1990
+ "red",
1991
+ "rev",
1992
+ "spn",
1993
+ "dsx",
1994
+ "pmc",
1995
+ "dte",
1996
+ "ove",
1997
+ "trc",
1998
+ "ldr",
1999
+ "led",
2000
+ "prg",
2001
+ "rap",
2002
+ "rce",
2003
+ "rpc",
2004
+ "rtr",
2005
+ "sad",
2006
+ "sgn",
2007
+ "tce",
2008
+ "aac",
2009
+ "acq",
2010
+ "ant",
2011
+ "arr",
2012
+ "art",
2013
+ "ard",
2014
+ "asg",
2015
+ "aus",
2016
+ "aft",
2017
+ "bdd",
2018
+ "bdd",
2019
+ "clb",
2020
+ "clc",
2021
+ "drd",
2022
+ "edt",
2023
+ "edt",
2024
+ "fmd",
2025
+ "flm",
2026
+ "fmo",
2027
+ "fpy",
2028
+ "hnr",
2029
+ "ill",
2030
+ "ilt",
2031
+ "img",
2032
+ "itr",
2033
+ "lrg",
2034
+ "lsa",
2035
+ "led",
2036
+ "lee",
2037
+ "lel",
2038
+ "lgd",
2039
+ "lse",
2040
+ "mfr",
2041
+ "mod",
2042
+ "mon",
2043
+ "mus",
2044
+ "nrt",
2045
+ "ogt",
2046
+ "org",
2047
+ "oth",
2048
+ "pnt",
2049
+ "ppa",
2050
+ "prv",
2051
+ "pup",
2052
+ "red",
2053
+ "rev",
2054
+ "rsg",
2055
+ "srv",
2056
+ "stn",
2057
+ "stl",
2058
+ "trc",
2059
+ "typ",
2060
+ "vdg",
2061
+ "voc",
2062
+ "wac",
2063
+ "wdc"
2064
+ ]);
2065
+ if (!validRelatorCodes.has(relatorCode)) {
2066
+ context.messages.push({
2067
+ id: "OPF-052",
2068
+ severity: "error",
2069
+ message: `Unknown MARC relator code "${relatorCode}" in dc:creator`,
2070
+ location: { path: opfPath }
2071
+ });
2072
+ }
2073
+ }
2074
+ }
987
2075
  }
988
2076
  if (this.packageDoc.version !== "2.0") {
989
- const hasModified = this.packageDoc.metaElements.some(
2077
+ const modifiedMeta = this.packageDoc.metaElements.find(
990
2078
  (meta) => meta.property === "dcterms:modified"
991
2079
  );
992
- if (!hasModified) {
2080
+ if (!modifiedMeta) {
993
2081
  context.messages.push({
994
2082
  id: "OPF-054",
995
2083
  severity: "error",
996
2084
  message: "EPUB 3 metadata must include a dcterms:modified meta element",
997
2085
  location: { path: opfPath }
998
2086
  });
2087
+ } else if (modifiedMeta.value && !isValidW3CDateFormat(modifiedMeta.value)) {
2088
+ context.messages.push({
2089
+ id: "OPF-054",
2090
+ severity: "error",
2091
+ message: `Invalid dcterms:modified date format "${modifiedMeta.value}"; must be W3C date format (ISO 8601)`,
2092
+ location: { path: opfPath }
2093
+ });
999
2094
  }
1000
2095
  }
1001
2096
  }
@@ -1034,6 +2129,29 @@ var OPFValidator = class {
1034
2129
  location: { path: opfPath }
1035
2130
  });
1036
2131
  }
2132
+ if (!isValidMimeType(item.mediaType)) {
2133
+ context.messages.push({
2134
+ id: "OPF-014",
2135
+ severity: "error",
2136
+ message: `Invalid media-type format "${item.mediaType}" for item "${item.id}"`,
2137
+ location: { path: opfPath }
2138
+ });
2139
+ }
2140
+ const deprecatedTypes = /* @__PURE__ */ new Map([
2141
+ ["text/x-oeb1-document", "OPF-035"],
2142
+ ["text/x-oeb1-css", "OPF-037"],
2143
+ ["application/x-oeb1-package", "OPF-038"],
2144
+ ["text/x-oeb1-html", "OPF-037"]
2145
+ ]);
2146
+ const deprecatedId = deprecatedTypes.get(item.mediaType);
2147
+ if (deprecatedId) {
2148
+ context.messages.push({
2149
+ id: deprecatedId,
2150
+ severity: "warning",
2151
+ message: `Deprecated OEB 1.0 media-type "${item.mediaType}" should not be used`,
2152
+ location: { path: opfPath }
2153
+ });
2154
+ }
1037
2155
  if (this.packageDoc.version !== "2.0" && item.properties) {
1038
2156
  for (const prop of item.properties) {
1039
2157
  if (!ITEM_PROPERTIES.has(prop) && !prop.includes(":")) {
@@ -1064,6 +2182,16 @@ var OPFValidator = class {
1064
2182
  location: { path: opfPath }
1065
2183
  });
1066
2184
  }
2185
+ if (this.packageDoc.version !== "2.0" && (item.href.startsWith("http://") || item.href.startsWith("https://"))) {
2186
+ if (!item.properties?.includes("remote-resources")) {
2187
+ context.messages.push({
2188
+ id: "RSC-006",
2189
+ severity: "error",
2190
+ message: `Manifest item "${item.id}" references remote resource but is missing "remote-resources" property`,
2191
+ location: { path: opfPath }
2192
+ });
2193
+ }
2194
+ }
1067
2195
  }
1068
2196
  if (this.packageDoc.version !== "2.0") {
1069
2197
  const hasNav = this.packageDoc.manifest.some((item) => item.properties?.includes("nav"));
@@ -1076,6 +2204,30 @@ var OPFValidator = class {
1076
2204
  });
1077
2205
  }
1078
2206
  }
2207
+ this.checkUndeclaredResources(context, opfPath);
2208
+ }
2209
+ /**
2210
+ * Check for files in container that are not declared in manifest
2211
+ */
2212
+ checkUndeclaredResources(context, opfPath) {
2213
+ if (!this.packageDoc) return;
2214
+ const declaredPaths = /* @__PURE__ */ new Set();
2215
+ for (const item of this.packageDoc.manifest) {
2216
+ const fullPath = resolvePath(opfPath, item.href);
2217
+ declaredPaths.add(fullPath);
2218
+ }
2219
+ declaredPaths.add(opfPath);
2220
+ for (const filePath of context.files.keys()) {
2221
+ if (filePath.startsWith("META-INF/")) continue;
2222
+ if (filePath === "mimetype") continue;
2223
+ if (declaredPaths.has(filePath)) continue;
2224
+ context.messages.push({
2225
+ id: "RSC-008",
2226
+ severity: "warning",
2227
+ message: `File in container is not declared in manifest: ${filePath}`,
2228
+ location: { path: filePath }
2229
+ });
2230
+ }
1079
2231
  }
1080
2232
  /**
1081
2233
  * Validate spine section
@@ -1228,6 +2380,89 @@ var OPFValidator = class {
1228
2380
  }
1229
2381
  }
1230
2382
  }
2383
+ validateCollections(context, opfPath) {
2384
+ if (!this.packageDoc) return;
2385
+ const collections = this.packageDoc.collections;
2386
+ if (collections.length === 0) {
2387
+ return;
2388
+ }
2389
+ const validRoles = /* @__PURE__ */ new Set(["dictionary", "index", "preview", "recordings"]);
2390
+ for (const collection of collections) {
2391
+ if (!validRoles.has(collection.role)) {
2392
+ context.messages.push({
2393
+ id: "OPF-071",
2394
+ severity: "warning",
2395
+ message: `Unknown collection role: "${collection.role}"`,
2396
+ location: { path: opfPath }
2397
+ });
2398
+ }
2399
+ if (collection.role === "dictionary") {
2400
+ if (!collection.name || collection.name.trim() === "") {
2401
+ context.messages.push({
2402
+ id: "OPF-072",
2403
+ severity: "error",
2404
+ message: "Dictionary collection must have a name attribute",
2405
+ location: { path: opfPath }
2406
+ });
2407
+ }
2408
+ for (const linkHref of collection.links) {
2409
+ const manifestItem = this.manifestByHref.get(linkHref);
2410
+ if (!manifestItem) {
2411
+ context.messages.push({
2412
+ id: "OPF-073",
2413
+ severity: "error",
2414
+ message: `Collection link "${linkHref}" references non-existent manifest item`,
2415
+ location: { path: opfPath }
2416
+ });
2417
+ continue;
2418
+ }
2419
+ if (manifestItem.mediaType !== "application/xhtml+xml" && manifestItem.mediaType !== "image/svg+xml") {
2420
+ context.messages.push({
2421
+ id: "OPF-074",
2422
+ severity: "error",
2423
+ message: `Dictionary collection item "${linkHref}" must be an XHTML or SVG document`,
2424
+ location: { path: opfPath }
2425
+ });
2426
+ }
2427
+ }
2428
+ }
2429
+ if (collection.role === "index") {
2430
+ for (const linkHref of collection.links) {
2431
+ const manifestItem = this.manifestByHref.get(linkHref);
2432
+ if (!manifestItem) {
2433
+ context.messages.push({
2434
+ id: "OPF-073",
2435
+ severity: "error",
2436
+ message: `Collection link "${linkHref}" references non-existent manifest item`,
2437
+ location: { path: opfPath }
2438
+ });
2439
+ continue;
2440
+ }
2441
+ if (manifestItem.mediaType !== "application/xhtml+xml") {
2442
+ context.messages.push({
2443
+ id: "OPF-075",
2444
+ severity: "error",
2445
+ message: `Index collection item "${linkHref}" must be an XHTML document`,
2446
+ location: { path: opfPath }
2447
+ });
2448
+ }
2449
+ }
2450
+ }
2451
+ if (collection.role === "preview") {
2452
+ for (const linkHref of collection.links) {
2453
+ const manifestItem = this.manifestByHref.get(linkHref);
2454
+ if (!manifestItem) {
2455
+ context.messages.push({
2456
+ id: "OPF-073",
2457
+ severity: "error",
2458
+ message: `Collection link "${linkHref}" references non-existent manifest item`,
2459
+ location: { path: opfPath }
2460
+ });
2461
+ }
2462
+ }
2463
+ }
2464
+ }
2465
+ }
1231
2466
  };
1232
2467
  function isSpineMediaType(mediaType) {
1233
2468
  return mediaType === "application/xhtml+xml" || mediaType === "image/svg+xml" || // EPUB 2 also allows these in spine
@@ -1256,6 +2491,442 @@ function resolvePath(basePath, relativePath) {
1256
2491
  }
1257
2492
  return parts.join("/");
1258
2493
  }
2494
+ function isValidMimeType(mediaType) {
2495
+ const mimeTypePattern = /^[a-zA-Z][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_+]*(?:\s*;\s*[a-zA-Z0-9-]+=[^;]+)?$/;
2496
+ if (!mimeTypePattern.test(mediaType)) {
2497
+ return false;
2498
+ }
2499
+ const [type, subtypeWithParams] = mediaType.split("/");
2500
+ if (!type || !subtypeWithParams) return false;
2501
+ const subtype = subtypeWithParams.split(";")[0]?.trim();
2502
+ if (!subtype) return false;
2503
+ if (type.length > 127 || subtype.length > 127) {
2504
+ return false;
2505
+ }
2506
+ return true;
2507
+ }
2508
+ function isValidW3CDateFormat(dateStr) {
2509
+ const trimmed = dateStr.trim();
2510
+ const yearOnlyPattern = /^\d{4}$/;
2511
+ if (yearOnlyPattern.test(trimmed)) {
2512
+ const year = Number(trimmed);
2513
+ return year >= 0 && year <= 9999;
2514
+ }
2515
+ const yearMonthPattern = /^(\d{4})-(\d{2})$/;
2516
+ const yearMonthMatch = yearMonthPattern.exec(trimmed);
2517
+ if (yearMonthMatch) {
2518
+ const year = Number(yearMonthMatch[1]);
2519
+ const month = Number(yearMonthMatch[2]);
2520
+ if (year < 0 || year > 9999) return false;
2521
+ if (month < 1 || month > 12) return false;
2522
+ return true;
2523
+ }
2524
+ const dateOnlyPattern = /^(\d{4})-(\d{2})-(\d{2})$/;
2525
+ const dateMatch = dateOnlyPattern.exec(trimmed);
2526
+ if (dateMatch) {
2527
+ const year = Number(dateMatch[1]);
2528
+ const month = Number(dateMatch[2]);
2529
+ const day = Number(dateMatch[3]);
2530
+ if (year < 0 || year > 9999) return false;
2531
+ if (month < 1 || month > 12) return false;
2532
+ const daysInMonth = new Date(year, month, 0).getDate();
2533
+ if (day < 1 || day > daysInMonth) return false;
2534
+ return true;
2535
+ }
2536
+ const dateTimePattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/;
2537
+ const dateTimeMatch = dateTimePattern.exec(trimmed);
2538
+ if (dateTimeMatch) {
2539
+ const year = Number(dateTimeMatch[1]);
2540
+ const month = Number(dateTimeMatch[2]);
2541
+ const day = Number(dateTimeMatch[3]);
2542
+ if (year < 0 || year > 9999) return false;
2543
+ if (month < 1 || month > 12) return false;
2544
+ const daysInMonth = new Date(year, month, 0).getDate();
2545
+ if (day < 1 || day > daysInMonth) return false;
2546
+ const hours = Number(dateTimeMatch[4]);
2547
+ const minutes = Number(dateTimeMatch[5]);
2548
+ const seconds = Number(dateTimeMatch[6]);
2549
+ if (hours < 0 || hours > 23) return false;
2550
+ if (minutes < 0 || minutes > 59) return false;
2551
+ if (seconds < 0 || seconds > 59) return false;
2552
+ return true;
2553
+ }
2554
+ return false;
2555
+ }
2556
+
2557
+ // src/references/registry.ts
2558
+ var ResourceRegistry = class {
2559
+ resources;
2560
+ ids;
2561
+ constructor() {
2562
+ this.resources = /* @__PURE__ */ new Map();
2563
+ this.ids = /* @__PURE__ */ new Map();
2564
+ }
2565
+ /**
2566
+ * Register a resource from manifest
2567
+ */
2568
+ registerResource(resource) {
2569
+ this.resources.set(resource.url, resource);
2570
+ for (const id of resource.ids) {
2571
+ this.registerID(resource.url, id);
2572
+ }
2573
+ }
2574
+ /**
2575
+ * Register an ID in a resource
2576
+ */
2577
+ registerID(resourceURL, id) {
2578
+ if (!id || !this.resources.has(resourceURL)) {
2579
+ return;
2580
+ }
2581
+ if (!this.ids.has(resourceURL)) {
2582
+ this.ids.set(resourceURL, /* @__PURE__ */ new Set());
2583
+ }
2584
+ const resourceIDs = this.ids.get(resourceURL);
2585
+ resourceIDs?.add(id);
2586
+ const resource = this.resources.get(resourceURL);
2587
+ if (resource) {
2588
+ resource.ids.add(id);
2589
+ }
2590
+ }
2591
+ /**
2592
+ * Get a resource by URL
2593
+ */
2594
+ getResource(url) {
2595
+ return this.resources.get(url);
2596
+ }
2597
+ /**
2598
+ * Check if a resource exists
2599
+ */
2600
+ hasResource(url) {
2601
+ return this.resources.has(url);
2602
+ }
2603
+ /**
2604
+ * Check if an ID exists in a resource
2605
+ */
2606
+ hasID(resourceURL, id) {
2607
+ const resourceIDs = this.ids.get(resourceURL);
2608
+ return resourceIDs?.has(id) ?? false;
2609
+ }
2610
+ /**
2611
+ * Get all resources
2612
+ */
2613
+ getAllResources() {
2614
+ return Array.from(this.resources.values());
2615
+ }
2616
+ /**
2617
+ * Get all resource URLs
2618
+ */
2619
+ getResourceURLs() {
2620
+ return Array.from(this.resources.keys());
2621
+ }
2622
+ /**
2623
+ * Get resources in spine
2624
+ */
2625
+ getSpineResources() {
2626
+ return Array.from(this.resources.values()).filter((r) => r.inSpine);
2627
+ }
2628
+ /**
2629
+ * Check if a resource is in spine
2630
+ */
2631
+ isInSpine(url) {
2632
+ const resource = this.resources.get(url);
2633
+ return resource?.inSpine ?? false;
2634
+ }
2635
+ };
2636
+
2637
+ // src/references/url.ts
2638
+ function parseURL(urlString) {
2639
+ const hashIndex = urlString.indexOf("#");
2640
+ if (hashIndex === -1) {
2641
+ return {
2642
+ url: urlString,
2643
+ resource: urlString,
2644
+ hasFragment: false
2645
+ };
2646
+ }
2647
+ const resource = urlString.substring(0, hashIndex);
2648
+ const fragment = urlString.substring(hashIndex + 1);
2649
+ const result = {
2650
+ url: urlString,
2651
+ resource,
2652
+ hasFragment: true
2653
+ };
2654
+ if (fragment) {
2655
+ result.fragment = fragment;
2656
+ }
2657
+ return result;
2658
+ }
2659
+ function isDataURL(url) {
2660
+ return url.startsWith("data:");
2661
+ }
2662
+ function isFileURL(url) {
2663
+ return url.startsWith("file:");
2664
+ }
2665
+ function hasAbsolutePath(url) {
2666
+ return url.startsWith("/");
2667
+ }
2668
+ function hasParentDirectoryReference(url) {
2669
+ return url.includes("..");
2670
+ }
2671
+ function isMalformedURL(url) {
2672
+ if (!url) return true;
2673
+ try {
2674
+ if (/[\s<>]/.test(url)) return true;
2675
+ return false;
2676
+ } catch {
2677
+ return true;
2678
+ }
2679
+ }
2680
+ function isHTTPS(url) {
2681
+ return url.startsWith("https://");
2682
+ }
2683
+ function isHTTP(url) {
2684
+ return url.startsWith("http://");
2685
+ }
2686
+ function isRemoteURL(url) {
2687
+ return isHTTP(url) || isHTTPS(url);
2688
+ }
2689
+
2690
+ // src/references/validator.ts
2691
+ var ReferenceValidator = class {
2692
+ registry;
2693
+ version;
2694
+ references = [];
2695
+ constructor(registry, version) {
2696
+ this.registry = registry;
2697
+ this.version = version;
2698
+ }
2699
+ /**
2700
+ * Register a reference for validation
2701
+ */
2702
+ addReference(reference) {
2703
+ this.references.push(reference);
2704
+ }
2705
+ /**
2706
+ * Validate all registered references
2707
+ */
2708
+ validate(context) {
2709
+ for (const reference of this.references) {
2710
+ this.validateReference(context, reference);
2711
+ }
2712
+ this.checkUndeclaredResources(context);
2713
+ }
2714
+ /**
2715
+ * Validate a single reference
2716
+ */
2717
+ validateReference(context, reference) {
2718
+ const url = reference.url;
2719
+ if (isMalformedURL(url)) {
2720
+ context.messages.push({
2721
+ id: "RSC-020",
2722
+ severity: "error",
2723
+ message: `Malformed URL: ${url}`,
2724
+ location: reference.location
2725
+ });
2726
+ return;
2727
+ }
2728
+ if (isDataURL(url)) {
2729
+ if (this.version.startsWith("3.")) {
2730
+ context.messages.push({
2731
+ id: "RSC-029",
2732
+ severity: "error",
2733
+ message: "Data URLs are not allowed in EPUB 3",
2734
+ location: reference.location
2735
+ });
2736
+ }
2737
+ return;
2738
+ }
2739
+ if (isFileURL(url)) {
2740
+ context.messages.push({
2741
+ id: "RSC-026",
2742
+ severity: "error",
2743
+ message: "File URLs are not allowed",
2744
+ location: reference.location
2745
+ });
2746
+ return;
2747
+ }
2748
+ const resourcePath = reference.targetResource || parseURL(url).resource;
2749
+ const fragment = reference.fragment ?? parseURL(url).fragment;
2750
+ const hasFragment = fragment !== void 0 && fragment !== "";
2751
+ if (!isRemoteURL(url)) {
2752
+ this.validateLocalReference(context, reference, resourcePath);
2753
+ } else {
2754
+ this.validateRemoteReference(context, reference);
2755
+ }
2756
+ if (hasFragment) {
2757
+ this.validateFragment(context, reference, resourcePath, fragment);
2758
+ }
2759
+ }
2760
+ /**
2761
+ * Validate a local (non-remote) reference
2762
+ */
2763
+ validateLocalReference(context, reference, resourcePath) {
2764
+ if (hasAbsolutePath(resourcePath)) {
2765
+ context.messages.push({
2766
+ id: "RSC-027",
2767
+ severity: "error",
2768
+ message: "Absolute paths are not allowed in EPUB",
2769
+ location: reference.location
2770
+ });
2771
+ }
2772
+ const forbiddenParentDirTypes = [
2773
+ "hyperlink" /* HYPERLINK */,
2774
+ "nav-toc-link" /* NAV_TOC_LINK */,
2775
+ "nav-pagelist-link" /* NAV_PAGELIST_LINK */
2776
+ ];
2777
+ if (hasParentDirectoryReference(reference.url) && forbiddenParentDirTypes.includes(reference.type)) {
2778
+ context.messages.push({
2779
+ id: "RSC-028",
2780
+ severity: "error",
2781
+ message: "Parent directory references (..) are not allowed",
2782
+ location: reference.location
2783
+ });
2784
+ }
2785
+ if (!this.registry.hasResource(resourcePath)) {
2786
+ const isLinkRef = reference.type === "link" /* LINK */;
2787
+ context.messages.push({
2788
+ id: isLinkRef ? "RSC-007w" : "RSC-007",
2789
+ severity: isLinkRef ? "warning" : "error",
2790
+ message: `Referenced resource not found in manifest: ${resourcePath}`,
2791
+ location: reference.location
2792
+ });
2793
+ return;
2794
+ }
2795
+ const resource = this.registry.getResource(resourcePath);
2796
+ if (reference.type === "hyperlink" /* HYPERLINK */ && !resource?.inSpine) {
2797
+ context.messages.push({
2798
+ id: "RSC-011",
2799
+ severity: "error",
2800
+ message: "Hyperlinks must reference spine items",
2801
+ location: reference.location
2802
+ });
2803
+ }
2804
+ if (reference.type === "hyperlink" /* HYPERLINK */ || reference.type === "overlay-text-link" /* OVERLAY_TEXT_LINK */) {
2805
+ const targetMimeType = resource?.mimeType;
2806
+ if (targetMimeType && !this.isBlessedItemType(targetMimeType, context.version) && !this.isDeprecatedBlessedItemType(targetMimeType) && !resource.hasCoreMediaTypeFallback) {
2807
+ context.messages.push({
2808
+ id: "RSC-010",
2809
+ severity: "error",
2810
+ message: "Publication resource references must point to content documents",
2811
+ location: reference.location
2812
+ });
2813
+ }
2814
+ }
2815
+ }
2816
+ /**
2817
+ * Validate a remote reference
2818
+ */
2819
+ validateRemoteReference(context, reference) {
2820
+ const url = reference.url;
2821
+ if (isHTTP(url) && !isHTTPS(url)) {
2822
+ context.messages.push({
2823
+ id: "RSC-031",
2824
+ severity: "error",
2825
+ message: "Remote resources must use HTTPS",
2826
+ location: reference.location
2827
+ });
2828
+ }
2829
+ if (isPublicationResourceReference(reference.type)) {
2830
+ const allowedRemoteTypes = /* @__PURE__ */ new Set([
2831
+ "audio" /* AUDIO */,
2832
+ "video" /* VIDEO */,
2833
+ "font" /* FONT */
2834
+ ]);
2835
+ if (!allowedRemoteTypes.has(reference.type)) {
2836
+ context.messages.push({
2837
+ id: "RSC-006",
2838
+ severity: "error",
2839
+ message: "Remote resources are only allowed for audio, video, and fonts",
2840
+ location: reference.location
2841
+ });
2842
+ }
2843
+ }
2844
+ }
2845
+ /**
2846
+ * Validate a fragment identifier
2847
+ */
2848
+ validateFragment(context, reference, resourcePath, fragment) {
2849
+ if (!fragment || !this.registry.hasResource(resourcePath)) {
2850
+ return;
2851
+ }
2852
+ const resource = this.registry.getResource(resourcePath);
2853
+ if (reference.type === "stylesheet" /* STYLESHEET */) {
2854
+ context.messages.push({
2855
+ id: "RSC-013",
2856
+ severity: "error",
2857
+ message: "Stylesheet references must not have fragment identifiers",
2858
+ location: reference.location
2859
+ });
2860
+ return;
2861
+ }
2862
+ if (resource?.mimeType === "image/svg+xml") {
2863
+ const hasSVGView = fragment.includes("svgView(") || fragment.includes("viewBox(");
2864
+ if (hasSVGView && reference.type === "hyperlink" /* HYPERLINK */) {
2865
+ context.messages.push({
2866
+ id: "RSC-014",
2867
+ severity: "error",
2868
+ message: "SVG view fragments can only be referenced from SVG documents",
2869
+ location: reference.location
2870
+ });
2871
+ }
2872
+ }
2873
+ if (!this.registry.hasID(resourcePath, fragment)) {
2874
+ context.messages.push({
2875
+ id: "RSC-012",
2876
+ severity: "error",
2877
+ message: `Fragment identifier not found: #${fragment}`,
2878
+ location: reference.location
2879
+ });
2880
+ }
2881
+ }
2882
+ /**
2883
+ * Check for resources that were never referenced
2884
+ * Following Java EPUBCheck OPFChecker30.java logic:
2885
+ * - Skip items in spine (implicitly referenced)
2886
+ * - Skip nav and NCX resources
2887
+ * - Only check for "publication resource references" (not HYPERLINK)
2888
+ */
2889
+ checkUndeclaredResources(context) {
2890
+ const referencedResources = new Set(
2891
+ this.references.filter((ref) => {
2892
+ if (!isPublicationResourceReference(ref.type)) {
2893
+ return false;
2894
+ }
2895
+ const targetResource = ref.targetResource || parseURL(ref.url).resource;
2896
+ return this.registry.hasResource(targetResource);
2897
+ }).map((ref) => ref.targetResource || parseURL(ref.url).resource)
2898
+ );
2899
+ for (const resource of this.registry.getAllResources()) {
2900
+ if (resource.inSpine) continue;
2901
+ if (referencedResources.has(resource.url)) continue;
2902
+ if (resource.url.includes("nav")) continue;
2903
+ if (resource.url.includes("toc.ncx") || resource.url.includes(".ncx")) continue;
2904
+ if (resource.url.includes("cover-image")) continue;
2905
+ context.messages.push({
2906
+ id: "OPF-097",
2907
+ severity: "usage",
2908
+ message: `Resource declared in manifest but not referenced: ${resource.url}`,
2909
+ location: { path: resource.url }
2910
+ });
2911
+ }
2912
+ }
2913
+ /**
2914
+ * Check if a MIME type is a blessed content document type
2915
+ */
2916
+ isBlessedItemType(mimeType, version) {
2917
+ if (version === "2.0") {
2918
+ return mimeType === "application/xhtml+xml" || mimeType === "application/x-dtbook+xml";
2919
+ } else {
2920
+ return mimeType === "application/xhtml+xml" || mimeType === "image/svg+xml";
2921
+ }
2922
+ }
2923
+ /**
2924
+ * Check if a MIME type is a deprecated blessed content document type
2925
+ */
2926
+ isDeprecatedBlessedItemType(mimeType) {
2927
+ return mimeType === "text/x-oeb1-document" || mimeType === "text/html";
2928
+ }
2929
+ };
1259
2930
  var COMPRESSED_SCHEMAS = {
1260
2931
  "applications.rng": "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",
1261
2932
  "aria.rng": "H4sIAHZNcGkCA+1dX4/kNnJ/tj9F3xgIkoeZziE4IFjs2lggl4sB5y6wnSBvgbpb062sWmpL6p3dw334K/4VqRYpFklR6t3xg3e3Waxi1e9HiiKp4tsfPp3Lzce8aYu6evfw+6d/ftjk1b4+FNXx3cN///rvj//68MP33749Ntn5nDUbkK7aN9m7h1PXXd5st01eZp+q41PdHLdVu93X50vWFbuiLLrP26yq6g7+WUMJ1Uxrm+q2XXPdd9cmZ7KHDKp+vuQ/Fbsmaz6/e3j4/tvN5u3vHh8338X4b/P4SBUe8ueiyjdVds7fPWRNkT0dy3qXldQclGdvDvX+es4r5sn33/z8x5/e/+/mz3/a/LI/5eds81w3m//49T9/2vzhzeb9fp+3bbEr883Pxf60+bHq8qbKu837y6Us9iwWm+/ebodamS1wjv5lE8dFrov/cSrarm4+v9nw8J/a4mNd5dVT8aF4ei62xPfHU3cu//C4K9otrcViNEPYRwL7/ucf35sCM/z1TxSiTQv/zNtNVh02l6a+5E1X5K1JR0GwKPPsY85+gJ/qCynPSvED/NTkzyoXiNqnrKvPxf5hK+tthxUdNNG2Pu2u7edARbRJ+7rqmrpsozRqf20aCFaMdh3ydt8Uu/ywi+LmIe+yIpKXh6LNoGMeYrQrb5q6OUNXz455DH3PZf3S1TE0nbL2Ul+ulyghOxWHQ15FUVVUH7OyiBL8D/nn9lQ33f7ahTKD6iuzXV5GUwQMi8P9svgYhVv1SxUlTPC4zj9mcYYJGLpyNlRQ+Xgjz4S6t1v9KfB2yx7/tzMBmMmc6wqG/q5pn+rulDcPG/htBxLvHnolYpZw0+pBE/mcgjdMb5ZjI6ie4kzmEUUHD5KuA0/7Nu1PdbGX7Rk+An/pn5T/JZ+UdOryZxD4CNMVQCTuo5P1+/zTBYwGj7pM2aWBIdemyxNfPbR76MTGwPrPIvbwuIZYxOk+LxFVFYDPp7ljesr3H3b1J2NcpzoQn6kQNT0BIvQj0pza1rDluc/moNeuJm9XZd5FeSo0+W/XoonUtj0ZQsgAnFeHWE+IPDvUVWl5kkbhJZUr6EMjjJlDrCNQ81BkZX1cYbv4E2J9LXsujrB+sMJ2wcvC+lp1bGp4T1j7uOc8tkQZD04w7MDS19qjAjNx2ytLlFAU5xWOPWVRfVhjq9ou+hyCAn2+ll3RwpvXviOLF1/4k9+1o0ThNwGt6HLzwOyP2kT3dFd0qduiavMoMypQ0xZ/DaVQUojOWXdaX29nCmagzVrRHrx1BeliY9nszIEYHMliwS5T121iAQVLmVfY+rG8NGN1FVFe5qmuqn6JpqvLP80942pgwlW/dqY1dyZYI1rLQ5LsQ8Km8qaoYEUWnt4HuZ8YEqUYtu5+PgQoz/BGePdhafOs2Z/mmd3PsXAWe6GQvoHAa1ekd4+pVb2Y7zGR8L9kDZwAeJ1HzDaPQOxlNoXYmpobdtis1vb8XjH/8jG/FNXEvuor7slwTzS+s3W1WbZ79/U11nGJlwldcUKRr3GbgvDpdfr1tU6/urouoy/i3P02FQwu13P1WDePMDQ8kk2rWeYqLZyyuz/eLXIkB1RNnKNZ6fGeqMtN6V7fxVF565sbae7mb++o4Dff/OM3t+dRf2Bl/8CKNIKNFLGD4HpBf657pML+BN9A5OVYCT/APVJEDn6Otoudv+RF/wR/8E8YRCxacBfix8P5N/Hn/22a/Nj/rhUKiQwOBO7L3CZSAlzwCcgH7WdRuCN+NqNF7GlIjxQ2nw0SgHPVFdVzPVp+zgq98aKgyj4WR/pmYG04bPTZygmC17YPp/vKAA33+mZMDG5ju/pmODhHW+XVCk6psGZwV/xbIVi7dDOAgws3gdF84UawcSKsERJS/2ZoY9IKWiPHv8XbQsbaxRvRj+uLN4Ut/8/WjASfMyofnlk/DnD99o5oMsqC9wUsoakNeFRMgwiLnzI7pItGG/Kp57sH+AaUnL77Hj4Fzd9uacmE5HNWtkPRt1vdCI0yBCaDd+n++8OtbKydHCMfyhkiw4tRwdFVusbnAt+ducWn7fKLm2RZs+9T3aTh81zHFnTF2VVypajzHXsT6qwYh7qmMk2vMIiei0+k8S6iV5inkhANxWeLvPxk0xB6UY6K/UDpnQ9J8vXBECJRjgrRQOmi/ExOOv7RqyGerBQVTU3hndNNfMZriA4vRoVHV5mMa2MRMerg6S8cH7kX+IAQ/mbFxCf44vNHQ/B5MSr4usrXB5Eh8nLl0xB6UY6K/UDplznKJnjBGd+AMSDVf/3s+r4z1IzC2NCssTdHkvDmqTjA78qSOAnfz3/8nx9/+fHXTXY48JwzsHJcbegc0pPValoTk9u0FOesqvBuH3S327imCCkyuDjdKneNVlGRvWDHFzpYc3ST3EGCAzfJCjIGJYJAHqAwvXfxctyLl650LOyGjglLdMe8eYIAPFY5WZxSs3MYovX4+4hzAH2b0RwUWo4Niqp0MgbkaL3qv58fdIvT7AYpxnqhqEzjBE/CZPSClSPd0JTanxRqYpc5HxVqWifTu3gvgnsdv1G9JpfZp15Wl6mIh8uqatIM8FAfPvyJKdJmGdtNi5FtVlW6zV88Wq4l1jItaSgyuGWNW+WzOcIzehlcYKWoxmsK19FFZK4x01oJL8etluhKw6aRqvfMXZINawNpj/op+fTbf4wVBPD96j5tg/OOrkvmueNs8NiQdRmnpX2adyT2zEXL2mYgjCqDIs2I8shDKksSZ2g3LUQ1WFU3R0tZFjpbc6kEvs2q4nWMQuw7QpOrpBDnpaIuxSSSpvkztR7KcI3vlbkOnPXzc+DQdoHXB9fNwAxWG2H1hbgVd3g51wdzzl5aiAqkqu6+VzL6A+Sm4AgBXIAGar+AIClpVmyh6sXwAbsxcd9hUz/NMoRMEUGF61a1a6ggmTkcPCT93SVcMGso/gqv3GPyqFHQbQXdJ8gkf6spulCGC2uvbB1P70uZ7fNTXZIPGEz7Wr0Ibm/rRnXkmZZMMWFqOC/HtVpXmmIKIj/PMPghylF+DJTe40B3e6RPD9f0oUDTJzBuqPB8y0ZUWDkSFU2pEZXvs3JkQCSvpv0/TVO8w4Em1ISBaVB/9OsQo54mP9fw+6iam89OsMrJR4WOit9udbfTRgHR0FnD6xcFmxFEW63BXD8XIkVhNipgw+sXBVPzV8ODJD0iQhRmHRjDuBA82ZVfdxofeqwc+dDTlN73O9fw6gRToHQxXLxGTUSePMucBkYHWDmy5ZrSu9vel5/rmoOC394fKE3yRsG/YDa7gd7e11WmcEJk/TOe+aPFyCN/qsq7oyfNB2AKB5ThYtErcx2RM7pnd3u01rSRxfb4nOVHDjdhV4HoTTGxwy5TBxlCL8pR4R8oNVIP9p0zNQmEuhgU8BiTKYysPhW4MWKgdBGfSColm09QjvepV7qITzSlk80pNqvFeqWo9Z1cJDhenJWwoGzy3npt0s2BUaIJccfSYB2JTMt4a3qYl8iWOWyNAWgi0cM7Op6zgCijpf/anOtqnBJAzMqf4m5MQ70kv+3FRh1xMCOcQNzYHDRCpLaie6zx+TiIpB8rRw7BJOKmio0fQycuDvI3yuT7q2yNbO1FQtmqGFvNoDcSADzJ1BClJdkwpHiSKRriG2XyPHmMiWCsOJRc3MhqiDVwGk8qEZa0hFLDiCGTyIuBSBWEMUhlecIZUy4LWhpII25iLSwaeIwmkYhJUg6pMfSikHNiIYQ5JsoSAhuToZDSUAIxE4vNxjwvV7VckeDEUz2weJ7y0KflqQIV+rk5kVvayxQVpTfWmj5CgrJAflL1GHa+JtcMGL81MNG9gsGdtE/09ED3COtVyx5mmKC4a9iWsId+YRHWK4SZHn23O4tHR1GxR/k7xAh64yieLTIUaRmjhQ7PmqnLpN3NkdCzZMosibJ5GVCVsmRWHmWKWhU9jvokYfa4usg3R/N6b2SKv2K10tzT63+kjfQdj8FK0ZJ6wLrpwVPPIHGtvPHTcFZuegYRpo9ki978+pd/+8sb5WFBjmOzM9hdvXnPPq6Bwzv9jo/zEMXbix6eUJcouPZQQ98fPiTv9L78mOnwI9+OM8KIgP4qOJ64r6pUxk8uRPXI5oSwknfYPDb0QqGTVM3gWpaoxsPgQzA1UKlZNgys17oVNhM13jiv0aeYtmQr4SLBpOuNrYdytwHwIJwSosR0G4TUk2yYRONYw1Se/kJPEJuzjwiJQJopptbCshHv0SRT45OUY8N4oh+dioLoJpm49YxElOMRX+rJiNBDEYuchwg6CoE7BYE4AHGA94A9XAhrToUlBIK5KAytZny7cd2DSDI4ibmkBdODTqJ+bINMmtPEyCn+r1BKCTOrYdTQbzyhZGTS8kmLpNd0TKqIbJIKP+fmr6hIWSCRqPq1kEjzFU0gFo2k5Omjhx6HaNWIZphgcbw2xtNWrDSUMMzEaiije4wnDY9JWtooMcQTh1WOaoqJQlJFI3WgLJQ4RP1qaKP6iicNjUZaysjoeT2iaPWIpqggSUVoIgxLUxhEGKoevY/B8r7Nv886zAP1pezsznGjdvw367Xeyj5gr/cIc5Plc/4Rpu9uDt3edlJNlEfo/n4n1vyHgPz1qMXXcdTiqzqJeNNhvYajBU4k6kPA5LBUm1NY08LgAYkYmPdgRbIeHPkQuB58D35ReBKTS8KJfgdjdWMa4pLZ5VTs28epVcQbQRO1x3k8tOLSNkPVAMhvXEgMPzIKRioM9czVAL1Wvft/mBBMEoSJedGDW8CTY9A0f2qIxi9DDDf/p2jBtcxjXK/TfoYjTJZpuSbmRQluAU+JQdP8KSEavwwl3PyfogTXMo9xWoeccKVxM1zowYoD50PCyBqXaOZcOxwGF01mGf6kJNbgQpNX1I5rjN+vbyQqFAWSlChfy6Kz6iiaNDQUSQkjQ4cmC6kZzwhbyS6qD+brNaoPgTSh6tfCE81XNFFYNJIypY8emiq0akQzXLA1zozZbZqBZAH16yGL4qsHWQZ3i6Ygi4ieB1mgakQzUtDyhY+8xiuYMl6f5SyxzRRvxXb1+0izfXYzZJVXz0z/0Y3GU6/+ifjkxtGYlAX+Gg8oiPII/ZSaQXdUed/H/D1UZJ+d39Kdv6ndkMarFzJaJe+GPQ29+iGtHtkcEzaf3A8/tj/XmX0v9oScs09/yN7/hD3ieL3r2fpzZk4JTMoCaULVr4Unmq9oorBoJGVKHz2vs2S0ekRTXLD57Zqbb+xjxcG0YUbWwxzdaQ/y8LAk5o8SRvRoI2rHNcZlu5OZQd0pmD6gfj3cUXz1IA6JRmLWiOh5UAaqRjTDBOEmbyNZ6C3fYWQh6pMfFrnfF2YNDjyfh9eyJ+CzBBjPZ1I1ohkpuMuMKVJ5cQRWEyOvxEYRW8XFi9sUueT0lkh7MZzUjmtMytpWgkR5BKajV4Li09J3Wclr3ciX3mFrLD1gyQkesMYiq0c2pwlPpcAcykXi/DwpMRHHSY3+e5NroQyZ4xH1Jhs2YybW/GilRw7zIyzqDu9gnGTk5mVbs8x2QICpWx/dOelDIrsfa2EWpu2eLBhXhqFGA6d7jAmoNKFIIxIziBuOpvMPzvjdzV0/y3V4vXsGJ8AiA65CGO/Ox3TMYfi2htcgS2uiRtjAbuc6A/56e81MD5mYXcm51T7M93+2VNnHgtyIas4410sE0lsxtZbl3BHv0WRR45N00B3G02tbSVES3SwTh2thjdSiV8a6z0qpLueZqGYZD+vwOtsEgDq4ZxhXaNWIZrggZH82QteF7gtS9asZCVRfPchCopGYLCJ6fv2eVI9oigoyGEyUYaWBpOEm8LS5fU2586wB9zFNG1AC3bMEaZL2LZVk6KGYV45qioOUt3KzxNDHVBnMk1XT7fyEHW0RGmK9zUmBxrhtgFtTsW6zzg9+GA6O5D47y8amIhI4oqvG0Buc8h7++YdaeT1+IlPk1vpEpuhV8qmSVoyRy2PMUOiXeMgY0NWj6/Ya4htldmwrxTFWiOdZGbbfTPMVLnIFLgkvsRQcsgSMWvp1X/KlktZERb1EjH6xUMqiu8oqNtvBnxGw/frNEtmRhvTx60GoPEnuJpl4frS8frDS0D7ETKxlZWjgMZ5MPCZpiaTEEE8iVjmqKa79xcic+iWUNtp8eW1JURKuCd1DGtmYaSBnyN2oEhXf4QmV0/Z2Qf3p/mefBvHy8J54X1MgT4qEzjJktFOTJWiGIaqHm6OvfvXL1N3RUgR5cXRf7/XW6NdUxl/brdE3vcZrgFrkvuhBx53wts2zZn+yHNeWAoGPtd7Q0s+1We5opjlvYGIW6cLneJc0T48APv3jljbo/qEQK2n/GBAR/Qjv68c2yKT3YKW07B1JgdAOKQ0ZF6Ita02O20mOW0GO2ziYbS3crowT52+QwXO+xy4t53Ws8ZyX9WMbVHqI/QkU5fGz8Idxsy3bDgLoORYvMhAHnMLiCqKa46KXrMng/kwzJblAMCuFoS+WmMNIenBTxjoxPTVsPOYJon5sg0y6LNTXkyFBaWkoO5mJL+ahv4b+oKOG7wwc17Q9QeEBvhuwylFNMdFLUYHTnXk/rZcI7Qi9qa+wM8z0BnkLH743KACn7REDQuB7Ra8gukk5R7i2xp5BS0N7BTOxlp3mgcd4MvGYpCWSEkM8iVjlqKaY6EvRWd7HaGkoeZiJqMkaxAE4J7boLuLZwoOQli1K0PBsYZWjmlJEUR8DsyrBeRYQjVTkI3/uGpUJ0+1E4ef/ZStkbjeBB0WBnZ8oX+wD7a8w35EKJprfFO6kw5ykB7rjkJrxjAg5GaGxrlCGfsg5vCPhi7lNWo+cD+9IbFMzT2Dhwz2oG9OQlLRcDcOLI3AQe0HMfZyW8zqiOMOlJklXmYac8ep76W/Z0Vjo1f8Qd+04GhOywEjgkbkT0vLwXsjMrOWV+sZvHyLxyKRmkhJJHyqx6pHNMeG8MeYBJWWhJCLqV0Mg1Vc8eWg00hJHRg9PGlI1ohku+Ml20RcvDqYMM3L3B7M8DpF6n9ta7YHVGdfsh2z06NGcr4k7tcJvj37Nasc1xmSLs3kLlxaG9mxqYDVPA81dPHlYQNJSpw/gFJZ1bTu1x4tD8eRGXlPZu5NugAuedgK5tMRTkcaPWbx2XGNStiuMH0Hx4gg8J0ZWM3INnPYiEQ1LchLJME7h2phvsiJloYgS9Xdwk+xrGoEo/UUlE76zULql7SmSnvixllSNaEYKHpvCuIEqyiP0SmrmdeE35cJv2u97Io8HX9QW1bCbeY1VrCMmH6/6juswmNhuHxLlEQYTz9uHPNOL3s8gEumIQPwkXvOcNxgSzqtbLXC/kk5hr6kA5n4lizkydf0uxn/mzADQ1R+zHUQcnuWmoeHUdZf2zXb78i/7p2PRna67p6Lekursf4cL/HLqzsatr7GBQjPsfHhrtLloYqlaEpML47aBYJqKGcz2FfYf4OB5mR+OBFB5qtaI5kAcB+q4LU9sdWVLQIyLhQ3pgab5GtHXe4ax/qVu5IzIiLgUxGE91O+JslCzBL6untuQlTrmMNzXuFzg7ayQGzZGMIUcDsuBdk8ouZYlkHR02wakUDGDWVlhB70Ztig/TMEo5VAwDrX7wSi0LACjq9sWGKWKGcz2FQo4cVTDcNx8nkRSEcWBeWvDE89e0RKQuvtvQ1XRMo/xQZ1jk11OjuByWQ90dSsh8DJNi+HrFoJJgLmamcwPKoE2N3yJoAe4iv4QZEHNYrA6eD6JKdExh2FZY3/KLjCdmsJSiKGQHOj2w5ErWQBFR58tGAoN8Y328nVZX079x8NGAIUcDsGBdk8IuZYlMHR02waiUDGDWaVCtS+vrZIi2gyklERCObTgC6bQswicrs5bAZVKZjGtVPnoMLJSISSUil5fFEHFIgA6eGvFjtSPbbCXhg3EQq6qGiFjUjjMNM2eoFEdS6Dm5LANNqYgusmB+OT6rBDzgC5wPZYrWQy8kPVXoSG+USkP+5TFXrsBzgShIolC8daCH5C9ngWwdHfeAqeiZBbTsgqs7qkXopoQFWIoOAe6/bDkShYA0tFnC4pCQ3yjQ/nJUVXK+SAYOK4KLcthGDKyShUzmO0rXAq6xDMJo5DDwTjQ7gkj17IEjI5u22AUKmYwq1Yo6+N1ejgVclgYNe3eMFIty8Do5LYdRqZiBrN9hQZyW2aTIDIpHISaZk8AqY4l4HNy2AYeUxDdZC/+KSNfY04ix8Vw0Om6PbFjSpYAz81nG3pcQ3yjUv65rjuX6aiUQyE41O4HodCyAIaubltAlCpmMKtUaHKXgzlSDgmjrt0XRqZlERjd3LbCyFXMYFZWOJZ122bTJwCkHArGoXY/GIWWBWB0ddsCo1Qxg1m9gsNesJTDwxi8Eyy0LAVj2D6wVDGDWVmB3Rw1gaFyvZQrgJpeP/SoigWgc/LWghurH9ugIt019eEKX59NL5lqskj8bq34wthrWgRN9xBYQVXUzGReViLTIoehVYihgB3o9sOUK1kATkefLUgKDfGNqvK22+n6hhApLHqKZm/wQMcy2Dk4bIeOKIhuUopfsmO+g29TJw8a94Io+G70+yEo1SwAorPnFhx7HXMY1mqoWSBtYDpnQrytFQzlAokmMW5PAIlJOYmONqsAl+lOQigu3HWHr9fqC12zDGzTrlohg+qRzfXCoCCbfvIJMRxgum5PzJiSJWBz89mGHNcQ36giD8cXHXaapBwSQV27L4RMyyIYurltBZGrmMFsX+Falr9dHVa8e0EckEP9nkgKNUtA6eq5DUupYw7DssZv1eSmIRFBIajo9MMOFCyAmoOfFrxI7bjGpGx73XVFN71HKOVQaA21+0EmtCyAm6vbFvCkihnMygpKhjsTgq7Z5LQKQbilz5rn6KcFLVI7rrFett5PYgQiOIx6nZ4Y1fslMJr204YR1A4z9nYLB5nOcBPx99/+HfM9pwjLhgEA",
@@ -1615,11 +3286,17 @@ var EpubCheck = class _EpubCheck {
1615
3286
  }
1616
3287
  const opfValidator = new OPFValidator();
1617
3288
  opfValidator.validate(context);
3289
+ const registry = new ResourceRegistry();
3290
+ const refValidator = new ReferenceValidator(registry, context.version);
3291
+ if (context.packageDocument) {
3292
+ this.populateRegistry(context, registry);
3293
+ }
1618
3294
  const contentValidator = new ContentValidator();
1619
- contentValidator.validate(context);
3295
+ contentValidator.validate(context, registry, refValidator);
1620
3296
  if (context.version === "2.0" && context.packageDocument) {
1621
3297
  this.validateNCX(context);
1622
3298
  }
3299
+ refValidator.validate(context);
1623
3300
  const schemaValidator = new SchemaValidator(context);
1624
3301
  await schemaValidator.validate();
1625
3302
  } catch (error) {
@@ -1707,6 +3384,25 @@ var EpubCheck = class _EpubCheck {
1707
3384
  }
1708
3385
  messages.push(message);
1709
3386
  }
3387
+ /**
3388
+ * Populate resource registry from package document manifest
3389
+ */
3390
+ populateRegistry(context, registry) {
3391
+ const packageDoc = context.packageDocument;
3392
+ if (!packageDoc) return;
3393
+ const opfPath = context.opfPath ?? "";
3394
+ const opfDir = opfPath.includes("/") ? opfPath.substring(0, opfPath.lastIndexOf("/")) : "";
3395
+ const spineIdrefs = new Set(packageDoc.spine.map((item) => item.idref));
3396
+ for (const item of packageDoc.manifest) {
3397
+ const fullPath = opfDir ? `${opfDir}/${item.href}` : item.href;
3398
+ registry.registerResource({
3399
+ url: fullPath,
3400
+ mimeType: item.mediaType,
3401
+ inSpine: spineIdrefs.has(item.id),
3402
+ ids: /* @__PURE__ */ new Set()
3403
+ });
3404
+ }
3405
+ }
1710
3406
  };
1711
3407
 
1712
3408
  // src/messages/message-id.ts