@likecoin/epubcheck-ts 0.1.0 → 0.2.1

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)`,
1242
+ location: { path: "mimetype" }
1243
+ });
1244
+ }
1245
+ if (compressionInfo.compressionMethod !== 0) {
1246
+ messages.push({
1247
+ id: "PKG-006",
1248
+ severity: "error",
1249
+ message: "Mimetype file must be uncompressed",
435
1250
  location: { path: "mimetype" }
436
1251
  });
437
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,94 @@ 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
+ if (file === "META-INF/") continue;
1382
+ const filename = file.replace("META-INF/", "");
1383
+ if (!allowedFiles.has(filename)) {
1384
+ messages.push({
1385
+ id: "PKG-025",
1386
+ severity: "error",
1387
+ message: `File not allowed in META-INF directory: ${filename}`,
1388
+ location: { path: file }
1389
+ });
1390
+ }
1391
+ }
1392
+ }
1393
+ /**
1394
+ * Validate filenames for invalid characters
1395
+ */
1396
+ validateFilenames(zip, messages) {
1397
+ for (const path of zip.paths) {
1398
+ if (path === "mimetype") continue;
1399
+ if (path.endsWith("/")) continue;
1400
+ const filename = path.includes("/") ? path.split("/").pop() ?? path : path;
1401
+ if (filename === "" || filename === "." || filename === "..") {
1402
+ messages.push({
1403
+ id: "PKG-009",
1404
+ severity: "error",
1405
+ message: `Invalid filename: "${path}"`,
1406
+ location: { path }
1407
+ });
1408
+ continue;
1409
+ }
1410
+ for (let i = 0; i < filename.length; i++) {
1411
+ const code = filename.charCodeAt(i);
1412
+ if (code < 32 || code === 127 || code >= 128 && code <= 159) {
1413
+ messages.push({
1414
+ id: "PKG-010",
1415
+ severity: "error",
1416
+ message: `Filename contains control character: "${path}"`,
1417
+ location: { path }
1418
+ });
1419
+ break;
1420
+ }
1421
+ }
1422
+ const specialChars = '<>:"|?*';
1423
+ for (const char of specialChars) {
1424
+ if (filename.includes(char)) {
1425
+ messages.push({
1426
+ id: "PKG-011",
1427
+ severity: "error",
1428
+ message: `Filename contains special character: "${path}"`,
1429
+ location: { path }
1430
+ });
1431
+ break;
1432
+ }
1433
+ }
1434
+ }
1435
+ }
1436
+ /**
1437
+ * Validate empty directories
1438
+ */
1439
+ validateEmptyDirectories(zip, messages) {
1440
+ const directories = /* @__PURE__ */ new Set();
1441
+ for (const path of zip.paths) {
1442
+ const parts = path.split("/");
1443
+ for (let i = 1; i < parts.length; i++) {
1444
+ const dir = parts.slice(0, i).join("/") + "/";
1445
+ directories.add(dir);
1446
+ }
1447
+ }
1448
+ for (const dir of directories) {
1449
+ if (dir !== "META-INF/" && dir !== "OEBPS/" && dir !== "OPS/") {
1450
+ const filesInDir = zip.paths.filter((p) => p.startsWith(dir) && p !== dir);
1451
+ if (filesInDir.length === 0) {
1452
+ messages.push({
1453
+ id: "PKG-014",
1454
+ severity: "warning",
1455
+ message: `Empty directory found: ${dir}`,
1456
+ location: { path: dir }
1457
+ });
1458
+ }
1459
+ }
548
1460
  }
549
1461
  }
550
1462
  };
@@ -581,6 +1493,7 @@ function parseOPF(xml) {
581
1493
  const spineResult = parseSpine(spineSection, spineAttrs);
582
1494
  const guideSection = extractSection(xml, "guide");
583
1495
  const guide = parseGuide(guideSection);
1496
+ const collections = parseCollections(xml);
584
1497
  const result = {
585
1498
  version,
586
1499
  uniqueIdentifier,
@@ -589,7 +1502,8 @@ function parseOPF(xml) {
589
1502
  linkElements,
590
1503
  manifest,
591
1504
  spine: spineResult.spine,
592
- guide
1505
+ guide,
1506
+ collections
593
1507
  };
594
1508
  if (Object.keys(prefixes).length > 0) {
595
1509
  result.prefixes = prefixes;
@@ -823,6 +1737,42 @@ function parseAttributes(attrsStr) {
823
1737
  function decodeXmlEntities(str) {
824
1738
  return str.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&apos;/g, "'").replace(/&quot;/g, '"');
825
1739
  }
1740
+ function parseCollections(xml) {
1741
+ const collections = [];
1742
+ const collectionRegex = /<collection[^>]*\srole=["']([^"']+)["'][^>]*>/g;
1743
+ let collectionMatch;
1744
+ while ((collectionMatch = collectionRegex.exec(xml)) !== null) {
1745
+ const role = collectionMatch[1];
1746
+ if (!role) continue;
1747
+ const collectionStart = collectionMatch.index;
1748
+ const collectionStartTag = collectionMatch[0];
1749
+ const idMatch = /id=["']([^"']+)["']/.exec(collectionStartTag);
1750
+ const id = idMatch?.[1];
1751
+ const nameMatch = /name=["']([^"']+)["']/.exec(collectionStartTag);
1752
+ const name = nameMatch?.[1];
1753
+ const links = [];
1754
+ const linkRegex = /<link[^>]*\shref=["']([^"']+)["'][^>]*\/?>/g;
1755
+ const closingTag = `</collection>`;
1756
+ const closingIndex = xml.indexOf(closingTag, collectionStart);
1757
+ const collectionEnd = closingIndex >= 0 ? closingIndex + closingTag.length : xml.length;
1758
+ const collectionXml = xml.slice(collectionStart, collectionEnd);
1759
+ let linkMatch;
1760
+ while ((linkMatch = linkRegex.exec(collectionXml)) !== null) {
1761
+ const href = linkMatch[1];
1762
+ if (href) {
1763
+ links.push(decodeXmlEntities(href));
1764
+ }
1765
+ }
1766
+ const collection = {
1767
+ role,
1768
+ links
1769
+ };
1770
+ if (id) collection.id = id;
1771
+ if (name) collection.name = name;
1772
+ collections.push(collection);
1773
+ }
1774
+ return collections;
1775
+ }
826
1776
 
827
1777
  // src/opf/types.ts
828
1778
  var ITEM_PROPERTIES = /* @__PURE__ */ new Set([
@@ -883,7 +1833,7 @@ var OPFValidator = class {
883
1833
  this.packageDoc = parseOPF(opfXml);
884
1834
  } catch (error) {
885
1835
  context.messages.push({
886
- id: "OPF-001",
1836
+ id: "OPF-002",
887
1837
  severity: "fatal",
888
1838
  message: `Failed to parse package document: ${error instanceof Error ? error.message : "Unknown error"}`,
889
1839
  location: { path: opfPath }
@@ -901,6 +1851,9 @@ var OPFValidator = class {
901
1851
  if (this.packageDoc.version === "2.0") {
902
1852
  this.validateGuide(context, opfPath);
903
1853
  }
1854
+ if (this.packageDoc.version.startsWith("3.")) {
1855
+ this.validateCollections(context, opfPath);
1856
+ }
904
1857
  }
905
1858
  /**
906
1859
  * Build lookup maps for manifest items
@@ -918,6 +1871,15 @@ var OPFValidator = class {
918
1871
  */
919
1872
  validatePackageAttributes(context, opfPath) {
920
1873
  if (!this.packageDoc) return;
1874
+ const validVersions = /* @__PURE__ */ new Set(["2.0", "3.0", "3.1", "3.2", "3.3"]);
1875
+ if (!validVersions.has(this.packageDoc.version)) {
1876
+ context.messages.push({
1877
+ id: "OPF-001",
1878
+ severity: "error",
1879
+ message: `Invalid package version "${this.packageDoc.version}"; must be one of: ${Array.from(validVersions).join(", ")}`,
1880
+ location: { path: opfPath }
1881
+ });
1882
+ }
921
1883
  if (!this.packageDoc.uniqueIdentifier) {
922
1884
  context.messages.push({
923
1885
  id: "OPF-048",
@@ -946,6 +1908,15 @@ var OPFValidator = class {
946
1908
  validateMetadata(context, opfPath) {
947
1909
  if (!this.packageDoc) return;
948
1910
  const dcElements = this.packageDoc.dcElements;
1911
+ if (dcElements.length === 0 && this.packageDoc.metaElements.length === 0) {
1912
+ context.messages.push({
1913
+ id: "OPF-072",
1914
+ severity: "error",
1915
+ message: "Metadata section is empty",
1916
+ location: { path: opfPath }
1917
+ });
1918
+ return;
1919
+ }
949
1920
  const hasIdentifier = dcElements.some((dc) => dc.name === "identifier");
950
1921
  const hasTitle = dcElements.some((dc) => dc.name === "title");
951
1922
  const hasLanguage = dcElements.some((dc) => dc.name === "language");
@@ -984,18 +1955,144 @@ var OPFValidator = class {
984
1955
  });
985
1956
  }
986
1957
  }
1958
+ if (dc.name === "date" && dc.value) {
1959
+ if (!isValidW3CDateFormat(dc.value)) {
1960
+ context.messages.push({
1961
+ id: "OPF-053",
1962
+ severity: "error",
1963
+ message: `Invalid date format "${dc.value}"; must be W3C date format (ISO 8601)`,
1964
+ location: { path: opfPath }
1965
+ });
1966
+ }
1967
+ }
1968
+ if (dc.name === "creator" && dc.attributes) {
1969
+ const opfRole = dc.attributes["opf:role"];
1970
+ if (opfRole?.startsWith("marc:")) {
1971
+ const relatorCode = opfRole.substring(5);
1972
+ const validRelatorCodes = /* @__PURE__ */ new Set([
1973
+ "arr",
1974
+ "aut",
1975
+ "aut",
1976
+ "ccp",
1977
+ "com",
1978
+ "ctb",
1979
+ "csl",
1980
+ "edt",
1981
+ "ill",
1982
+ "itr",
1983
+ "pbl",
1984
+ "pdr",
1985
+ "prt",
1986
+ "trl",
1987
+ "cre",
1988
+ "art",
1989
+ "ctb",
1990
+ "edt",
1991
+ "pfr",
1992
+ "red",
1993
+ "rev",
1994
+ "spn",
1995
+ "dsx",
1996
+ "pmc",
1997
+ "dte",
1998
+ "ove",
1999
+ "trc",
2000
+ "ldr",
2001
+ "led",
2002
+ "prg",
2003
+ "rap",
2004
+ "rce",
2005
+ "rpc",
2006
+ "rtr",
2007
+ "sad",
2008
+ "sgn",
2009
+ "tce",
2010
+ "aac",
2011
+ "acq",
2012
+ "ant",
2013
+ "arr",
2014
+ "art",
2015
+ "ard",
2016
+ "asg",
2017
+ "aus",
2018
+ "aft",
2019
+ "bdd",
2020
+ "bdd",
2021
+ "clb",
2022
+ "clc",
2023
+ "drd",
2024
+ "edt",
2025
+ "edt",
2026
+ "fmd",
2027
+ "flm",
2028
+ "fmo",
2029
+ "fpy",
2030
+ "hnr",
2031
+ "ill",
2032
+ "ilt",
2033
+ "img",
2034
+ "itr",
2035
+ "lrg",
2036
+ "lsa",
2037
+ "led",
2038
+ "lee",
2039
+ "lel",
2040
+ "lgd",
2041
+ "lse",
2042
+ "mfr",
2043
+ "mod",
2044
+ "mon",
2045
+ "mus",
2046
+ "nrt",
2047
+ "ogt",
2048
+ "org",
2049
+ "oth",
2050
+ "pnt",
2051
+ "ppa",
2052
+ "prv",
2053
+ "pup",
2054
+ "red",
2055
+ "rev",
2056
+ "rsg",
2057
+ "srv",
2058
+ "stn",
2059
+ "stl",
2060
+ "trc",
2061
+ "typ",
2062
+ "vdg",
2063
+ "voc",
2064
+ "wac",
2065
+ "wdc"
2066
+ ]);
2067
+ if (!validRelatorCodes.has(relatorCode)) {
2068
+ context.messages.push({
2069
+ id: "OPF-052",
2070
+ severity: "error",
2071
+ message: `Unknown MARC relator code "${relatorCode}" in dc:creator`,
2072
+ location: { path: opfPath }
2073
+ });
2074
+ }
2075
+ }
2076
+ }
987
2077
  }
988
2078
  if (this.packageDoc.version !== "2.0") {
989
- const hasModified = this.packageDoc.metaElements.some(
2079
+ const modifiedMeta = this.packageDoc.metaElements.find(
990
2080
  (meta) => meta.property === "dcterms:modified"
991
2081
  );
992
- if (!hasModified) {
2082
+ if (!modifiedMeta) {
993
2083
  context.messages.push({
994
2084
  id: "OPF-054",
995
2085
  severity: "error",
996
2086
  message: "EPUB 3 metadata must include a dcterms:modified meta element",
997
2087
  location: { path: opfPath }
998
2088
  });
2089
+ } else if (modifiedMeta.value && !isValidW3CDateFormat(modifiedMeta.value)) {
2090
+ context.messages.push({
2091
+ id: "OPF-054",
2092
+ severity: "error",
2093
+ message: `Invalid dcterms:modified date format "${modifiedMeta.value}"; must be W3C date format (ISO 8601)`,
2094
+ location: { path: opfPath }
2095
+ });
999
2096
  }
1000
2097
  }
1001
2098
  }
@@ -1034,6 +2131,29 @@ var OPFValidator = class {
1034
2131
  location: { path: opfPath }
1035
2132
  });
1036
2133
  }
2134
+ if (!isValidMimeType(item.mediaType)) {
2135
+ context.messages.push({
2136
+ id: "OPF-014",
2137
+ severity: "error",
2138
+ message: `Invalid media-type format "${item.mediaType}" for item "${item.id}"`,
2139
+ location: { path: opfPath }
2140
+ });
2141
+ }
2142
+ const deprecatedTypes = /* @__PURE__ */ new Map([
2143
+ ["text/x-oeb1-document", "OPF-035"],
2144
+ ["text/x-oeb1-css", "OPF-037"],
2145
+ ["application/x-oeb1-package", "OPF-038"],
2146
+ ["text/x-oeb1-html", "OPF-037"]
2147
+ ]);
2148
+ const deprecatedId = deprecatedTypes.get(item.mediaType);
2149
+ if (deprecatedId) {
2150
+ context.messages.push({
2151
+ id: deprecatedId,
2152
+ severity: "warning",
2153
+ message: `Deprecated OEB 1.0 media-type "${item.mediaType}" should not be used`,
2154
+ location: { path: opfPath }
2155
+ });
2156
+ }
1037
2157
  if (this.packageDoc.version !== "2.0" && item.properties) {
1038
2158
  for (const prop of item.properties) {
1039
2159
  if (!ITEM_PROPERTIES.has(prop) && !prop.includes(":")) {
@@ -1064,6 +2184,16 @@ var OPFValidator = class {
1064
2184
  location: { path: opfPath }
1065
2185
  });
1066
2186
  }
2187
+ if (this.packageDoc.version !== "2.0" && (item.href.startsWith("http://") || item.href.startsWith("https://"))) {
2188
+ if (!item.properties?.includes("remote-resources")) {
2189
+ context.messages.push({
2190
+ id: "RSC-006",
2191
+ severity: "error",
2192
+ message: `Manifest item "${item.id}" references remote resource but is missing "remote-resources" property`,
2193
+ location: { path: opfPath }
2194
+ });
2195
+ }
2196
+ }
1067
2197
  }
1068
2198
  if (this.packageDoc.version !== "2.0") {
1069
2199
  const hasNav = this.packageDoc.manifest.some((item) => item.properties?.includes("nav"));
@@ -1076,6 +2206,31 @@ var OPFValidator = class {
1076
2206
  });
1077
2207
  }
1078
2208
  }
2209
+ this.checkUndeclaredResources(context, opfPath);
2210
+ }
2211
+ /**
2212
+ * Check for files in container that are not declared in manifest
2213
+ */
2214
+ checkUndeclaredResources(context, opfPath) {
2215
+ if (!this.packageDoc) return;
2216
+ const declaredPaths = /* @__PURE__ */ new Set();
2217
+ for (const item of this.packageDoc.manifest) {
2218
+ const fullPath = resolvePath(opfPath, item.href);
2219
+ declaredPaths.add(fullPath);
2220
+ }
2221
+ declaredPaths.add(opfPath);
2222
+ for (const filePath of context.files.keys()) {
2223
+ if (filePath.endsWith("/")) continue;
2224
+ if (filePath.startsWith("META-INF/")) continue;
2225
+ if (filePath === "mimetype") continue;
2226
+ if (declaredPaths.has(filePath)) continue;
2227
+ context.messages.push({
2228
+ id: "RSC-008",
2229
+ severity: "error",
2230
+ message: `File in container is not declared in manifest: ${filePath}`,
2231
+ location: { path: filePath }
2232
+ });
2233
+ }
1079
2234
  }
1080
2235
  /**
1081
2236
  * Validate spine section
@@ -1228,6 +2383,89 @@ var OPFValidator = class {
1228
2383
  }
1229
2384
  }
1230
2385
  }
2386
+ validateCollections(context, opfPath) {
2387
+ if (!this.packageDoc) return;
2388
+ const collections = this.packageDoc.collections;
2389
+ if (collections.length === 0) {
2390
+ return;
2391
+ }
2392
+ const validRoles = /* @__PURE__ */ new Set(["dictionary", "index", "preview", "recordings"]);
2393
+ for (const collection of collections) {
2394
+ if (!validRoles.has(collection.role)) {
2395
+ context.messages.push({
2396
+ id: "OPF-071",
2397
+ severity: "warning",
2398
+ message: `Unknown collection role: "${collection.role}"`,
2399
+ location: { path: opfPath }
2400
+ });
2401
+ }
2402
+ if (collection.role === "dictionary") {
2403
+ if (!collection.name || collection.name.trim() === "") {
2404
+ context.messages.push({
2405
+ id: "OPF-072",
2406
+ severity: "error",
2407
+ message: "Dictionary collection must have a name attribute",
2408
+ location: { path: opfPath }
2409
+ });
2410
+ }
2411
+ for (const linkHref of collection.links) {
2412
+ const manifestItem = this.manifestByHref.get(linkHref);
2413
+ if (!manifestItem) {
2414
+ context.messages.push({
2415
+ id: "OPF-073",
2416
+ severity: "error",
2417
+ message: `Collection link "${linkHref}" references non-existent manifest item`,
2418
+ location: { path: opfPath }
2419
+ });
2420
+ continue;
2421
+ }
2422
+ if (manifestItem.mediaType !== "application/xhtml+xml" && manifestItem.mediaType !== "image/svg+xml") {
2423
+ context.messages.push({
2424
+ id: "OPF-074",
2425
+ severity: "error",
2426
+ message: `Dictionary collection item "${linkHref}" must be an XHTML or SVG document`,
2427
+ location: { path: opfPath }
2428
+ });
2429
+ }
2430
+ }
2431
+ }
2432
+ if (collection.role === "index") {
2433
+ for (const linkHref of collection.links) {
2434
+ const manifestItem = this.manifestByHref.get(linkHref);
2435
+ if (!manifestItem) {
2436
+ context.messages.push({
2437
+ id: "OPF-073",
2438
+ severity: "error",
2439
+ message: `Collection link "${linkHref}" references non-existent manifest item`,
2440
+ location: { path: opfPath }
2441
+ });
2442
+ continue;
2443
+ }
2444
+ if (manifestItem.mediaType !== "application/xhtml+xml") {
2445
+ context.messages.push({
2446
+ id: "OPF-075",
2447
+ severity: "error",
2448
+ message: `Index collection item "${linkHref}" must be an XHTML document`,
2449
+ location: { path: opfPath }
2450
+ });
2451
+ }
2452
+ }
2453
+ }
2454
+ if (collection.role === "preview") {
2455
+ for (const linkHref of collection.links) {
2456
+ const manifestItem = this.manifestByHref.get(linkHref);
2457
+ if (!manifestItem) {
2458
+ context.messages.push({
2459
+ id: "OPF-073",
2460
+ severity: "error",
2461
+ message: `Collection link "${linkHref}" references non-existent manifest item`,
2462
+ location: { path: opfPath }
2463
+ });
2464
+ }
2465
+ }
2466
+ }
2467
+ }
2468
+ }
1231
2469
  };
1232
2470
  function isSpineMediaType(mediaType) {
1233
2471
  return mediaType === "application/xhtml+xml" || mediaType === "image/svg+xml" || // EPUB 2 also allows these in spine
@@ -1256,6 +2494,442 @@ function resolvePath(basePath, relativePath) {
1256
2494
  }
1257
2495
  return parts.join("/");
1258
2496
  }
2497
+ function isValidMimeType(mediaType) {
2498
+ const mimeTypePattern = /^[a-zA-Z][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_+]*(?:\s*;\s*[a-zA-Z0-9-]+=[^;]+)?$/;
2499
+ if (!mimeTypePattern.test(mediaType)) {
2500
+ return false;
2501
+ }
2502
+ const [type, subtypeWithParams] = mediaType.split("/");
2503
+ if (!type || !subtypeWithParams) return false;
2504
+ const subtype = subtypeWithParams.split(";")[0]?.trim();
2505
+ if (!subtype) return false;
2506
+ if (type.length > 127 || subtype.length > 127) {
2507
+ return false;
2508
+ }
2509
+ return true;
2510
+ }
2511
+ function isValidW3CDateFormat(dateStr) {
2512
+ const trimmed = dateStr.trim();
2513
+ const yearOnlyPattern = /^\d{4}$/;
2514
+ if (yearOnlyPattern.test(trimmed)) {
2515
+ const year = Number(trimmed);
2516
+ return year >= 0 && year <= 9999;
2517
+ }
2518
+ const yearMonthPattern = /^(\d{4})-(\d{2})$/;
2519
+ const yearMonthMatch = yearMonthPattern.exec(trimmed);
2520
+ if (yearMonthMatch) {
2521
+ const year = Number(yearMonthMatch[1]);
2522
+ const month = Number(yearMonthMatch[2]);
2523
+ if (year < 0 || year > 9999) return false;
2524
+ if (month < 1 || month > 12) return false;
2525
+ return true;
2526
+ }
2527
+ const dateOnlyPattern = /^(\d{4})-(\d{2})-(\d{2})$/;
2528
+ const dateMatch = dateOnlyPattern.exec(trimmed);
2529
+ if (dateMatch) {
2530
+ const year = Number(dateMatch[1]);
2531
+ const month = Number(dateMatch[2]);
2532
+ const day = Number(dateMatch[3]);
2533
+ if (year < 0 || year > 9999) return false;
2534
+ if (month < 1 || month > 12) return false;
2535
+ const daysInMonth = new Date(year, month, 0).getDate();
2536
+ if (day < 1 || day > daysInMonth) return false;
2537
+ return true;
2538
+ }
2539
+ const dateTimePattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?$/;
2540
+ const dateTimeMatch = dateTimePattern.exec(trimmed);
2541
+ if (dateTimeMatch) {
2542
+ const year = Number(dateTimeMatch[1]);
2543
+ const month = Number(dateTimeMatch[2]);
2544
+ const day = Number(dateTimeMatch[3]);
2545
+ if (year < 0 || year > 9999) return false;
2546
+ if (month < 1 || month > 12) return false;
2547
+ const daysInMonth = new Date(year, month, 0).getDate();
2548
+ if (day < 1 || day > daysInMonth) return false;
2549
+ const hours = Number(dateTimeMatch[4]);
2550
+ const minutes = Number(dateTimeMatch[5]);
2551
+ const seconds = Number(dateTimeMatch[6]);
2552
+ if (hours < 0 || hours > 23) return false;
2553
+ if (minutes < 0 || minutes > 59) return false;
2554
+ if (seconds < 0 || seconds > 59) return false;
2555
+ return true;
2556
+ }
2557
+ return false;
2558
+ }
2559
+
2560
+ // src/references/registry.ts
2561
+ var ResourceRegistry = class {
2562
+ resources;
2563
+ ids;
2564
+ constructor() {
2565
+ this.resources = /* @__PURE__ */ new Map();
2566
+ this.ids = /* @__PURE__ */ new Map();
2567
+ }
2568
+ /**
2569
+ * Register a resource from manifest
2570
+ */
2571
+ registerResource(resource) {
2572
+ this.resources.set(resource.url, resource);
2573
+ for (const id of resource.ids) {
2574
+ this.registerID(resource.url, id);
2575
+ }
2576
+ }
2577
+ /**
2578
+ * Register an ID in a resource
2579
+ */
2580
+ registerID(resourceURL, id) {
2581
+ if (!id || !this.resources.has(resourceURL)) {
2582
+ return;
2583
+ }
2584
+ if (!this.ids.has(resourceURL)) {
2585
+ this.ids.set(resourceURL, /* @__PURE__ */ new Set());
2586
+ }
2587
+ const resourceIDs = this.ids.get(resourceURL);
2588
+ resourceIDs?.add(id);
2589
+ const resource = this.resources.get(resourceURL);
2590
+ if (resource) {
2591
+ resource.ids.add(id);
2592
+ }
2593
+ }
2594
+ /**
2595
+ * Get a resource by URL
2596
+ */
2597
+ getResource(url) {
2598
+ return this.resources.get(url);
2599
+ }
2600
+ /**
2601
+ * Check if a resource exists
2602
+ */
2603
+ hasResource(url) {
2604
+ return this.resources.has(url);
2605
+ }
2606
+ /**
2607
+ * Check if an ID exists in a resource
2608
+ */
2609
+ hasID(resourceURL, id) {
2610
+ const resourceIDs = this.ids.get(resourceURL);
2611
+ return resourceIDs?.has(id) ?? false;
2612
+ }
2613
+ /**
2614
+ * Get all resources
2615
+ */
2616
+ getAllResources() {
2617
+ return Array.from(this.resources.values());
2618
+ }
2619
+ /**
2620
+ * Get all resource URLs
2621
+ */
2622
+ getResourceURLs() {
2623
+ return Array.from(this.resources.keys());
2624
+ }
2625
+ /**
2626
+ * Get resources in spine
2627
+ */
2628
+ getSpineResources() {
2629
+ return Array.from(this.resources.values()).filter((r) => r.inSpine);
2630
+ }
2631
+ /**
2632
+ * Check if a resource is in spine
2633
+ */
2634
+ isInSpine(url) {
2635
+ const resource = this.resources.get(url);
2636
+ return resource?.inSpine ?? false;
2637
+ }
2638
+ };
2639
+
2640
+ // src/references/url.ts
2641
+ function parseURL(urlString) {
2642
+ const hashIndex = urlString.indexOf("#");
2643
+ if (hashIndex === -1) {
2644
+ return {
2645
+ url: urlString,
2646
+ resource: urlString,
2647
+ hasFragment: false
2648
+ };
2649
+ }
2650
+ const resource = urlString.substring(0, hashIndex);
2651
+ const fragment = urlString.substring(hashIndex + 1);
2652
+ const result = {
2653
+ url: urlString,
2654
+ resource,
2655
+ hasFragment: true
2656
+ };
2657
+ if (fragment) {
2658
+ result.fragment = fragment;
2659
+ }
2660
+ return result;
2661
+ }
2662
+ function isDataURL(url) {
2663
+ return url.startsWith("data:");
2664
+ }
2665
+ function isFileURL(url) {
2666
+ return url.startsWith("file:");
2667
+ }
2668
+ function hasAbsolutePath(url) {
2669
+ return url.startsWith("/");
2670
+ }
2671
+ function hasParentDirectoryReference(url) {
2672
+ return url.includes("..");
2673
+ }
2674
+ function isMalformedURL(url) {
2675
+ if (!url) return true;
2676
+ try {
2677
+ if (/[\s<>]/.test(url)) return true;
2678
+ return false;
2679
+ } catch {
2680
+ return true;
2681
+ }
2682
+ }
2683
+ function isHTTPS(url) {
2684
+ return url.startsWith("https://");
2685
+ }
2686
+ function isHTTP(url) {
2687
+ return url.startsWith("http://");
2688
+ }
2689
+ function isRemoteURL(url) {
2690
+ return isHTTP(url) || isHTTPS(url);
2691
+ }
2692
+
2693
+ // src/references/validator.ts
2694
+ var ReferenceValidator = class {
2695
+ registry;
2696
+ version;
2697
+ references = [];
2698
+ constructor(registry, version) {
2699
+ this.registry = registry;
2700
+ this.version = version;
2701
+ }
2702
+ /**
2703
+ * Register a reference for validation
2704
+ */
2705
+ addReference(reference) {
2706
+ this.references.push(reference);
2707
+ }
2708
+ /**
2709
+ * Validate all registered references
2710
+ */
2711
+ validate(context) {
2712
+ for (const reference of this.references) {
2713
+ this.validateReference(context, reference);
2714
+ }
2715
+ this.checkUndeclaredResources(context);
2716
+ }
2717
+ /**
2718
+ * Validate a single reference
2719
+ */
2720
+ validateReference(context, reference) {
2721
+ const url = reference.url;
2722
+ if (isMalformedURL(url)) {
2723
+ context.messages.push({
2724
+ id: "RSC-020",
2725
+ severity: "error",
2726
+ message: `Malformed URL: ${url}`,
2727
+ location: reference.location
2728
+ });
2729
+ return;
2730
+ }
2731
+ if (isDataURL(url)) {
2732
+ if (this.version.startsWith("3.")) {
2733
+ context.messages.push({
2734
+ id: "RSC-029",
2735
+ severity: "error",
2736
+ message: "Data URLs are not allowed in EPUB 3",
2737
+ location: reference.location
2738
+ });
2739
+ }
2740
+ return;
2741
+ }
2742
+ if (isFileURL(url)) {
2743
+ context.messages.push({
2744
+ id: "RSC-026",
2745
+ severity: "error",
2746
+ message: "File URLs are not allowed",
2747
+ location: reference.location
2748
+ });
2749
+ return;
2750
+ }
2751
+ const resourcePath = reference.targetResource || parseURL(url).resource;
2752
+ const fragment = reference.fragment ?? parseURL(url).fragment;
2753
+ const hasFragment = fragment !== void 0 && fragment !== "";
2754
+ if (!isRemoteURL(url)) {
2755
+ this.validateLocalReference(context, reference, resourcePath);
2756
+ } else {
2757
+ this.validateRemoteReference(context, reference);
2758
+ }
2759
+ if (hasFragment) {
2760
+ this.validateFragment(context, reference, resourcePath, fragment);
2761
+ }
2762
+ }
2763
+ /**
2764
+ * Validate a local (non-remote) reference
2765
+ */
2766
+ validateLocalReference(context, reference, resourcePath) {
2767
+ if (hasAbsolutePath(resourcePath)) {
2768
+ context.messages.push({
2769
+ id: "RSC-027",
2770
+ severity: "error",
2771
+ message: "Absolute paths are not allowed in EPUB",
2772
+ location: reference.location
2773
+ });
2774
+ }
2775
+ const forbiddenParentDirTypes = [
2776
+ "hyperlink" /* HYPERLINK */,
2777
+ "nav-toc-link" /* NAV_TOC_LINK */,
2778
+ "nav-pagelist-link" /* NAV_PAGELIST_LINK */
2779
+ ];
2780
+ if (hasParentDirectoryReference(reference.url) && forbiddenParentDirTypes.includes(reference.type)) {
2781
+ context.messages.push({
2782
+ id: "RSC-028",
2783
+ severity: "error",
2784
+ message: "Parent directory references (..) are not allowed",
2785
+ location: reference.location
2786
+ });
2787
+ }
2788
+ if (!this.registry.hasResource(resourcePath)) {
2789
+ const isLinkRef = reference.type === "link" /* LINK */;
2790
+ context.messages.push({
2791
+ id: isLinkRef ? "RSC-007w" : "RSC-007",
2792
+ severity: isLinkRef ? "warning" : "error",
2793
+ message: `Referenced resource not found in manifest: ${resourcePath}`,
2794
+ location: reference.location
2795
+ });
2796
+ return;
2797
+ }
2798
+ const resource = this.registry.getResource(resourcePath);
2799
+ if (reference.type === "hyperlink" /* HYPERLINK */ && !resource?.inSpine) {
2800
+ context.messages.push({
2801
+ id: "RSC-011",
2802
+ severity: "error",
2803
+ message: "Hyperlinks must reference spine items",
2804
+ location: reference.location
2805
+ });
2806
+ }
2807
+ if (reference.type === "hyperlink" /* HYPERLINK */ || reference.type === "overlay-text-link" /* OVERLAY_TEXT_LINK */) {
2808
+ const targetMimeType = resource?.mimeType;
2809
+ if (targetMimeType && !this.isBlessedItemType(targetMimeType, context.version) && !this.isDeprecatedBlessedItemType(targetMimeType) && !resource.hasCoreMediaTypeFallback) {
2810
+ context.messages.push({
2811
+ id: "RSC-010",
2812
+ severity: "error",
2813
+ message: "Publication resource references must point to content documents",
2814
+ location: reference.location
2815
+ });
2816
+ }
2817
+ }
2818
+ }
2819
+ /**
2820
+ * Validate a remote reference
2821
+ */
2822
+ validateRemoteReference(context, reference) {
2823
+ const url = reference.url;
2824
+ if (isHTTP(url) && !isHTTPS(url)) {
2825
+ context.messages.push({
2826
+ id: "RSC-031",
2827
+ severity: "error",
2828
+ message: "Remote resources must use HTTPS",
2829
+ location: reference.location
2830
+ });
2831
+ }
2832
+ if (isPublicationResourceReference(reference.type)) {
2833
+ const allowedRemoteTypes = /* @__PURE__ */ new Set([
2834
+ "audio" /* AUDIO */,
2835
+ "video" /* VIDEO */,
2836
+ "font" /* FONT */
2837
+ ]);
2838
+ if (!allowedRemoteTypes.has(reference.type)) {
2839
+ context.messages.push({
2840
+ id: "RSC-006",
2841
+ severity: "error",
2842
+ message: "Remote resources are only allowed for audio, video, and fonts",
2843
+ location: reference.location
2844
+ });
2845
+ }
2846
+ }
2847
+ }
2848
+ /**
2849
+ * Validate a fragment identifier
2850
+ */
2851
+ validateFragment(context, reference, resourcePath, fragment) {
2852
+ if (!fragment || !this.registry.hasResource(resourcePath)) {
2853
+ return;
2854
+ }
2855
+ const resource = this.registry.getResource(resourcePath);
2856
+ if (reference.type === "stylesheet" /* STYLESHEET */) {
2857
+ context.messages.push({
2858
+ id: "RSC-013",
2859
+ severity: "error",
2860
+ message: "Stylesheet references must not have fragment identifiers",
2861
+ location: reference.location
2862
+ });
2863
+ return;
2864
+ }
2865
+ if (resource?.mimeType === "image/svg+xml") {
2866
+ const hasSVGView = fragment.includes("svgView(") || fragment.includes("viewBox(");
2867
+ if (hasSVGView && reference.type === "hyperlink" /* HYPERLINK */) {
2868
+ context.messages.push({
2869
+ id: "RSC-014",
2870
+ severity: "error",
2871
+ message: "SVG view fragments can only be referenced from SVG documents",
2872
+ location: reference.location
2873
+ });
2874
+ }
2875
+ }
2876
+ if (!this.registry.hasID(resourcePath, fragment)) {
2877
+ context.messages.push({
2878
+ id: "RSC-012",
2879
+ severity: "error",
2880
+ message: `Fragment identifier not found: #${fragment}`,
2881
+ location: reference.location
2882
+ });
2883
+ }
2884
+ }
2885
+ /**
2886
+ * Check for resources that were never referenced
2887
+ * Following Java EPUBCheck OPFChecker30.java logic:
2888
+ * - Skip items in spine (implicitly referenced)
2889
+ * - Skip nav and NCX resources
2890
+ * - Only check for "publication resource references" (not HYPERLINK)
2891
+ */
2892
+ checkUndeclaredResources(context) {
2893
+ const referencedResources = new Set(
2894
+ this.references.filter((ref) => {
2895
+ if (!isPublicationResourceReference(ref.type)) {
2896
+ return false;
2897
+ }
2898
+ const targetResource = ref.targetResource || parseURL(ref.url).resource;
2899
+ return this.registry.hasResource(targetResource);
2900
+ }).map((ref) => ref.targetResource || parseURL(ref.url).resource)
2901
+ );
2902
+ for (const resource of this.registry.getAllResources()) {
2903
+ if (resource.inSpine) continue;
2904
+ if (referencedResources.has(resource.url)) continue;
2905
+ if (resource.url.includes("nav")) continue;
2906
+ if (resource.url.includes("toc.ncx") || resource.url.includes(".ncx")) continue;
2907
+ if (resource.url.includes("cover-image")) continue;
2908
+ context.messages.push({
2909
+ id: "OPF-097",
2910
+ severity: "usage",
2911
+ message: `Resource declared in manifest but not referenced: ${resource.url}`,
2912
+ location: { path: resource.url }
2913
+ });
2914
+ }
2915
+ }
2916
+ /**
2917
+ * Check if a MIME type is a blessed content document type
2918
+ */
2919
+ isBlessedItemType(mimeType, version) {
2920
+ if (version === "2.0") {
2921
+ return mimeType === "application/xhtml+xml" || mimeType === "application/x-dtbook+xml";
2922
+ } else {
2923
+ return mimeType === "application/xhtml+xml" || mimeType === "image/svg+xml";
2924
+ }
2925
+ }
2926
+ /**
2927
+ * Check if a MIME type is a deprecated blessed content document type
2928
+ */
2929
+ isDeprecatedBlessedItemType(mimeType) {
2930
+ return mimeType === "text/x-oeb1-document" || mimeType === "text/html";
2931
+ }
2932
+ };
1259
2933
  var COMPRESSED_SCHEMAS = {
1260
2934
  "applications.rng": "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",
1261
2935
  "aria.rng": "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",
@@ -1615,11 +3289,17 @@ var EpubCheck = class _EpubCheck {
1615
3289
  }
1616
3290
  const opfValidator = new OPFValidator();
1617
3291
  opfValidator.validate(context);
3292
+ const registry = new ResourceRegistry();
3293
+ const refValidator = new ReferenceValidator(registry, context.version);
3294
+ if (context.packageDocument) {
3295
+ this.populateRegistry(context, registry);
3296
+ }
1618
3297
  const contentValidator = new ContentValidator();
1619
- contentValidator.validate(context);
3298
+ contentValidator.validate(context, registry, refValidator);
1620
3299
  if (context.version === "2.0" && context.packageDocument) {
1621
3300
  this.validateNCX(context);
1622
3301
  }
3302
+ refValidator.validate(context);
1623
3303
  const schemaValidator = new SchemaValidator(context);
1624
3304
  await schemaValidator.validate();
1625
3305
  } catch (error) {
@@ -1630,7 +3310,16 @@ var EpubCheck = class _EpubCheck {
1630
3310
  });
1631
3311
  }
1632
3312
  const elapsedMs = performance.now() - startTime;
1633
- return buildReport(context.messages, context.version, elapsedMs);
3313
+ const filteredMessages = context.messages.filter((msg) => {
3314
+ if (!this.options.includeUsage && msg.severity === "usage") {
3315
+ return false;
3316
+ }
3317
+ if (!this.options.includeInfo && msg.severity === "info") {
3318
+ return false;
3319
+ }
3320
+ return true;
3321
+ });
3322
+ return buildReport(filteredMessages, context.version, elapsedMs);
1634
3323
  }
1635
3324
  /**
1636
3325
  * Static method to validate an EPUB file with default options
@@ -1674,20 +3363,20 @@ var EpubCheck = class _EpubCheck {
1674
3363
  const ncxContent = new TextDecoder().decode(ncxData);
1675
3364
  const ncxValidator = new NCXValidator();
1676
3365
  ncxValidator.validate(context, ncxContent, ncxPath);
1677
- if (context.ncxUid) {
1678
- const dcIdentifiers = context.packageDocument.dcElements.filter(
1679
- (dc) => dc.name === "identifier"
3366
+ if (context.ncxUid && context.packageDocument.uniqueIdentifier) {
3367
+ const uniqueIdRef = context.packageDocument.uniqueIdentifier;
3368
+ const matchingIdentifier = context.packageDocument.dcElements.find(
3369
+ (dc) => dc.name === "identifier" && dc.id === uniqueIdRef
1680
3370
  );
1681
- for (const dc of dcIdentifiers) {
1682
- const opfUid = dc.value.trim();
3371
+ if (matchingIdentifier) {
3372
+ const opfUid = matchingIdentifier.value.trim();
1683
3373
  if (context.ncxUid !== opfUid) {
1684
3374
  context.messages.push({
1685
3375
  id: "NCX-001",
1686
- severity: "warning",
3376
+ severity: "error",
1687
3377
  message: `NCX uid "${context.ncxUid}" does not match OPF unique identifier "${opfUid}"`,
1688
3378
  location: { path: ncxPath }
1689
3379
  });
1690
- break;
1691
3380
  }
1692
3381
  }
1693
3382
  }
@@ -1707,6 +3396,25 @@ var EpubCheck = class _EpubCheck {
1707
3396
  }
1708
3397
  messages.push(message);
1709
3398
  }
3399
+ /**
3400
+ * Populate resource registry from package document manifest
3401
+ */
3402
+ populateRegistry(context, registry) {
3403
+ const packageDoc = context.packageDocument;
3404
+ if (!packageDoc) return;
3405
+ const opfPath = context.opfPath ?? "";
3406
+ const opfDir = opfPath.includes("/") ? opfPath.substring(0, opfPath.lastIndexOf("/")) : "";
3407
+ const spineIdrefs = new Set(packageDoc.spine.map((item) => item.idref));
3408
+ for (const item of packageDoc.manifest) {
3409
+ const fullPath = opfDir ? `${opfDir}/${item.href}` : item.href;
3410
+ registry.registerResource({
3411
+ url: fullPath,
3412
+ mimeType: item.mediaType,
3413
+ inSpine: spineIdrefs.has(item.id),
3414
+ ids: /* @__PURE__ */ new Set()
3415
+ });
3416
+ }
3417
+ }
1710
3418
  };
1711
3419
 
1712
3420
  // src/messages/message-id.ts