@likecoin/epubcheck-ts 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +8 -8
  2. package/bin/epubcheck.js +4 -4
  3. package/bin/epubcheck.ts +4 -4
  4. package/dist/index.cjs +1057 -101
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +3 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.js +1057 -101
  9. package/dist/index.js.map +1 -1
  10. package/package.json +4 -4
  11. package/schemas/applications.rng +0 -429
  12. package/schemas/aria.rng +0 -3355
  13. package/schemas/block.rng +0 -488
  14. package/schemas/common.rng +0 -1076
  15. package/schemas/container.rng +0 -24
  16. package/schemas/core-scripting.rng +0 -950
  17. package/schemas/data.rng +0 -161
  18. package/schemas/datatypes.rng +0 -401
  19. package/schemas/embed.rng +0 -980
  20. package/schemas/epub-mathml3-inc.rng +0 -161
  21. package/schemas/epub-nav-30.rnc +0 -44
  22. package/schemas/epub-nav-30.rng +0 -19985
  23. package/schemas/epub-nav-30.sch +0 -87
  24. package/schemas/epub-prefix-attr.rng +0 -17
  25. package/schemas/epub-shared-inc.rng +0 -29
  26. package/schemas/epub-ssml-attrs.rng +0 -17
  27. package/schemas/epub-svg-30.rnc +0 -17
  28. package/schemas/epub-svg-30.rng +0 -19903
  29. package/schemas/epub-svg-30.sch +0 -7
  30. package/schemas/epub-svg-forgiving-inc.rng +0 -315
  31. package/schemas/epub-switch.rng +0 -121
  32. package/schemas/epub-trigger.rng +0 -90
  33. package/schemas/epub-type-attr.rng +0 -12
  34. package/schemas/epub-xhtml-30.rnc +0 -6
  35. package/schemas/epub-xhtml-30.rng +0 -19882
  36. package/schemas/epub-xhtml-30.sch +0 -409
  37. package/schemas/epub-xhtml-inc.rng +0 -151
  38. package/schemas/epub-xhtml-integration.rng +0 -565
  39. package/schemas/epub-xhtml-svg-mathml.rng +0 -17
  40. package/schemas/form-datatypes.rng +0 -54
  41. package/schemas/mathml3-common.rng +0 -336
  42. package/schemas/mathml3-content.rng +0 -1552
  43. package/schemas/mathml3-inc.rng +0 -30
  44. package/schemas/mathml3-presentation.rng +0 -2341
  45. package/schemas/mathml3-strict-content.rng +0 -205
  46. package/schemas/media.rng +0 -374
  47. package/schemas/meta.rng +0 -754
  48. package/schemas/microdata.rng +0 -192
  49. package/schemas/ncx.rng +0 -308
  50. package/schemas/ocf-container-30.rnc +0 -37
  51. package/schemas/ocf-container-30.rng +0 -568
  52. package/schemas/opf.rng +0 -15
  53. package/schemas/opf20.rng +0 -513
  54. package/schemas/package-30.rnc +0 -133
  55. package/schemas/package-30.rng +0 -1153
  56. package/schemas/package-30.sch +0 -444
  57. package/schemas/phrase.rng +0 -746
  58. package/schemas/rdfa.rng +0 -552
  59. package/schemas/revision.rng +0 -106
  60. package/schemas/ruby.rng +0 -141
  61. package/schemas/sectional.rng +0 -278
  62. package/schemas/structural.rng +0 -298
  63. package/schemas/tables.rng +0 -420
  64. package/schemas/web-components.rng +0 -184
  65. package/schemas/web-forms.rng +0 -975
  66. package/schemas/web-forms2.rng +0 -1236
package/dist/index.js CHANGED
@@ -1,7 +1,473 @@
1
1
  import { XmlDocument } from 'libxml2-wasm';
2
+ import { parse, walk } from 'css-tree';
2
3
  import { unzipSync, strFromU8, gunzipSync } from 'fflate';
3
4
 
4
5
  // src/content/validator.ts
6
+ var BLESSED_FONT_TYPES = /* @__PURE__ */ new Set([
7
+ "application/font-woff",
8
+ "application/font-woff2",
9
+ "font/woff",
10
+ "font/woff2",
11
+ "font/otf",
12
+ "font/ttf",
13
+ "application/vnd.ms-opentype",
14
+ "application/font-sfnt",
15
+ "application/x-font-ttf",
16
+ "application/x-font-opentype",
17
+ "application/x-font-truetype"
18
+ ]);
19
+ var FONT_EXTENSION_TO_TYPE = {
20
+ ".woff": "font/woff",
21
+ ".woff2": "font/woff2",
22
+ ".otf": "font/otf",
23
+ ".ttf": "font/ttf"
24
+ };
25
+ var CSSValidator = class {
26
+ /**
27
+ * Validate CSS content and extract references
28
+ */
29
+ validate(context, css, resourcePath) {
30
+ const result = {
31
+ references: [],
32
+ fontFamilies: []
33
+ };
34
+ let ast;
35
+ try {
36
+ ast = parse(css, {
37
+ positions: true,
38
+ onParseError: (error) => {
39
+ const err = error;
40
+ const location = {
41
+ path: resourcePath
42
+ };
43
+ if (err.line !== void 0) location.line = err.line;
44
+ if (err.column !== void 0) location.column = err.column;
45
+ context.messages.push({
46
+ id: "CSS-008",
47
+ severity: "error",
48
+ message: `CSS parse error: ${error.formattedMessage}`,
49
+ location
50
+ });
51
+ }
52
+ });
53
+ } catch (error) {
54
+ context.messages.push({
55
+ id: "CSS-008",
56
+ severity: "error",
57
+ message: `CSS parse error: ${error instanceof Error ? error.message : "Unknown error"}`,
58
+ location: { path: resourcePath }
59
+ });
60
+ return result;
61
+ }
62
+ this.checkDiscouragedProperties(context, ast, resourcePath);
63
+ this.checkAtRules(context, ast, resourcePath, result);
64
+ this.checkMediaOverlayClasses(context, ast, resourcePath);
65
+ this.extractUrlReferences(context, ast, resourcePath, result);
66
+ return result;
67
+ }
68
+ extractUrlReferences(context, ast, resourcePath, result) {
69
+ walk(ast, (node) => {
70
+ if (node.type === "Atrule") {
71
+ const atRule = node;
72
+ if (atRule.name === "font-face") {
73
+ return;
74
+ }
75
+ if (atRule.block) {
76
+ walk(atRule.block, (blockNode) => {
77
+ if (blockNode.type === "Declaration") {
78
+ this.processDeclarationForUrl(blockNode, resourcePath, result);
79
+ }
80
+ });
81
+ }
82
+ } else if (node.type === "Rule") {
83
+ const rule = node;
84
+ walk(rule.block, (blockNode) => {
85
+ if (blockNode.type === "Declaration") {
86
+ this.processDeclarationForUrl(blockNode, resourcePath, result);
87
+ }
88
+ });
89
+ }
90
+ });
91
+ }
92
+ processDeclarationForUrl(declaration, resourcePath, result) {
93
+ const property = declaration.property.toLowerCase();
94
+ walk(declaration.value, (valueNode) => {
95
+ if (valueNode.type === "Url") {
96
+ const urlValue = this.extractUrlValue(valueNode);
97
+ if (urlValue && !urlValue.startsWith("data:")) {
98
+ const loc = valueNode.loc;
99
+ const start = loc?.start;
100
+ if (start) {
101
+ start.line;
102
+ start.column;
103
+ }
104
+ let refType = "resource";
105
+ if (property.includes("font")) {
106
+ refType = "font";
107
+ } else if (property.includes("background") || property.includes("list-style") || property.includes("content") || property.includes("border-image") || property.includes("mask")) {
108
+ refType = "image";
109
+ }
110
+ result.references.push({
111
+ url: urlValue,
112
+ type: refType,
113
+ line: start?.line,
114
+ column: start?.column
115
+ });
116
+ }
117
+ }
118
+ });
119
+ }
120
+ /**
121
+ * Check for forbidden and discouraged CSS properties in EPUB
122
+ */
123
+ checkDiscouragedProperties(context, ast, resourcePath) {
124
+ walk(ast, (node) => {
125
+ if (node.type === "Declaration") {
126
+ this.checkForbiddenProperties(context, node, resourcePath);
127
+ this.checkPositionProperty(context, node, resourcePath);
128
+ }
129
+ });
130
+ }
131
+ /**
132
+ * Check for forbidden CSS properties (direction, unicode-bidi)
133
+ * These properties must not be used in EPUB content per EPUB spec
134
+ */
135
+ checkForbiddenProperties(context, node, resourcePath) {
136
+ const property = node.property.toLowerCase();
137
+ const forbiddenProperties = ["direction", "unicode-bidi"];
138
+ if (!forbiddenProperties.includes(property)) return;
139
+ const loc = node.loc;
140
+ const start = loc?.start;
141
+ const location = { path: resourcePath };
142
+ if (start) {
143
+ location.line = start.line;
144
+ location.column = start.column;
145
+ }
146
+ context.messages.push({
147
+ id: "CSS-001",
148
+ severity: "error",
149
+ message: `CSS property "${property}" must not be included in an EPUB Style Sheet`,
150
+ location
151
+ });
152
+ }
153
+ /**
154
+ * Check position property for discouraged values
155
+ */
156
+ checkPositionProperty(context, node, resourcePath) {
157
+ const property = node.property.toLowerCase();
158
+ if (property !== "position") return;
159
+ const value = this.getDeclarationValue(node);
160
+ const loc = node.loc;
161
+ const start = loc?.start;
162
+ const location = { path: resourcePath };
163
+ if (start) {
164
+ location.line = start.line;
165
+ location.column = start.column;
166
+ }
167
+ if (value === "fixed") {
168
+ context.messages.push({
169
+ id: "CSS-006",
170
+ severity: "warning",
171
+ message: 'CSS property "position: fixed" is discouraged in EPUB',
172
+ location
173
+ });
174
+ }
175
+ if (value === "absolute") {
176
+ context.messages.push({
177
+ id: "CSS-019",
178
+ severity: "warning",
179
+ message: 'CSS property "position: absolute" should be used with caution in EPUB',
180
+ location
181
+ });
182
+ }
183
+ }
184
+ /**
185
+ * Extract the value from a Declaration node
186
+ */
187
+ getDeclarationValue(node) {
188
+ const value = node.value;
189
+ if (value.type === "Value") {
190
+ const first = value.children.first;
191
+ if (first?.type === "Identifier") {
192
+ return first.name.toLowerCase();
193
+ }
194
+ }
195
+ return "";
196
+ }
197
+ /**
198
+ * Check at-rules (@import, @font-face)
199
+ */
200
+ checkAtRules(context, ast, resourcePath, result) {
201
+ walk(ast, (node) => {
202
+ if (node.type === "Atrule") {
203
+ const atRule = node;
204
+ const ruleName = atRule.name.toLowerCase();
205
+ if (ruleName === "import") {
206
+ this.checkImport(context, atRule, resourcePath, result);
207
+ } else if (ruleName === "font-face") {
208
+ this.checkFontFace(context, atRule, resourcePath, result);
209
+ }
210
+ }
211
+ });
212
+ }
213
+ /**
214
+ * Check @import at-rule
215
+ */
216
+ checkImport(context, atRule, resourcePath, result) {
217
+ const loc = atRule.loc;
218
+ const start = loc?.start;
219
+ const location = { path: resourcePath };
220
+ if (start) {
221
+ location.line = start.line;
222
+ location.column = start.column;
223
+ }
224
+ if (!atRule.prelude) {
225
+ context.messages.push({
226
+ id: "CSS-002",
227
+ severity: "error",
228
+ message: "Empty @import rule",
229
+ location
230
+ });
231
+ return;
232
+ }
233
+ let importUrl = "";
234
+ walk(atRule.prelude, (node) => {
235
+ if (importUrl) return;
236
+ if (node.type === "Url") {
237
+ importUrl = this.extractUrlValue(node);
238
+ } else if (node.type === "String") {
239
+ importUrl = node.value;
240
+ }
241
+ });
242
+ if (!importUrl || importUrl.trim() === "") {
243
+ context.messages.push({
244
+ id: "CSS-002",
245
+ severity: "error",
246
+ message: "Empty or NULL reference found in @import",
247
+ location
248
+ });
249
+ return;
250
+ }
251
+ result.references.push({
252
+ url: importUrl,
253
+ type: "import",
254
+ line: start?.line,
255
+ column: start?.column
256
+ });
257
+ }
258
+ /**
259
+ * Check @font-face at-rule
260
+ */
261
+ checkFontFace(context, atRule, resourcePath, result) {
262
+ const loc = atRule.loc;
263
+ const start = loc?.start;
264
+ const location = { path: resourcePath };
265
+ if (start) {
266
+ location.line = start.line;
267
+ location.column = start.column;
268
+ }
269
+ if (context.options.includeUsage) {
270
+ context.messages.push({
271
+ id: "CSS-028",
272
+ severity: "usage",
273
+ message: "Use of @font-face declaration",
274
+ location
275
+ });
276
+ }
277
+ if (!atRule.block || atRule.block.children.isEmpty) {
278
+ context.messages.push({
279
+ id: "CSS-019",
280
+ severity: "warning",
281
+ message: "@font-face declaration has no attributes",
282
+ location
283
+ });
284
+ return;
285
+ }
286
+ const state = { hasSrc: false, fontFamily: null };
287
+ walk(atRule.block, (node) => {
288
+ if (node.type === "Declaration") {
289
+ const propName = node.property.toLowerCase();
290
+ if (propName === "font-family") {
291
+ state.fontFamily = this.extractFontFamily(node);
292
+ } else if (propName === "src") {
293
+ state.hasSrc = true;
294
+ this.checkFontFaceSrc(context, node, resourcePath, result);
295
+ }
296
+ }
297
+ });
298
+ if (state.fontFamily) {
299
+ result.fontFamilies.push(state.fontFamily);
300
+ }
301
+ if (!state.hasSrc) {
302
+ context.messages.push({
303
+ id: "CSS-019",
304
+ severity: "warning",
305
+ message: "@font-face declaration is missing src property",
306
+ location
307
+ });
308
+ }
309
+ }
310
+ /**
311
+ * Check src property in @font-face
312
+ */
313
+ checkFontFaceSrc(context, decl, resourcePath, result) {
314
+ const loc = decl.loc;
315
+ const start = loc?.start;
316
+ const location = { path: resourcePath };
317
+ if (start) {
318
+ location.line = start.line;
319
+ location.column = start.column;
320
+ }
321
+ walk(decl.value, (node) => {
322
+ if (node.type === "Url") {
323
+ const urlNode = node;
324
+ const urlValue = this.extractUrlValue(urlNode);
325
+ if (!urlValue || urlValue.trim() === "") {
326
+ context.messages.push({
327
+ id: "CSS-002",
328
+ severity: "error",
329
+ message: "Empty or NULL reference found in @font-face src",
330
+ location
331
+ });
332
+ return;
333
+ }
334
+ if (urlValue.startsWith("data:") || urlValue.startsWith("#")) {
335
+ return;
336
+ }
337
+ result.references.push({
338
+ url: urlValue,
339
+ type: "font",
340
+ line: start?.line,
341
+ column: start?.column
342
+ });
343
+ this.checkFontType(context, urlValue, resourcePath, location);
344
+ }
345
+ });
346
+ }
347
+ /**
348
+ * Check if font type is a blessed EPUB font type
349
+ */
350
+ checkFontType(context, fontUrl, resourcePath, location) {
351
+ const urlPath = fontUrl.split("?")[0] ?? fontUrl;
352
+ const extMatch = /\.[a-zA-Z0-9]+$/.exec(urlPath);
353
+ if (!extMatch) return;
354
+ const ext = extMatch[0].toLowerCase();
355
+ const mimeType = FONT_EXTENSION_TO_TYPE[ext];
356
+ if (mimeType && !BLESSED_FONT_TYPES.has(mimeType)) {
357
+ context.messages.push({
358
+ id: "CSS-007",
359
+ severity: "error",
360
+ message: `Font-face reference "${fontUrl}" refers to non-standard font type "${mimeType}"`,
361
+ location
362
+ });
363
+ }
364
+ const packageDoc = context.packageDocument;
365
+ if (packageDoc) {
366
+ const cssDir = resourcePath.includes("/") ? resourcePath.substring(0, resourcePath.lastIndexOf("/")) : "";
367
+ const resolvedPath = this.resolvePath(cssDir, fontUrl);
368
+ const manifestItem = packageDoc.manifest.find((item) => item.href === resolvedPath);
369
+ if (manifestItem && !BLESSED_FONT_TYPES.has(manifestItem.mediaType)) {
370
+ context.messages.push({
371
+ id: "CSS-007",
372
+ severity: "error",
373
+ message: `Font-face reference "${fontUrl}" has non-standard media type "${manifestItem.mediaType}" in manifest`,
374
+ location
375
+ });
376
+ }
377
+ }
378
+ }
379
+ /**
380
+ * Extract URL value from Url node
381
+ */
382
+ extractUrlValue(urlNode) {
383
+ const value = urlNode.value;
384
+ if (typeof value === "string") {
385
+ return value;
386
+ }
387
+ return "";
388
+ }
389
+ /**
390
+ * Extract font-family value from declaration
391
+ */
392
+ extractFontFamily(decl) {
393
+ const value = decl.value;
394
+ if (value.type === "Value") {
395
+ const first = value.children.first;
396
+ if (first?.type === "String") {
397
+ return first.value;
398
+ }
399
+ if (first?.type === "Identifier") {
400
+ return first.name;
401
+ }
402
+ }
403
+ return null;
404
+ }
405
+ /**
406
+ * Resolve a relative path from a base path
407
+ */
408
+ resolvePath(basePath, relativePath) {
409
+ if (relativePath.startsWith("/")) {
410
+ return relativePath.substring(1);
411
+ }
412
+ const baseSegments = basePath.split("/").filter(Boolean);
413
+ const relativeSegments = relativePath.split("/");
414
+ const resultSegments = [...baseSegments];
415
+ for (const segment of relativeSegments) {
416
+ if (segment === "..") {
417
+ resultSegments.pop();
418
+ } else if (segment !== "." && segment !== "") {
419
+ resultSegments.push(segment);
420
+ }
421
+ }
422
+ return resultSegments.join("/");
423
+ }
424
+ /**
425
+ * Check for reserved media overlay class names
426
+ */
427
+ checkMediaOverlayClasses(context, ast, resourcePath) {
428
+ const reservedClassNames = /* @__PURE__ */ new Set([
429
+ "-epub-media-overlay-active",
430
+ "media-overlay-active",
431
+ "-epub-media-overlay-playing",
432
+ "media-overlay-playing"
433
+ ]);
434
+ walk(ast, (node) => {
435
+ if (node.type === "ClassSelector") {
436
+ const className = node.name.toLowerCase();
437
+ if (reservedClassNames.has(className)) {
438
+ const loc = node.loc;
439
+ const start = loc?.start;
440
+ const location = { path: resourcePath };
441
+ if (start) {
442
+ location.line = start.line;
443
+ location.column = start.column;
444
+ }
445
+ context.messages.push({
446
+ id: "CSS-029",
447
+ severity: "error",
448
+ message: `Class name "${className}" is reserved for media overlays`,
449
+ location
450
+ });
451
+ }
452
+ if (className.startsWith("-epub-media-overlay-")) {
453
+ const loc = node.loc;
454
+ const start = loc?.start;
455
+ const location = { path: resourcePath };
456
+ if (start) {
457
+ location.line = start.line;
458
+ location.column = start.column;
459
+ }
460
+ context.messages.push({
461
+ id: "CSS-030",
462
+ severity: "warning",
463
+ message: `Class names starting with "-epub-media-overlay-" are reserved for future use`,
464
+ location
465
+ });
466
+ }
467
+ }
468
+ });
469
+ }
470
+ };
5
471
 
6
472
  // src/references/types.ts
7
473
  function isPublicationResourceReference(type) {
@@ -19,6 +485,104 @@ function isPublicationResourceReference(type) {
19
485
 
20
486
  // src/content/validator.ts
21
487
  var DISCOURAGED_ELEMENTS = /* @__PURE__ */ new Set(["base", "embed"]);
488
+ var HTML_ENTITIES = /* @__PURE__ */ new Set([
489
+ "nbsp",
490
+ "iexcl",
491
+ "cent",
492
+ "pound",
493
+ "curren",
494
+ "yen",
495
+ "brvbar",
496
+ "sect",
497
+ "uml",
498
+ "copy",
499
+ "ordf",
500
+ "laquo",
501
+ "not",
502
+ "shy",
503
+ "reg",
504
+ "macr",
505
+ "deg",
506
+ "plusmn",
507
+ "sup2",
508
+ "sup3",
509
+ "acute",
510
+ "micro",
511
+ "para",
512
+ "middot",
513
+ "cedil",
514
+ "sup1",
515
+ "ordm",
516
+ "raquo",
517
+ "frac14",
518
+ "frac12",
519
+ "frac34",
520
+ "iquest",
521
+ "Agrave",
522
+ "Aacute",
523
+ "Acirc",
524
+ "Atilde",
525
+ "Auml",
526
+ "Aring",
527
+ "AElig",
528
+ "Ccedil",
529
+ "Egrave",
530
+ "Eacute",
531
+ "Ecirc",
532
+ "Euml",
533
+ "Igrave",
534
+ "Iacute",
535
+ "Icirc",
536
+ "Iuml",
537
+ "ETH",
538
+ "Ntilde",
539
+ "Ograve",
540
+ "Oacute",
541
+ "Ocirc",
542
+ "Otilde",
543
+ "Ouml",
544
+ "times",
545
+ "Oslash",
546
+ "Ugrave",
547
+ "Uacute",
548
+ "Ucirc",
549
+ "Uuml",
550
+ "Yacute",
551
+ "THORN",
552
+ "szlig",
553
+ "agrave",
554
+ "aacute",
555
+ "acirc",
556
+ "atilde",
557
+ "auml",
558
+ "aring",
559
+ "aelig",
560
+ "ccedil",
561
+ "egrave",
562
+ "eacute",
563
+ "ecirc",
564
+ "euml",
565
+ "igrave",
566
+ "iacute",
567
+ "icirc",
568
+ "iuml",
569
+ "eth",
570
+ "ntilde",
571
+ "ograve",
572
+ "oacute",
573
+ "ocirc",
574
+ "otilde",
575
+ "ouml",
576
+ "divide",
577
+ "oslash",
578
+ "ugrave",
579
+ "uacute",
580
+ "ucirc",
581
+ "uuml",
582
+ "yacute",
583
+ "thorn",
584
+ "yuml"
585
+ ]);
22
586
  var ContentValidator = class {
23
587
  validate(context, registry, refValidator) {
24
588
  const packageDoc = context.packageDocument;
@@ -43,6 +607,32 @@ var ContentValidator = class {
43
607
  return;
44
608
  }
45
609
  const cssContent = new TextDecoder().decode(cssData);
610
+ const cssValidator = new CSSValidator();
611
+ const result = cssValidator.validate(context, cssContent, path);
612
+ const cssDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
613
+ for (const ref of result.references) {
614
+ if (ref.type === "font") {
615
+ const resolvedPath = this.resolveRelativePath(cssDir, ref.url, opfDir);
616
+ const hashIndex = resolvedPath.indexOf("#");
617
+ const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
618
+ refValidator.addReference({
619
+ url: ref.url,
620
+ targetResource,
621
+ type: "font" /* FONT */,
622
+ location: { path }
623
+ });
624
+ } else if (ref.type === "image") {
625
+ const resolvedPath = this.resolveRelativePath(cssDir, ref.url, opfDir);
626
+ const hashIndex = resolvedPath.indexOf("#");
627
+ const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
628
+ refValidator.addReference({
629
+ url: ref.url,
630
+ targetResource,
631
+ type: "image" /* IMAGE */,
632
+ location: { path }
633
+ });
634
+ }
635
+ }
46
636
  this.extractCSSImports(path, cssContent, opfDir, refValidator);
47
637
  }
48
638
  validateXHTMLDocument(context, path, itemId, opfDir, registry, refValidator) {
@@ -62,19 +652,26 @@ var ContentValidator = class {
62
652
  } catch (error) {
63
653
  if (error instanceof Error) {
64
654
  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;
655
+ const entityPattern = /Entity '(\w+)' not defined/;
656
+ const entityExec = entityPattern.exec(error.message);
657
+ const entityName = entityExec?.[1];
658
+ const isKnownHtmlEntity = entityName !== void 0 && HTML_ENTITIES.has(entityName);
659
+ const isEpub2 = context.version === "2.0";
660
+ if (!isEpub2 || !isKnownHtmlEntity) {
661
+ const location = { path };
662
+ if (line !== void 0) {
663
+ location.line = line;
664
+ }
665
+ if (column !== void 0) {
666
+ location.column = column;
667
+ }
668
+ context.messages.push({
669
+ id: "HTM-004",
670
+ severity: "error",
671
+ message,
672
+ location
673
+ });
71
674
  }
72
- context.messages.push({
73
- id: "HTM-004",
74
- severity: "error",
75
- message,
76
- location
77
- });
78
675
  }
79
676
  return;
80
677
  }
@@ -177,6 +774,7 @@ var ContentValidator = class {
177
774
  this.extractAndRegisterHyperlinks(path, root, opfDir, refValidator);
178
775
  this.extractAndRegisterStylesheets(path, root, opfDir, refValidator);
179
776
  this.extractAndRegisterImages(path, root, opfDir, refValidator);
777
+ this.extractAndRegisterCiteAttributes(path, root, opfDir, refValidator);
180
778
  }
181
779
  } finally {
182
780
  doc.dispose();
@@ -273,10 +871,18 @@ var ContentValidator = class {
273
871
  }
274
872
  }
275
873
  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;
874
+ const htmlScripts = root.find(".//html:script", { html: "http://www.w3.org/1999/xhtml" });
875
+ for (const script of htmlScripts) {
876
+ if (this.isScriptType(this.getAttribute(script, "type"))) {
877
+ return true;
878
+ }
879
+ }
880
+ const svgScripts = root.find(".//svg:script", { svg: "http://www.w3.org/2000/svg" });
881
+ for (const script of svgScripts) {
882
+ if (this.isScriptType(this.getAttribute(script, "type"))) {
883
+ return true;
884
+ }
885
+ }
280
886
  const form = root.get(".//html:form", { html: "http://www.w3.org/1999/xhtml" });
281
887
  if (form) return true;
282
888
  const elementsWithEvents = root.find(
@@ -285,6 +891,35 @@ var ContentValidator = class {
285
891
  if (elementsWithEvents.length > 0) return true;
286
892
  return false;
287
893
  }
894
+ /**
895
+ * Check if the script type is a JavaScript type that requires "scripted" property.
896
+ * Per EPUB spec and Java EPUBCheck, only JavaScript types require it.
897
+ * Data block types like application/ld+json, application/json do NOT require it.
898
+ */
899
+ isScriptType(type) {
900
+ if (!type || type.trim() === "") return true;
901
+ const jsTypes = /* @__PURE__ */ new Set([
902
+ "application/javascript",
903
+ "text/javascript",
904
+ "application/ecmascript",
905
+ "application/x-ecmascript",
906
+ "application/x-javascript",
907
+ "text/ecmascript",
908
+ "text/javascript1.0",
909
+ "text/javascript1.1",
910
+ "text/javascript1.2",
911
+ "text/javascript1.3",
912
+ "text/javascript1.4",
913
+ "text/javascript1.5",
914
+ "text/jscript",
915
+ "text/livescript",
916
+ "text/x-ecmascript",
917
+ "text/x-javascript",
918
+ "module"
919
+ // ES modules
920
+ ]);
921
+ return jsTypes.has(type.toLowerCase());
922
+ }
288
923
  detectMathML(_context, _path, root) {
289
924
  const mathMLElements = root.find(".//math:*", { math: "http://www.w3.org/1998/Math/MathML" });
290
925
  return mathMLElements.length > 0;
@@ -859,6 +1494,53 @@ var ContentValidator = class {
859
1494
  });
860
1495
  }
861
1496
  }
1497
+ /**
1498
+ * Extract cite attribute references from blockquote, q, ins, del elements
1499
+ * These need to be validated as RSC-007 if the referenced resource is missing
1500
+ */
1501
+ extractAndRegisterCiteAttributes(path, root, opfDir, refValidator) {
1502
+ const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
1503
+ const citeElements = [
1504
+ ...root.find(".//html:blockquote[@cite]", { html: "http://www.w3.org/1999/xhtml" }),
1505
+ ...root.find(".//html:q[@cite]", { html: "http://www.w3.org/1999/xhtml" }),
1506
+ ...root.find(".//html:ins[@cite]", { html: "http://www.w3.org/1999/xhtml" }),
1507
+ ...root.find(".//html:del[@cite]", { html: "http://www.w3.org/1999/xhtml" })
1508
+ ];
1509
+ for (const elem of citeElements) {
1510
+ const cite = this.getAttribute(elem, "cite");
1511
+ if (!cite) continue;
1512
+ const line = elem.line;
1513
+ if (cite.startsWith("http://") || cite.startsWith("https://")) {
1514
+ continue;
1515
+ }
1516
+ if (cite.startsWith("#")) {
1517
+ const targetResource2 = path;
1518
+ const fragment2 = cite.slice(1);
1519
+ refValidator.addReference({
1520
+ url: cite,
1521
+ targetResource: targetResource2,
1522
+ fragment: fragment2,
1523
+ type: "hyperlink" /* HYPERLINK */,
1524
+ location: { path, line }
1525
+ });
1526
+ continue;
1527
+ }
1528
+ const resolvedPath = this.resolveRelativePath(docDir, cite, opfDir);
1529
+ const hashIndex = resolvedPath.indexOf("#");
1530
+ const targetResource = hashIndex >= 0 ? resolvedPath.slice(0, hashIndex) : resolvedPath;
1531
+ const fragment = hashIndex >= 0 ? resolvedPath.slice(hashIndex + 1) : void 0;
1532
+ const ref = {
1533
+ url: cite,
1534
+ targetResource,
1535
+ type: "hyperlink" /* HYPERLINK */,
1536
+ location: { path, line }
1537
+ };
1538
+ if (fragment) {
1539
+ ref.fragment = fragment;
1540
+ }
1541
+ refValidator.addReference(ref);
1542
+ }
1543
+ }
862
1544
  resolveRelativePath(docDir, href, _opfDir) {
863
1545
  const hrefWithoutFragment = href.split("#")[0] ?? href;
864
1546
  const fragment = href.includes("#") ? href.split("#")[1] : "";
@@ -1177,6 +1859,93 @@ var ZipReader = class _ZipReader {
1177
1859
  const prefix = dirPath.endsWith("/") ? dirPath : `${dirPath}/`;
1178
1860
  return this._paths.filter((p) => p.startsWith(prefix));
1179
1861
  }
1862
+ /**
1863
+ * Check for filenames that are not valid UTF-8 by parsing raw ZIP data
1864
+ *
1865
+ * ZIP files store filenames as bytes. The EPUB spec requires filenames to be UTF-8.
1866
+ * This method parses the ZIP central directory to find filenames with invalid UTF-8.
1867
+ *
1868
+ * @returns Array of filenames with invalid UTF-8 encoding
1869
+ */
1870
+ getInvalidUtf8Filenames() {
1871
+ const invalid = [];
1872
+ const data = this._rawData;
1873
+ let eocdOffset = -1;
1874
+ for (let i = data.length - 22; i >= 0; i--) {
1875
+ if (data[i] === 80 && data[i + 1] === 75 && data[i + 2] === 5 && data[i + 3] === 6) {
1876
+ eocdOffset = i;
1877
+ break;
1878
+ }
1879
+ }
1880
+ if (eocdOffset === -1) {
1881
+ return invalid;
1882
+ }
1883
+ const cdOffset = (data[eocdOffset + 16] ?? 0) | (data[eocdOffset + 17] ?? 0) << 8 | (data[eocdOffset + 18] ?? 0) << 16 | (data[eocdOffset + 19] ?? 0) << 24;
1884
+ let offset = cdOffset;
1885
+ while (offset < eocdOffset) {
1886
+ if (data[offset] !== 80 || data[offset + 1] !== 75 || data[offset + 2] !== 1 || data[offset + 3] !== 2) {
1887
+ break;
1888
+ }
1889
+ const filenameLength = (data[offset + 28] ?? 0) | (data[offset + 29] ?? 0) << 8;
1890
+ const extraLength = (data[offset + 30] ?? 0) | (data[offset + 31] ?? 0) << 8;
1891
+ const commentLength = (data[offset + 32] ?? 0) | (data[offset + 33] ?? 0) << 8;
1892
+ const filenameBytes = data.slice(offset + 46, offset + 46 + filenameLength);
1893
+ const utf8Error = this.validateUtf8(filenameBytes);
1894
+ if (utf8Error) {
1895
+ const filename = strFromU8(filenameBytes);
1896
+ invalid.push({ filename, reason: utf8Error });
1897
+ }
1898
+ offset += 46 + filenameLength + extraLength + commentLength;
1899
+ }
1900
+ return invalid;
1901
+ }
1902
+ /**
1903
+ * Validate that bytes form a valid UTF-8 sequence
1904
+ *
1905
+ * @returns Error description if invalid, undefined if valid
1906
+ */
1907
+ validateUtf8(bytes) {
1908
+ let i = 0;
1909
+ while (i < bytes.length) {
1910
+ const byte = bytes[i] ?? 0;
1911
+ if (byte <= 127) {
1912
+ i++;
1913
+ } else if ((byte & 224) === 192) {
1914
+ if (byte < 194) {
1915
+ return `Overlong encoding at byte ${String(i)}`;
1916
+ }
1917
+ if (i + 1 >= bytes.length || ((bytes[i + 1] ?? 0) & 192) !== 128) {
1918
+ return `Invalid continuation byte at position ${String(i + 1)}`;
1919
+ }
1920
+ i += 2;
1921
+ } else if ((byte & 240) === 224) {
1922
+ if (i + 2 >= bytes.length || ((bytes[i + 1] ?? 0) & 192) !== 128 || ((bytes[i + 2] ?? 0) & 192) !== 128) {
1923
+ return `Invalid continuation byte in 3-byte sequence at position ${String(i)}`;
1924
+ }
1925
+ if (byte === 224 && (bytes[i + 1] ?? 0) < 160) {
1926
+ return `Overlong 3-byte encoding at byte ${String(i)}`;
1927
+ }
1928
+ if (byte === 237 && (bytes[i + 1] ?? 0) >= 160) {
1929
+ return `UTF-16 surrogate at byte ${String(i)}`;
1930
+ }
1931
+ i += 3;
1932
+ } else if ((byte & 248) === 240) {
1933
+ if (i + 3 >= bytes.length || ((bytes[i + 1] ?? 0) & 192) !== 128 || ((bytes[i + 2] ?? 0) & 192) !== 128 || ((bytes[i + 3] ?? 0) & 192) !== 128) {
1934
+ return `Invalid continuation byte in 4-byte sequence at position ${String(i)}`;
1935
+ }
1936
+ if (byte === 240 && (bytes[i + 1] ?? 0) < 144) {
1937
+ return `Overlong 4-byte encoding at byte ${String(i)}`;
1938
+ }
1939
+ if (byte > 244 || byte === 244 && (bytes[i + 1] ?? 0) > 143) {
1940
+ return `Code point exceeds U+10FFFF at byte ${String(i)}`;
1941
+ }
1942
+ i += 4;
1943
+ } else {
1944
+ return `Invalid UTF-8 start byte 0x${byte.toString(16).toUpperCase()} at position ${String(i)}`;
1945
+ }
1946
+ }
1947
+ return void 0;
1948
+ }
1180
1949
  };
1181
1950
 
1182
1951
  // src/ocf/validator.ts
@@ -1207,6 +1976,8 @@ var OCFValidator = class {
1207
1976
  this.validateContainer(zip, context);
1208
1977
  this.validateMetaInf(zip, context.messages);
1209
1978
  this.validateFilenames(zip, context.messages);
1979
+ this.validateDuplicateFilenames(zip, context.messages);
1980
+ this.validateUtf8Filenames(zip, context.messages);
1210
1981
  this.validateEmptyDirectories(zip, context.messages);
1211
1982
  }
1212
1983
  /**
@@ -1273,20 +2044,11 @@ var OCFValidator = class {
1273
2044
  });
1274
2045
  return;
1275
2046
  }
1276
- const trimmed = content.trim();
1277
- if (trimmed !== EPUB_MIMETYPE) {
2047
+ if (content !== EPUB_MIMETYPE) {
1278
2048
  messages.push({
1279
2049
  id: "PKG-007",
1280
2050
  severity: "error",
1281
- message: `Mimetype file must contain "${EPUB_MIMETYPE}", found "${trimmed}"`,
1282
- location: { path: "mimetype" }
1283
- });
1284
- }
1285
- if (content !== EPUB_MIMETYPE) {
1286
- messages.push({
1287
- id: "PKG-008",
1288
- severity: "warning",
1289
- message: "Mimetype file should not contain leading/trailing whitespace or newlines",
2051
+ message: `Mimetype file must contain exactly "${EPUB_MIMETYPE}"`,
1290
2052
  location: { path: "mimetype" }
1291
2053
  });
1292
2054
  }
@@ -1298,9 +2060,9 @@ var OCFValidator = class {
1298
2060
  const containerPath = "META-INF/container.xml";
1299
2061
  if (!zip.has(containerPath)) {
1300
2062
  context.messages.push({
1301
- id: "PKG-003",
2063
+ id: "RSC-002",
1302
2064
  severity: "fatal",
1303
- message: "Missing META-INF/container.xml",
2065
+ message: "Required file META-INF/container.xml was not found",
1304
2066
  location: { path: containerPath }
1305
2067
  });
1306
2068
  return;
@@ -1308,7 +2070,7 @@ var OCFValidator = class {
1308
2070
  const content = zip.readText(containerPath);
1309
2071
  if (!content) {
1310
2072
  context.messages.push({
1311
- id: "PKG-003",
2073
+ id: "RSC-002",
1312
2074
  severity: "fatal",
1313
2075
  message: "Could not read META-INF/container.xml",
1314
2076
  location: { path: containerPath }
@@ -1382,8 +2144,15 @@ var OCFValidator = class {
1382
2144
  }
1383
2145
  /**
1384
2146
  * Validate filenames for invalid characters
2147
+ *
2148
+ * Per EPUB 3.3 spec and Java EPUBCheck:
2149
+ * - PKG-009: Disallowed characters (ASCII special chars, control chars, private use, etc.)
2150
+ * - PKG-010: Whitespace characters (warning)
2151
+ * - PKG-011: Filename ends with period
2152
+ * - PKG-012: Non-ASCII characters (usage info)
1385
2153
  */
1386
2154
  validateFilenames(zip, messages) {
2155
+ const DISALLOWED_ASCII = /* @__PURE__ */ new Set([34, 42, 58, 60, 62, 63, 92, 124]);
1387
2156
  for (const path of zip.paths) {
1388
2157
  if (path === "mimetype") continue;
1389
2158
  if (path.endsWith("/")) continue;
@@ -1397,30 +2166,164 @@ var OCFValidator = class {
1397
2166
  });
1398
2167
  continue;
1399
2168
  }
2169
+ const disallowed = [];
2170
+ let hasSpaces = false;
1400
2171
  for (let i = 0; i < filename.length; i++) {
1401
2172
  const code = filename.charCodeAt(i);
1402
- if (code < 32 || code === 127 || code >= 128 && code <= 159) {
1403
- messages.push({
1404
- id: "PKG-010",
1405
- severity: "error",
1406
- message: `Filename contains control character: "${path}"`,
1407
- location: { path }
1408
- });
1409
- break;
2173
+ if (DISALLOWED_ASCII.has(code)) {
2174
+ const char = filename[i] ?? "";
2175
+ disallowed.push(`U+${code.toString(16).toUpperCase().padStart(4, "0")} (${char})`);
2176
+ } else if (code <= 31 || code === 127 || code >= 128 && code <= 159) {
2177
+ disallowed.push(`U+${code.toString(16).toUpperCase().padStart(4, "0")} (CONTROL)`);
2178
+ } else if (code >= 57344 && code <= 63743) {
2179
+ disallowed.push(`U+${code.toString(16).toUpperCase().padStart(4, "0")} (PRIVATE USE)`);
2180
+ } else if (code >= 65520 && code <= 65535) {
2181
+ disallowed.push(`U+${code.toString(16).toUpperCase().padStart(4, "0")} (SPECIALS)`);
1410
2182
  }
1411
- }
1412
- const specialChars = '<>:"|?*';
1413
- for (const char of specialChars) {
1414
- if (filename.includes(char)) {
1415
- messages.push({
1416
- id: "PKG-011",
1417
- severity: "error",
1418
- message: `Filename contains special character: "${path}"`,
1419
- location: { path }
1420
- });
1421
- break;
2183
+ if (code === 32 || code === 9 || code === 10 || code === 13) {
2184
+ hasSpaces = true;
1422
2185
  }
1423
2186
  }
2187
+ if (filename.endsWith(".")) {
2188
+ messages.push({
2189
+ id: "PKG-011",
2190
+ severity: "error",
2191
+ message: `Filename must not end with a period: "${path}"`,
2192
+ location: { path }
2193
+ });
2194
+ }
2195
+ if (disallowed.length > 0) {
2196
+ messages.push({
2197
+ id: "PKG-009",
2198
+ severity: "error",
2199
+ message: `Filename "${path}" contains disallowed characters: ${disallowed.join(", ")}`,
2200
+ location: { path }
2201
+ });
2202
+ }
2203
+ if (hasSpaces) {
2204
+ messages.push({
2205
+ id: "PKG-010",
2206
+ severity: "warning",
2207
+ message: `Filename "${path}" contains spaces`,
2208
+ location: { path }
2209
+ });
2210
+ }
2211
+ }
2212
+ }
2213
+ /**
2214
+ * Check for duplicate filenames after Unicode normalization and case folding
2215
+ *
2216
+ * Per EPUB spec, filenames must be unique after applying:
2217
+ * - Unicode Canonical Case Fold Normalization (NFD + case folding)
2218
+ *
2219
+ * OPF-060: Duplicate filename after normalization
2220
+ */
2221
+ validateDuplicateFilenames(zip, messages) {
2222
+ const seenPaths = /* @__PURE__ */ new Set();
2223
+ const normalizedPaths = /* @__PURE__ */ new Map();
2224
+ for (const path of zip.paths) {
2225
+ if (path.endsWith("/")) continue;
2226
+ if (seenPaths.has(path)) {
2227
+ messages.push({
2228
+ id: "OPF-060",
2229
+ severity: "error",
2230
+ message: `Duplicate ZIP entry: "${path}"`,
2231
+ location: { path }
2232
+ });
2233
+ continue;
2234
+ }
2235
+ seenPaths.add(path);
2236
+ const normalized = this.canonicalCaseFold(path);
2237
+ const existing = normalizedPaths.get(normalized);
2238
+ if (existing !== void 0) {
2239
+ messages.push({
2240
+ id: "OPF-060",
2241
+ severity: "error",
2242
+ message: `Duplicate filename after Unicode normalization: "${path}" conflicts with "${existing}"`,
2243
+ location: { path }
2244
+ });
2245
+ } else {
2246
+ normalizedPaths.set(normalized, path);
2247
+ }
2248
+ }
2249
+ }
2250
+ /**
2251
+ * Apply Unicode Canonical Case Fold Normalization
2252
+ *
2253
+ * This applies:
2254
+ * 1. NFD (Canonical Decomposition) - decomposes combined characters
2255
+ * 2. Full Unicode case folding
2256
+ *
2257
+ * Based on Unicode case folding rules for filename comparison.
2258
+ */
2259
+ canonicalCaseFold(str) {
2260
+ let result = str.normalize("NFD");
2261
+ result = this.unicodeCaseFold(result);
2262
+ return result;
2263
+ }
2264
+ /**
2265
+ * Perform Unicode full case folding
2266
+ *
2267
+ * Handles special Unicode case folding rules beyond simple toLowerCase:
2268
+ * - ß (U+00DF) -> ss
2269
+ * - ẞ (U+1E9E) -> ss (capital sharp s)
2270
+ * - fi (U+FB01) -> fi
2271
+ * - fl (U+FB02) -> fl
2272
+ * - ff (U+FB00) -> ff
2273
+ * - ffi (U+FB03) -> ffi
2274
+ * - ffl (U+FB04) -> ffl
2275
+ * - ſt (U+FB05) -> st
2276
+ * - st (U+FB06) -> st
2277
+ * And other Unicode case folding rules
2278
+ */
2279
+ unicodeCaseFold(str) {
2280
+ const caseFoldMap = {
2281
+ "\xDF": "ss",
2282
+ // ß -> ss
2283
+ "\u1E9E": "ss",
2284
+ // ẞ -> ss (capital sharp s)
2285
+ "\uFB00": "ff",
2286
+ // ff -> ff
2287
+ "\uFB01": "fi",
2288
+ // fi -> fi
2289
+ "\uFB02": "fl",
2290
+ // fl -> fl
2291
+ "\uFB03": "ffi",
2292
+ // ffi -> ffi
2293
+ "\uFB04": "ffl",
2294
+ // ffl -> ffl
2295
+ "\uFB05": "st",
2296
+ // ſt -> st
2297
+ "\uFB06": "st",
2298
+ // st -> st
2299
+ "\u0130": "i\u0307"
2300
+ // İ -> i + combining dot above
2301
+ };
2302
+ let result = "";
2303
+ for (const char of str) {
2304
+ const folded = caseFoldMap[char];
2305
+ if (folded !== void 0) {
2306
+ result += folded;
2307
+ } else {
2308
+ result += char.toLowerCase();
2309
+ }
2310
+ }
2311
+ return result;
2312
+ }
2313
+ /**
2314
+ * Validate that filenames are encoded as UTF-8
2315
+ *
2316
+ * PKG-027: Filenames in EPUB ZIP archives must be UTF-8 encoded
2317
+ */
2318
+ validateUtf8Filenames(zip, messages) {
2319
+ const invalidFilenames = zip.getInvalidUtf8Filenames();
2320
+ for (const { filename, reason } of invalidFilenames) {
2321
+ messages.push({
2322
+ id: "PKG-027",
2323
+ severity: "fatal",
2324
+ message: `Filename is not valid UTF-8: "${filename}" (${reason})`,
2325
+ location: { path: filename }
2326
+ });
1424
2327
  }
1425
2328
  }
1426
2329
  /**
@@ -1835,6 +2738,7 @@ var OPFValidator = class {
1835
2738
  context.packageDocument = this.packageDoc;
1836
2739
  this.validatePackageAttributes(context, opfPath);
1837
2740
  this.validateMetadata(context, opfPath);
2741
+ this.validateLinkElements(context, opfPath);
1838
2742
  this.validateManifest(context, opfPath);
1839
2743
  this.validateSpine(context, opfPath);
1840
2744
  this.validateFallbackChains(context, opfPath);
@@ -2086,6 +2990,34 @@ var OPFValidator = class {
2086
2990
  }
2087
2991
  }
2088
2992
  }
2993
+ /**
2994
+ * Validate EPUB 3 link elements in metadata
2995
+ */
2996
+ validateLinkElements(context, opfPath) {
2997
+ if (!this.packageDoc) return;
2998
+ const opfDir = opfPath.includes("/") ? opfPath.substring(0, opfPath.lastIndexOf("/")) : "";
2999
+ for (const link of this.packageDoc.linkElements) {
3000
+ const href = link.href;
3001
+ const decodedHref = tryDecodeUriComponent(href);
3002
+ const basePath = href.includes("#") ? href.substring(0, href.indexOf("#")) : href;
3003
+ const basePathDecoded = decodedHref.includes("#") ? decodedHref.substring(0, decodedHref.indexOf("#")) : decodedHref;
3004
+ if (href.startsWith("#")) {
3005
+ continue;
3006
+ }
3007
+ const resolvedPath = resolvePath(opfDir, basePath);
3008
+ const resolvedPathDecoded = basePathDecoded !== basePath ? resolvePath(opfDir, basePathDecoded) : resolvedPath;
3009
+ const fileExists = context.files.has(resolvedPath) || context.files.has(resolvedPathDecoded);
3010
+ const inManifest = this.manifestByHref.has(basePath) || this.manifestByHref.has(basePathDecoded);
3011
+ if (!fileExists && !inManifest) {
3012
+ context.messages.push({
3013
+ id: "RSC-007",
3014
+ severity: "warning",
3015
+ message: `Referenced resource "${resolvedPath}" could not be found in the EPUB`,
3016
+ location: { path: opfPath }
3017
+ });
3018
+ }
3019
+ }
3020
+ }
2089
3021
  /**
2090
3022
  * Validate manifest section
2091
3023
  */
@@ -2113,11 +3045,32 @@ var OPFValidator = class {
2113
3045
  }
2114
3046
  seenHrefs.add(item.href);
2115
3047
  const fullPath = resolvePath(opfPath, item.href);
2116
- if (!context.files.has(fullPath) && !item.href.startsWith("http")) {
3048
+ if (fullPath === opfPath) {
3049
+ context.messages.push({
3050
+ id: "OPF-099",
3051
+ severity: "error",
3052
+ message: "The manifest must not list the package document",
3053
+ location: { path: opfPath }
3054
+ });
3055
+ }
3056
+ if (!item.href.startsWith("http") && !item.href.startsWith("mailto:")) {
3057
+ const leaked = checkUrlLeaking(item.href);
3058
+ if (leaked) {
3059
+ context.messages.push({
3060
+ id: "RSC-026",
3061
+ severity: "error",
3062
+ message: `URL "${item.href}" leaks outside the container (it is not a valid-relative-ocf-URL-with-fragment string)`,
3063
+ location: { path: opfPath }
3064
+ });
3065
+ }
3066
+ }
3067
+ const decodedHref = tryDecodeUriComponent(item.href);
3068
+ const fullPathDecoded = decodedHref !== item.href ? resolvePath(opfPath, decodedHref) : fullPath;
3069
+ if (!context.files.has(fullPath) && !context.files.has(fullPathDecoded) && !item.href.startsWith("http")) {
2117
3070
  context.messages.push({
2118
- id: "OPF-010",
3071
+ id: "RSC-001",
2119
3072
  severity: "error",
2120
- message: `Manifest item "${item.id}" references missing file: ${item.href}`,
3073
+ message: `Referenced resource "${item.href}" could not be found in the EPUB`,
2121
3074
  location: { path: opfPath }
2122
3075
  });
2123
3076
  }
@@ -2204,31 +3157,6 @@ var OPFValidator = class {
2204
3157
  });
2205
3158
  }
2206
3159
  }
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.endsWith("/")) continue;
2222
- if (filePath.startsWith("META-INF/")) continue;
2223
- if (filePath === "mimetype") continue;
2224
- if (declaredPaths.has(filePath)) continue;
2225
- context.messages.push({
2226
- id: "RSC-008",
2227
- severity: "error",
2228
- message: `File in container is not declared in manifest: ${filePath}`,
2229
- location: { path: filePath }
2230
- });
2231
- }
2232
3160
  }
2233
3161
  /**
2234
3162
  * Validate spine section
@@ -2253,32 +3181,30 @@ var OPFValidator = class {
2253
3181
  location: { path: opfPath }
2254
3182
  });
2255
3183
  }
2256
- if (this.packageDoc.version === "2.0") {
2257
- const ncxId = this.packageDoc.spineToc;
2258
- if (!ncxId) {
3184
+ const ncxId = this.packageDoc.spineToc;
3185
+ if (this.packageDoc.version === "2.0" && !ncxId) {
3186
+ context.messages.push({
3187
+ id: "OPF-050",
3188
+ severity: "warning",
3189
+ message: "EPUB 2 spine should have a toc attribute referencing the NCX",
3190
+ location: { path: opfPath }
3191
+ });
3192
+ } else if (ncxId) {
3193
+ const ncxItem = this.manifestById.get(ncxId);
3194
+ if (!ncxItem) {
3195
+ context.messages.push({
3196
+ id: "OPF-049",
3197
+ severity: "error",
3198
+ message: `Spine toc attribute references non-existent item: "${ncxId}"`,
3199
+ location: { path: opfPath }
3200
+ });
3201
+ } else if (ncxItem.mediaType !== "application/x-dtbncx+xml") {
2259
3202
  context.messages.push({
2260
3203
  id: "OPF-050",
2261
- severity: "warning",
2262
- message: "EPUB 2 spine should have a toc attribute referencing the NCX",
3204
+ severity: "error",
3205
+ message: `Spine toc attribute must reference an NCX document (media-type "application/x-dtbncx+xml"), found: "${ncxItem.mediaType}"`,
2263
3206
  location: { path: opfPath }
2264
3207
  });
2265
- } else {
2266
- const ncxItem = this.manifestById.get(ncxId);
2267
- if (!ncxItem) {
2268
- context.messages.push({
2269
- id: "OPF-049",
2270
- severity: "error",
2271
- message: `Spine toc attribute references non-existent item: "${ncxId}"`,
2272
- location: { path: opfPath }
2273
- });
2274
- } else if (ncxItem.mediaType !== "application/x-dtbncx+xml") {
2275
- context.messages.push({
2276
- id: "OPF-050",
2277
- severity: "error",
2278
- message: `NCX item must have media-type "application/x-dtbncx+xml", found: "${ncxItem.mediaType}"`,
2279
- location: { path: opfPath }
2280
- });
2281
- }
2282
3208
  }
2283
3209
  }
2284
3210
  const seenIdrefs = /* @__PURE__ */ new Set();
@@ -2293,7 +3219,7 @@ var OPFValidator = class {
2293
3219
  });
2294
3220
  continue;
2295
3221
  }
2296
- if (this.packageDoc.version === "2.0" && seenIdrefs.has(itemref.idref)) {
3222
+ if (seenIdrefs.has(itemref.idref)) {
2297
3223
  context.messages.push({
2298
3224
  id: "OPF-034",
2299
3225
  severity: "error",
@@ -2492,6 +3418,24 @@ function resolvePath(basePath, relativePath) {
2492
3418
  }
2493
3419
  return parts.join("/");
2494
3420
  }
3421
+ function tryDecodeUriComponent(encoded) {
3422
+ try {
3423
+ return decodeURIComponent(encoded);
3424
+ } catch {
3425
+ return encoded;
3426
+ }
3427
+ }
3428
+ function checkUrlLeaking(href) {
3429
+ const TEST_BASE_A = "https://a.example.org/A/";
3430
+ const TEST_BASE_B = "https://b.example.org/B/";
3431
+ try {
3432
+ const urlA = new URL(href, TEST_BASE_A).toString();
3433
+ const urlB = new URL(href, TEST_BASE_B).toString();
3434
+ return !urlA.startsWith(TEST_BASE_A) || !urlB.startsWith(TEST_BASE_B);
3435
+ } catch {
3436
+ return false;
3437
+ }
3438
+ }
2495
3439
  function isValidMimeType(mediaType) {
2496
3440
  const mimeTypePattern = /^[a-zA-Z][a-zA-Z0-9!#$&\-^_.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*(?:\s*;\s*[a-zA-Z0-9-]+=[^;]+)?$/;
2497
3441
  if (!mimeTypePattern.test(mediaType)) {
@@ -2785,7 +3729,18 @@ var ReferenceValidator = class {
2785
3729
  }
2786
3730
  if (!this.registry.hasResource(resourcePath)) {
2787
3731
  const fileExistsInContainer = context.files.has(resourcePath);
2788
- if (!fileExistsInContainer) {
3732
+ if (fileExistsInContainer) {
3733
+ if (!context.referencedUndeclaredResources?.has(resourcePath)) {
3734
+ context.messages.push({
3735
+ id: "RSC-008",
3736
+ severity: "error",
3737
+ message: `Referenced resource "${resourcePath}" is not declared in the OPF manifest`,
3738
+ location: reference.location
3739
+ });
3740
+ context.referencedUndeclaredResources ??= /* @__PURE__ */ new Set();
3741
+ context.referencedUndeclaredResources.add(resourcePath);
3742
+ }
3743
+ } else {
2789
3744
  const isLinkRef = reference.type === "link" /* LINK */;
2790
3745
  context.messages.push({
2791
3746
  id: isLinkRef ? "RSC-007w" : "RSC-007",
@@ -3461,6 +4416,7 @@ var MessageId = /* @__PURE__ */ ((MessageId2) => {
3461
4416
  MessageId2["OPF_013"] = "OPF-013";
3462
4417
  MessageId2["OPF_014"] = "OPF-014";
3463
4418
  MessageId2["OPF_097"] = "OPF-097";
4419
+ MessageId2["OPF_099"] = "OPF-099";
3464
4420
  MessageId2["OPF_015"] = "OPF-015";
3465
4421
  MessageId2["RSC_001"] = "RSC-001";
3466
4422
  MessageId2["RSC_002"] = "RSC-002";