@likecoin/epubcheck-ts 0.5.1 → 0.6.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.cjs CHANGED
@@ -1668,144 +1668,6 @@ var CSSValidator = class {
1668
1668
  }
1669
1669
  };
1670
1670
 
1671
- // src/vocab/epub-ssv.ts
1672
- var EPUB_SSV_DEPRECATED = /* @__PURE__ */ new Set([
1673
- "annoref",
1674
- "annotation",
1675
- "biblioentry",
1676
- "bridgehead",
1677
- "endnote",
1678
- "help",
1679
- "marginalia",
1680
- "note",
1681
- "rearnote",
1682
- "rearnotes",
1683
- "sidebar",
1684
- "subchapter",
1685
- "warning"
1686
- ]);
1687
- var EPUB_SSV_DISALLOWED_ON_CONTENT = /* @__PURE__ */ new Set([
1688
- "aside",
1689
- "figure",
1690
- "list",
1691
- "list-item",
1692
- "table",
1693
- "table-cell",
1694
- "table-row"
1695
- ]);
1696
- var EPUB_SSV_ALL = /* @__PURE__ */ new Set([
1697
- ...EPUB_SSV_DEPRECATED,
1698
- ...EPUB_SSV_DISALLOWED_ON_CONTENT,
1699
- "abstract",
1700
- "acknowledgments",
1701
- "afterword",
1702
- "appendix",
1703
- "assessment",
1704
- "assessments",
1705
- "backlink",
1706
- "backmatter",
1707
- "balloon",
1708
- "bibliography",
1709
- "biblioref",
1710
- "bodymatter",
1711
- "case-study",
1712
- "chapter",
1713
- "colophon",
1714
- "concluding-sentence",
1715
- "conclusion",
1716
- "contributors",
1717
- "copyright-page",
1718
- "cover",
1719
- "covertitle",
1720
- "credit",
1721
- "credits",
1722
- "dedication",
1723
- "division",
1724
- "endnotes",
1725
- "epigraph",
1726
- "epilogue",
1727
- "errata",
1728
- "fill-in-the-blank-problem",
1729
- "footnote",
1730
- "footnotes",
1731
- "foreword",
1732
- "frontmatter",
1733
- "fulltitle",
1734
- "general-problem",
1735
- "glossary",
1736
- "glossdef",
1737
- "glossref",
1738
- "glossterm",
1739
- "halftitle",
1740
- "halftitlepage",
1741
- "imprimatur",
1742
- "imprint",
1743
- "index",
1744
- "index-editor-note",
1745
- "index-entry",
1746
- "index-entry-list",
1747
- "index-group",
1748
- "index-headnotes",
1749
- "index-legend",
1750
- "index-locator",
1751
- "index-locator-list",
1752
- "index-locator-range",
1753
- "index-term",
1754
- "index-term-categories",
1755
- "index-term-category",
1756
- "index-xref-preferred",
1757
- "index-xref-related",
1758
- "introduction",
1759
- "keyword",
1760
- "keywords",
1761
- "label",
1762
- "landmarks",
1763
- "learning-objective",
1764
- "learning-objectives",
1765
- "learning-outcome",
1766
- "learning-outcomes",
1767
- "learning-resource",
1768
- "learning-resources",
1769
- "learning-standard",
1770
- "learning-standards",
1771
- "loa",
1772
- "loi",
1773
- "lot",
1774
- "lov",
1775
- "match-problem",
1776
- "multiple-choice-problem",
1777
- "noteref",
1778
- "notice",
1779
- "ordinal",
1780
- "other-credits",
1781
- "page-list",
1782
- "pagebreak",
1783
- "panel",
1784
- "panel-group",
1785
- "part",
1786
- "practice",
1787
- "practices",
1788
- "preamble",
1789
- "preface",
1790
- "prologue",
1791
- "pullquote",
1792
- "qna",
1793
- "question",
1794
- "referrer",
1795
- "revision-history",
1796
- "seriespage",
1797
- "sound-area",
1798
- "subtitle",
1799
- "tip",
1800
- "title",
1801
- "titlepage",
1802
- "toc",
1803
- "toc-brief",
1804
- "topic-sentence",
1805
- "true-false-problem",
1806
- "volume"
1807
- ]);
1808
-
1809
1671
  // src/references/url.ts
1810
1672
  function parseURL(urlString) {
1811
1673
  const hashIndex = urlString.indexOf("#");
@@ -1834,12 +1696,13 @@ function isDataURL(url) {
1834
1696
  function isFileURL(url) {
1835
1697
  return url.startsWith("file:");
1836
1698
  }
1699
+ function isRelativeURL(url) {
1700
+ const regex = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
1701
+ return regex.exec(url) === null;
1702
+ }
1837
1703
  function hasAbsolutePath(url) {
1838
1704
  return url.startsWith("/");
1839
1705
  }
1840
- function hasParentDirectoryReference(url) {
1841
- return url.includes("..");
1842
- }
1843
1706
  function isMalformedURL(url) {
1844
1707
  if (!url.trim()) return true;
1845
1708
  if (/[\s<>]/.test(url)) return true;
@@ -1854,12 +1717,14 @@ function isHTTP(url) {
1854
1717
  function isRemoteURL(url) {
1855
1718
  return isHTTP(url) || isHTTPS(url);
1856
1719
  }
1857
- function checkUrlLeaking(href) {
1720
+ function checkUrlLeaking(href, resourcePath) {
1858
1721
  const TEST_BASE_A = "https://a.example.org/A/";
1859
1722
  const TEST_BASE_B = "https://b.example.org/B/";
1860
1723
  try {
1861
- const urlA = new URL(href, TEST_BASE_A).toString();
1862
- const urlB = new URL(href, TEST_BASE_B).toString();
1724
+ const baseA = resourcePath ? new URL(resourcePath, TEST_BASE_A).toString() : TEST_BASE_A;
1725
+ const baseB = resourcePath ? new URL(resourcePath, TEST_BASE_B).toString() : TEST_BASE_B;
1726
+ const urlA = new URL(href, baseA).toString();
1727
+ const urlB = new URL(href, baseB).toString();
1863
1728
  return !urlA.startsWith(TEST_BASE_A) || !urlB.startsWith(TEST_BASE_B);
1864
1729
  } catch {
1865
1730
  return false;
@@ -2031,6 +1896,8 @@ function parseOPF(xml) {
2031
1896
  }
2032
1897
  }
2033
1898
  }
1899
+ const nsRegex = /<package[^>]*\sxmlns=["']([^"']+)["']/;
1900
+ const isLegacyOebps12 = nsRegex.exec(xml)?.[1] === "http://openebook.org/namespaces/oeb-package/1.0/";
2034
1901
  const prefixes = parsePrefixes(xml);
2035
1902
  const dirRegex = /<package[^>]*\sdir=["']([^"']+)["']/;
2036
1903
  const dirMatch = dirRegex.exec(xml);
@@ -2069,6 +1936,9 @@ function parseOPF(xml) {
2069
1936
  if (!versionDeclared) {
2070
1937
  result.versionDeclared = false;
2071
1938
  }
1939
+ if (isLegacyOebps12) {
1940
+ result.isLegacyOebps12 = true;
1941
+ }
2072
1942
  if (Object.keys(prefixes).length > 0) {
2073
1943
  result.prefixes = prefixes;
2074
1944
  }
@@ -2159,7 +2029,7 @@ function parseDCElements(metadataXml) {
2159
2029
  }
2160
2030
  function parseMetaElements(metadataXml) {
2161
2031
  const elements = [];
2162
- const metaRegex = /<meta([^>]*property=["'][^"']+["'][^>]*)>([^<]*)<\/meta>/g;
2032
+ const metaRegex = /<(?:[A-Za-z_][\w.-]*:)?meta([^>]*property=["'][^"']+["'][^>]*)>([^<]*)<\/(?:[A-Za-z_][\w.-]*:)?meta>/g;
2163
2033
  let match;
2164
2034
  while ((match = metaRegex.exec(metadataXml)) !== null) {
2165
2035
  const attrsStr = match[1] ?? "";
@@ -2823,6 +2693,31 @@ var PROFILE_DC_TYPE = {
2823
2693
  dict: "dictionary",
2824
2694
  preview: "preview"
2825
2695
  };
2696
+ var TYPE_TO_PROFILE = {
2697
+ dictionary: "dict",
2698
+ edupub: "edupub",
2699
+ index: "idx",
2700
+ preview: "preview"
2701
+ };
2702
+ var RESERVED_PREFIX_URIS = {
2703
+ dcterms: "http://purl.org/dc/terms/",
2704
+ marc: "http://id.loc.gov/vocabulary/",
2705
+ media: "http://www.idpf.org/epub/vocab/overlays/#",
2706
+ onix: "http://www.editeur.org/ONIX/book/codelists/current.html#",
2707
+ rendition: "http://www.idpf.org/vocab/rendition/#",
2708
+ schema: "http://schema.org/",
2709
+ xsd: "http://www.w3.org/2001/XMLSchema#",
2710
+ a11y: "http://www.idpf.org/epub/vocab/package/a11y/#"
2711
+ };
2712
+ function isValidURI(uri) {
2713
+ if (!uri) return false;
2714
+ try {
2715
+ new URL(uri);
2716
+ return true;
2717
+ } catch {
2718
+ return false;
2719
+ }
2720
+ }
2826
2721
  var DICTIONARY_TYPE_VALUES = /* @__PURE__ */ new Set([
2827
2722
  "monolingual",
2828
2723
  "bilingual",
@@ -2929,9 +2824,14 @@ var OPFValidator = class {
2929
2824
  }
2930
2825
  this.validatePackageAttributes(context, opfPath);
2931
2826
  this.validateMetadata(context, opfPath);
2827
+ if (this.packageDoc.version !== "2.0") {
2828
+ this.validatePrefixDeclarations(context, opfPath, opfXml);
2829
+ this.validateMetaPrefixes(context, opfPath, opfXml);
2830
+ }
2932
2831
  this.validateLinkElements(context, opfPath);
2933
2832
  this.validateManifest(context, opfPath);
2934
2833
  this.validateSpine(context, opfPath);
2834
+ this.validatePageMap(context, opfPath, opfXml);
2935
2835
  this.validateFallbackChains(context, opfPath);
2936
2836
  this.validateUndeclaredResources(context, opfPath);
2937
2837
  if (this.packageDoc.version === "2.0") {
@@ -2962,6 +2862,7 @@ var OPFValidator = class {
2962
2862
  if (this.packageDoc.version.startsWith("3.")) {
2963
2863
  this.validateAccessibilityMetadata(context, opfPath);
2964
2864
  this.validateProfileDcType(context, opfPath);
2865
+ this.validateDcTypeProfileSwitch(context, opfPath);
2965
2866
  this.validateEdupubMetadata(context, opfPath);
2966
2867
  this.validateDictionaryMetadata(context, opfPath);
2967
2868
  this.validatePreviewMetadata(context, opfPath);
@@ -3165,6 +3066,22 @@ var OPFValidator = class {
3165
3066
  });
3166
3067
  }
3167
3068
  }
3069
+ // Mirrors Java's EPUBProfile.makeTypeCompatible flow.
3070
+ validateDcTypeProfileSwitch(context, opfPath) {
3071
+ if (!this.packageDoc) return;
3072
+ for (const dc of this.packageDoc.dcElements) {
3073
+ if (dc.name !== "type") continue;
3074
+ const inferred = TYPE_TO_PROFILE[dc.value.trim().toLowerCase()];
3075
+ if (inferred && inferred !== context.options.profile) {
3076
+ pushMessage(context.messages, {
3077
+ id: MessageId.OPF_064,
3078
+ message: `OPF declares type "${dc.value.trim().toLowerCase()}"; consider validating using the "${inferred}" profile.`,
3079
+ location: { path: opfPath }
3080
+ });
3081
+ return;
3082
+ }
3083
+ }
3084
+ }
3168
3085
  /**
3169
3086
  * Build lookup maps for manifest items
3170
3087
  */
@@ -3181,6 +3098,13 @@ var OPFValidator = class {
3181
3098
  */
3182
3099
  validatePackageAttributes(context, opfPath) {
3183
3100
  if (!this.packageDoc) return;
3101
+ if (this.packageDoc.isLegacyOebps12) {
3102
+ pushMessage(context.messages, {
3103
+ id: MessageId.OPF_047,
3104
+ message: "OPF file is using OEBPS 1.2 syntax allowing backwards compatibility.",
3105
+ location: { path: opfPath }
3106
+ });
3107
+ }
3184
3108
  if (this.packageDoc.versionDeclared === false) {
3185
3109
  pushMessage(context.messages, {
3186
3110
  id: MessageId.OPF_001,
@@ -4105,14 +4029,24 @@ var OPFValidator = class {
4105
4029
  const resolvedPath = resolvePath(opfPath, basePathNoQuery);
4106
4030
  const resolvedPathDecoded = basePathDecodedNoQuery !== basePathNoQuery ? resolvePath(opfPath, basePathDecodedNoQuery) : resolvedPath;
4107
4031
  const fileExists = context.files.has(resolvedPath) || context.files.has(resolvedPathDecoded);
4108
- const inManifest = this.manifestByHref.has(basePathNoQuery) || this.manifestByHref.has(basePathDecodedNoQuery);
4109
- if (!fileExists && !inManifest) {
4032
+ const manifestItem = this.manifestByHref.get(basePathNoQuery) ?? this.manifestByHref.get(basePathDecodedNoQuery);
4033
+ if (!fileExists && !manifestItem) {
4110
4034
  pushMessage(context.messages, {
4111
4035
  id: MessageId.RSC_007w,
4112
4036
  message: `Referenced resource "${resolvedPath}" could not be found in the EPUB`,
4113
4037
  location: { path: opfPath }
4114
4038
  });
4115
4039
  }
4040
+ if (manifestItem) {
4041
+ const inSpine = this.packageDoc.spine.some((ref) => ref.idref === manifestItem.id);
4042
+ if (!inSpine) {
4043
+ pushMessage(context.messages, {
4044
+ id: MessageId.OPF_067,
4045
+ message: `Resource "${manifestItem.href}" is referenced as a link but is also declared as a manifest item.`,
4046
+ location: { path: opfPath }
4047
+ });
4048
+ }
4049
+ }
4116
4050
  }
4117
4051
  }
4118
4052
  /**
@@ -4122,6 +4056,7 @@ var OPFValidator = class {
4122
4056
  if (!this.packageDoc) return;
4123
4057
  const seenIds = /* @__PURE__ */ new Set();
4124
4058
  const seenHrefs = /* @__PURE__ */ new Set();
4059
+ const declaredPrefixes = this.packageDoc.prefixes ?? {};
4125
4060
  for (const item of this.packageDoc.manifest) {
4126
4061
  if (seenIds.has(item.id)) {
4127
4062
  pushMessage(context.messages, {
@@ -4165,7 +4100,7 @@ var OPFValidator = class {
4165
4100
  });
4166
4101
  }
4167
4102
  if (!item.href.startsWith("http") && !item.href.startsWith("mailto:")) {
4168
- const leaked = checkUrlLeaking(item.href);
4103
+ const leaked = checkUrlLeaking(item.href, opfPath);
4169
4104
  if (leaked) {
4170
4105
  pushMessage(context.messages, {
4171
4106
  id: MessageId.RSC_026,
@@ -4200,11 +4135,11 @@ var OPFValidator = class {
4200
4135
  if (DEPRECATED_MEDIA_TYPES.has(item.mediaType) || item.mediaType === "text/html") {
4201
4136
  if (this.packageDoc.version === "2.0" && item.mediaType === "text/html") {
4202
4137
  pushMessage(context.messages, {
4203
- id: MessageId.OPF_035,
4138
+ id: this.packageDoc.isLegacyOebps12 ? MessageId.OPF_038 : MessageId.OPF_035,
4204
4139
  message: `XHTML Content Document "${item.id}" is declared as "text/html"`,
4205
4140
  location: { path: opfPath }
4206
4141
  });
4207
- } else if (this.packageDoc.version === "2.0") {
4142
+ } else if (this.packageDoc.version === "2.0" && !this.packageDoc.isLegacyOebps12) {
4208
4143
  pushMessage(context.messages, {
4209
4144
  id: MessageId.OPF_037,
4210
4145
  message: `Found deprecated media-type "${item.mediaType}"`,
@@ -4212,6 +4147,13 @@ var OPFValidator = class {
4212
4147
  });
4213
4148
  }
4214
4149
  }
4150
+ if (this.packageDoc.version === "2.0" && this.packageDoc.isLegacyOebps12 && item.mediaType === "text/css" && !item.fallback) {
4151
+ pushMessage(context.messages, {
4152
+ id: MessageId.OPF_039,
4153
+ message: `Media type "${item.mediaType}" requires a fallback in legacy OEBPS 1.2 context`,
4154
+ location: { path: opfPath }
4155
+ });
4156
+ }
4215
4157
  const preferred = getPreferredMediaType(item.mediaType, fullPath);
4216
4158
  if (preferred !== null) {
4217
4159
  pushMessage(context.messages, {
@@ -4222,13 +4164,14 @@ var OPFValidator = class {
4222
4164
  }
4223
4165
  if (this.packageDoc.version !== "2.0" && item.properties) {
4224
4166
  for (const prop of item.properties) {
4225
- if (!ITEM_PROPERTIES.has(prop)) {
4226
- pushMessage(context.messages, {
4227
- id: MessageId.OPF_027,
4228
- message: `Undefined property: "${prop}"`,
4229
- location: { path: opfPath }
4230
- });
4231
- }
4167
+ if (ITEM_PROPERTIES.has(prop)) continue;
4168
+ const colon = prop.indexOf(":");
4169
+ if (colon > 0 && declaredPrefixes[prop.slice(0, colon)] !== void 0) continue;
4170
+ pushMessage(context.messages, {
4171
+ id: MessageId.OPF_027,
4172
+ message: `Undefined property: "${prop}"`,
4173
+ location: { path: opfPath }
4174
+ });
4232
4175
  }
4233
4176
  if (item.properties.includes("nav")) {
4234
4177
  if (item.mediaType !== "application/xhtml+xml") {
@@ -4385,12 +4328,89 @@ var OPFValidator = class {
4385
4328
  }
4386
4329
  }
4387
4330
  }
4331
+ // Mirrors Java's PrefixDeclarationParser + VocabUtil.parsePrefixDeclaration,
4332
+ // but emits only the four main IDs (not Java's OPF-004a..f sub-codes).
4333
+ validatePrefixDeclarations(context, opfPath, opfXml) {
4334
+ const stripped = stripXmlComments(opfXml);
4335
+ const match = /<package[^>]*\sprefix\s*=\s*["']([^"']*)["']/.exec(stripped);
4336
+ if (!match) return;
4337
+ const raw = match[1] ?? "";
4338
+ if (raw !== raw.trim()) {
4339
+ pushMessage(context.messages, {
4340
+ id: MessageId.OPF_004,
4341
+ message: "The value of the prefix attribute has leading or trailing whitespace.",
4342
+ location: { path: opfPath }
4343
+ });
4344
+ }
4345
+ const parts = raw.trim().split(/\s+/).filter(Boolean);
4346
+ for (let i = 0; i < parts.length; ) {
4347
+ const token = parts[i] ?? "";
4348
+ if (token.endsWith(":") && token.length > 1) {
4349
+ const prefix = token.slice(0, -1);
4350
+ const uri = parts[i + 1];
4351
+ if (!uri || uri.endsWith(":")) {
4352
+ pushMessage(context.messages, {
4353
+ id: MessageId.OPF_005,
4354
+ message: `The prefix "${prefix}" is declared but no URI is bound to it.`,
4355
+ location: { path: opfPath }
4356
+ });
4357
+ i += 1;
4358
+ continue;
4359
+ }
4360
+ if (!isValidURI(uri)) {
4361
+ pushMessage(context.messages, {
4362
+ id: MessageId.OPF_006,
4363
+ message: `The value "${uri}" bound to prefix "${prefix}" is not a valid URI.`,
4364
+ location: { path: opfPath }
4365
+ });
4366
+ }
4367
+ const reservedUri = RESERVED_PREFIX_URIS[prefix];
4368
+ if (reservedUri !== void 0 && reservedUri !== uri) {
4369
+ pushMessage(context.messages, {
4370
+ id: MessageId.OPF_007,
4371
+ message: `The prefix "${prefix}" is reserved and must not be re-declared.`,
4372
+ location: { path: opfPath }
4373
+ });
4374
+ }
4375
+ i += 2;
4376
+ } else {
4377
+ i += 1;
4378
+ }
4379
+ }
4380
+ }
4388
4381
  /**
4389
4382
  * RSC-005: all id attributes on elements in the OPF document must be unique.
4390
4383
  * Mirrors Java's id-unique.sch / opf.sch opf_idAttrUnique pattern, which
4391
4384
  * emits one assertion failure per offending element (so two duplicate ids
4392
4385
  * produce two RSC-005 messages).
4393
4386
  */
4387
+ validateMetaPrefixes(context, opfPath, opfXml) {
4388
+ if (!this.packageDoc) return;
4389
+ const RESERVED = new Set(Object.keys(RESERVED_PREFIX_URIS));
4390
+ const declared = new Set(Object.keys(this.packageDoc.prefixes ?? {}));
4391
+ const reported = /* @__PURE__ */ new Set();
4392
+ const reportIfUndeclared = (prefix) => {
4393
+ if (!prefix || reported.has(prefix)) return;
4394
+ if (RESERVED.has(prefix) || declared.has(prefix)) return;
4395
+ reported.add(prefix);
4396
+ pushMessage(context.messages, {
4397
+ id: MessageId.OPF_028,
4398
+ message: `Undeclared prefix: "${prefix}"`,
4399
+ location: { path: opfPath }
4400
+ });
4401
+ };
4402
+ const stripped = stripXmlComments(opfXml);
4403
+ const attrRegex = /\b(?:property|scheme|rel)\s*=\s*["']([^"']+)["']/g;
4404
+ for (const match of stripped.matchAll(attrRegex)) {
4405
+ const value = match[1]?.trim();
4406
+ if (!value) continue;
4407
+ for (const token of value.split(/\s+/)) {
4408
+ const colon = token.indexOf(":");
4409
+ if (colon <= 0) continue;
4410
+ reportIfUndeclared(token.slice(0, colon));
4411
+ }
4412
+ }
4413
+ }
4394
4414
  validateOpfIdUniqueness(context, opfPath, opfXml) {
4395
4415
  const stripped = stripXmlComments(opfXml);
4396
4416
  const counts = /* @__PURE__ */ new Map();
@@ -4422,11 +4442,12 @@ var OPFValidator = class {
4422
4442
  for (const item of this.packageDoc.manifest) {
4423
4443
  const hrefBase = item.href.split("?")[0] ?? item.href;
4424
4444
  if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(hrefBase)) continue;
4425
- manifestPaths.add(resolvePath(opfPath, hrefBase).normalize("NFC"));
4445
+ manifestPaths.add(resolvePath(opfPath, tryDecodeUriComponent(hrefBase)).normalize("NFC"));
4426
4446
  }
4427
4447
  const rootfilePaths = new Set(context.rootfiles.map((r) => r.path.normalize("NFC")));
4428
4448
  for (const path of context.files.keys()) {
4429
4449
  if (path === "mimetype") continue;
4450
+ if (path.endsWith("/")) continue;
4430
4451
  if (path.startsWith("META-INF/")) continue;
4431
4452
  if (rootfilePaths.has(path)) continue;
4432
4453
  if (manifestPaths.has(path)) continue;
@@ -4572,6 +4593,26 @@ var OPFValidator = class {
4572
4593
  }
4573
4594
  }
4574
4595
  }
4596
+ validatePageMap(context, opfPath, opfXml) {
4597
+ if (!this.packageDoc) return;
4598
+ const stripped = stripXmlComments(opfXml);
4599
+ const m = /<spine\b[^>]*\spage-map\s*=\s*["']([^"']*)["']/.exec(stripped);
4600
+ if (!m) return;
4601
+ const pageMapId = (m[1] ?? "").trim();
4602
+ pushMessage(context.messages, {
4603
+ id: MessageId.OPF_062,
4604
+ message: `Found Adobe page-map attribute on spine element (page-map="${pageMapId}")`,
4605
+ location: { path: opfPath }
4606
+ });
4607
+ if (!pageMapId) return;
4608
+ if (!this.manifestById.has(pageMapId)) {
4609
+ pushMessage(context.messages, {
4610
+ id: MessageId.OPF_063,
4611
+ message: `The Adobe page-map item "${pageMapId}" was not found in the manifest`,
4612
+ location: { path: opfPath }
4613
+ });
4614
+ }
4615
+ }
4575
4616
  /**
4576
4617
  * Validate fallback chains
4577
4618
  */
@@ -5015,6 +5056,246 @@ function isValidW3CDateFormat(dateStr) {
5015
5056
  return false;
5016
5057
  }
5017
5058
 
5059
+ // src/references/types.ts
5060
+ var PUBLICATION_RESOURCE_TYPES = /* @__PURE__ */ new Set([
5061
+ "generic" /* GENERIC */,
5062
+ "stylesheet" /* STYLESHEET */,
5063
+ "font" /* FONT */,
5064
+ "image" /* IMAGE */,
5065
+ "audio" /* AUDIO */,
5066
+ "video" /* VIDEO */,
5067
+ "track" /* TRACK */,
5068
+ "media-overlay" /* MEDIA_OVERLAY */,
5069
+ "svg-symbol" /* SVG_SYMBOL */,
5070
+ "svg-paint" /* SVG_PAINT */,
5071
+ "svg-clip-path" /* SVG_CLIP_PATH */
5072
+ ]);
5073
+ function isPublicationResourceReference(type) {
5074
+ return PUBLICATION_RESOURCE_TYPES.has(type);
5075
+ }
5076
+
5077
+ // src/skm/validator.ts
5078
+ var OPS_NS_URI = "http://www.idpf.org/2007/ops";
5079
+ var SKM_NS = { ops: OPS_NS_URI };
5080
+ var SKMValidator = class {
5081
+ validate(context, path, refValidator) {
5082
+ const data = context.files.get(path);
5083
+ if (!data) return;
5084
+ const content = typeof data === "string" ? data : new TextDecoder().decode(data);
5085
+ let doc = null;
5086
+ try {
5087
+ doc = libxml2Wasm.XmlDocument.fromString(content);
5088
+ } catch {
5089
+ pushMessage(context.messages, {
5090
+ id: MessageId.RSC_016,
5091
+ message: "Search Key Map document is not well-formed XML",
5092
+ location: { path }
5093
+ });
5094
+ return;
5095
+ }
5096
+ try {
5097
+ const root = doc.root;
5098
+ if (root.namespaceUri !== OPS_NS_URI || root.name !== "search-key-map") {
5099
+ pushMessage(context.messages, {
5100
+ id: MessageId.RSC_005,
5101
+ message: `Root element must be "search-key-map" in the OPS namespace`,
5102
+ location: { path, line: root.line }
5103
+ });
5104
+ return;
5105
+ }
5106
+ const groups = root.find("./ops:search-key-group", SKM_NS);
5107
+ if (groups.length === 0) {
5108
+ pushMessage(context.messages, {
5109
+ id: MessageId.RSC_005,
5110
+ message: 'A "search-key-map" must contain at least one "search-key-group"',
5111
+ location: { path, line: root.line }
5112
+ });
5113
+ }
5114
+ for (const group of groups) {
5115
+ const groupEl = group;
5116
+ const href = groupEl.attr("href")?.value;
5117
+ if (!href) {
5118
+ pushMessage(context.messages, {
5119
+ id: MessageId.RSC_005,
5120
+ message: 'The "href" attribute is required on "search-key-group"',
5121
+ location: { path, line: groupEl.line }
5122
+ });
5123
+ } else if (refValidator) {
5124
+ registerSkmRef(refValidator, path, href, groupEl.line);
5125
+ }
5126
+ const matches = groupEl.find("./ops:match", SKM_NS);
5127
+ if (matches.length === 0) {
5128
+ pushMessage(context.messages, {
5129
+ id: MessageId.RSC_005,
5130
+ message: 'A "search-key-group" must contain at least one "match"',
5131
+ location: { path, line: groupEl.line }
5132
+ });
5133
+ }
5134
+ for (const match of matches) {
5135
+ const matchEl = match;
5136
+ const matchHref = matchEl.attr("href")?.value;
5137
+ if (matchHref && refValidator) {
5138
+ registerSkmRef(refValidator, path, matchHref, matchEl.line);
5139
+ }
5140
+ }
5141
+ }
5142
+ } finally {
5143
+ doc.dispose();
5144
+ }
5145
+ }
5146
+ };
5147
+ function registerSkmRef(refValidator, path, href, line) {
5148
+ const parsed = parseURL(href);
5149
+ const targetResource = resolvePath(path, tryDecodeUriComponent(parsed.resource)).normalize("NFC");
5150
+ const location = line != null ? { path, line } : { path };
5151
+ const ref = {
5152
+ url: parsed.hasFragment ? `${targetResource}#${parsed.fragment ?? ""}` : targetResource,
5153
+ targetResource,
5154
+ type: "search-key" /* SEARCH_KEY */,
5155
+ location
5156
+ };
5157
+ if (parsed.fragment !== void 0) ref.fragment = parsed.fragment;
5158
+ refValidator.addReference(ref);
5159
+ }
5160
+
5161
+ // src/vocab/epub-ssv.ts
5162
+ var EPUB_SSV_DEPRECATED = /* @__PURE__ */ new Set([
5163
+ "annoref",
5164
+ "annotation",
5165
+ "biblioentry",
5166
+ "bridgehead",
5167
+ "endnote",
5168
+ "help",
5169
+ "marginalia",
5170
+ "note",
5171
+ "rearnote",
5172
+ "rearnotes",
5173
+ "sidebar",
5174
+ "subchapter",
5175
+ "warning"
5176
+ ]);
5177
+ var EPUB_SSV_DISALLOWED_ON_CONTENT = /* @__PURE__ */ new Set([
5178
+ "aside",
5179
+ "figure",
5180
+ "list",
5181
+ "list-item",
5182
+ "table",
5183
+ "table-cell",
5184
+ "table-row"
5185
+ ]);
5186
+ var EPUB_SSV_ALL = /* @__PURE__ */ new Set([
5187
+ ...EPUB_SSV_DEPRECATED,
5188
+ ...EPUB_SSV_DISALLOWED_ON_CONTENT,
5189
+ "abstract",
5190
+ "acknowledgments",
5191
+ "afterword",
5192
+ "appendix",
5193
+ "assessment",
5194
+ "assessments",
5195
+ "backlink",
5196
+ "backmatter",
5197
+ "balloon",
5198
+ "bibliography",
5199
+ "biblioref",
5200
+ "bodymatter",
5201
+ "case-study",
5202
+ "chapter",
5203
+ "colophon",
5204
+ "concluding-sentence",
5205
+ "conclusion",
5206
+ "contributors",
5207
+ "copyright-page",
5208
+ "cover",
5209
+ "covertitle",
5210
+ "credit",
5211
+ "credits",
5212
+ "dedication",
5213
+ "division",
5214
+ "endnotes",
5215
+ "epigraph",
5216
+ "epilogue",
5217
+ "errata",
5218
+ "fill-in-the-blank-problem",
5219
+ "footnote",
5220
+ "footnotes",
5221
+ "foreword",
5222
+ "frontmatter",
5223
+ "fulltitle",
5224
+ "general-problem",
5225
+ "glossary",
5226
+ "glossdef",
5227
+ "glossref",
5228
+ "glossterm",
5229
+ "halftitle",
5230
+ "halftitlepage",
5231
+ "imprimatur",
5232
+ "imprint",
5233
+ "index",
5234
+ "index-editor-note",
5235
+ "index-entry",
5236
+ "index-entry-list",
5237
+ "index-group",
5238
+ "index-headnotes",
5239
+ "index-legend",
5240
+ "index-locator",
5241
+ "index-locator-list",
5242
+ "index-locator-range",
5243
+ "index-term",
5244
+ "index-term-categories",
5245
+ "index-term-category",
5246
+ "index-xref-preferred",
5247
+ "index-xref-related",
5248
+ "introduction",
5249
+ "keyword",
5250
+ "keywords",
5251
+ "label",
5252
+ "landmarks",
5253
+ "learning-objective",
5254
+ "learning-objectives",
5255
+ "learning-outcome",
5256
+ "learning-outcomes",
5257
+ "learning-resource",
5258
+ "learning-resources",
5259
+ "learning-standard",
5260
+ "learning-standards",
5261
+ "loa",
5262
+ "loi",
5263
+ "lot",
5264
+ "lov",
5265
+ "match-problem",
5266
+ "multiple-choice-problem",
5267
+ "noteref",
5268
+ "notice",
5269
+ "ordinal",
5270
+ "other-credits",
5271
+ "page-list",
5272
+ "pagebreak",
5273
+ "panel",
5274
+ "panel-group",
5275
+ "part",
5276
+ "practice",
5277
+ "practices",
5278
+ "preamble",
5279
+ "preface",
5280
+ "prologue",
5281
+ "pullquote",
5282
+ "qna",
5283
+ "question",
5284
+ "referrer",
5285
+ "revision-history",
5286
+ "seriespage",
5287
+ "sound-area",
5288
+ "subtitle",
5289
+ "tip",
5290
+ "title",
5291
+ "titlepage",
5292
+ "toc",
5293
+ "toc-brief",
5294
+ "topic-sentence",
5295
+ "true-false-problem",
5296
+ "volume"
5297
+ ]);
5298
+
5018
5299
  // src/smil/validator.ts
5019
5300
  var SMIL_NS = { smil: "http://www.w3.org/ns/SMIL" };
5020
5301
  var BLESSED_AUDIO_TYPES = /* @__PURE__ */ new Set(["audio/mpeg", "audio/mp4"]);
@@ -5249,34 +5530,16 @@ var SMILValidator = class {
5249
5530
  if (!baseDir) return decoded.normalize("NFC");
5250
5531
  const segments = `${baseDir}/${decoded}`.split("/");
5251
5532
  const resolved = [];
5252
- for (const seg of segments) {
5253
- if (seg === "..") {
5254
- resolved.pop();
5255
- } else if (seg !== ".") {
5256
- resolved.push(seg);
5257
- }
5258
- }
5259
- return resolved.join("/").normalize("NFC");
5260
- }
5261
- };
5262
-
5263
- // src/references/types.ts
5264
- var PUBLICATION_RESOURCE_TYPES = /* @__PURE__ */ new Set([
5265
- "generic" /* GENERIC */,
5266
- "stylesheet" /* STYLESHEET */,
5267
- "font" /* FONT */,
5268
- "image" /* IMAGE */,
5269
- "audio" /* AUDIO */,
5270
- "video" /* VIDEO */,
5271
- "track" /* TRACK */,
5272
- "media-overlay" /* MEDIA_OVERLAY */,
5273
- "svg-symbol" /* SVG_SYMBOL */,
5274
- "svg-paint" /* SVG_PAINT */,
5275
- "svg-clip-path" /* SVG_CLIP_PATH */
5276
- ]);
5277
- function isPublicationResourceReference(type) {
5278
- return PUBLICATION_RESOURCE_TYPES.has(type);
5279
- }
5533
+ for (const seg of segments) {
5534
+ if (seg === "..") {
5535
+ resolved.pop();
5536
+ } else if (seg !== ".") {
5537
+ resolved.push(seg);
5538
+ }
5539
+ }
5540
+ return resolved.join("/").normalize("NFC");
5541
+ }
5542
+ };
5280
5543
 
5281
5544
  // src/references/uri-schemes.ts
5282
5545
  var URI_SCHEMES = /* @__PURE__ */ new Set([
@@ -5367,7 +5630,9 @@ var ABSOLUTE_URI_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
5367
5630
  var SPECIAL_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ftp", "ws", "wss"]);
5368
5631
  var CSS_CHARSET_RE = /^@charset\s+"([^"]+)"\s*;/;
5369
5632
  var EPUB_XMLNS_RE = /xmlns:epub\s*=\s*"([^"]*)"/;
5370
- var XHTML_NS = { html: "http://www.w3.org/1999/xhtml" };
5633
+ var XHTML_NS_URI = "http://www.w3.org/1999/xhtml";
5634
+ var XML_NS_URI = "http://www.w3.org/XML/1998/namespace";
5635
+ var XHTML_NS = { html: XHTML_NS_URI };
5371
5636
  var EPUB_OPS_NS = { epub: "http://www.idpf.org/2007/ops" };
5372
5637
  var EPUB_TYPE_FORBIDDEN_ELEMENTS = /* @__PURE__ */ new Set([
5373
5638
  "head",
@@ -5651,6 +5916,112 @@ var HTML5_ELEMENTS = /* @__PURE__ */ new Set([
5651
5916
  "video",
5652
5917
  "wbr"
5653
5918
  ]);
5919
+ var XHTML11_ELEMENTS = /* @__PURE__ */ new Set([
5920
+ // struct
5921
+ "html",
5922
+ "head",
5923
+ "title",
5924
+ "body",
5925
+ "meta",
5926
+ "link",
5927
+ "base",
5928
+ "style",
5929
+ "script",
5930
+ "noscript",
5931
+ // text
5932
+ "br",
5933
+ "span",
5934
+ "abbr",
5935
+ "acronym",
5936
+ "cite",
5937
+ "code",
5938
+ "dfn",
5939
+ "em",
5940
+ "kbd",
5941
+ "q",
5942
+ "samp",
5943
+ "strong",
5944
+ "var",
5945
+ "div",
5946
+ "p",
5947
+ "address",
5948
+ "blockquote",
5949
+ "pre",
5950
+ "h1",
5951
+ "h2",
5952
+ "h3",
5953
+ "h4",
5954
+ "h5",
5955
+ "h6",
5956
+ // pres / legacy
5957
+ "hr",
5958
+ "b",
5959
+ "big",
5960
+ "i",
5961
+ "small",
5962
+ "sub",
5963
+ "sup",
5964
+ "tt",
5965
+ "basefont",
5966
+ "center",
5967
+ "font",
5968
+ "s",
5969
+ "strike",
5970
+ "u",
5971
+ "dir",
5972
+ "menu",
5973
+ "isindex",
5974
+ // list
5975
+ "dl",
5976
+ "dt",
5977
+ "dd",
5978
+ "ol",
5979
+ "ul",
5980
+ "li",
5981
+ // table
5982
+ "table",
5983
+ "caption",
5984
+ "tr",
5985
+ "th",
5986
+ "td",
5987
+ "col",
5988
+ "colgroup",
5989
+ "tbody",
5990
+ "thead",
5991
+ "tfoot",
5992
+ // hypertext / image / object / form / edit / ruby / map / iframe / applet / bdo / param
5993
+ "a",
5994
+ "img",
5995
+ "object",
5996
+ "param",
5997
+ "form",
5998
+ "label",
5999
+ "input",
6000
+ "select",
6001
+ "option",
6002
+ "optgroup",
6003
+ "fieldset",
6004
+ "button",
6005
+ "legend",
6006
+ "textarea",
6007
+ "ins",
6008
+ "del",
6009
+ "ruby",
6010
+ "rbc",
6011
+ "rtc",
6012
+ "rb",
6013
+ "rt",
6014
+ "rp",
6015
+ "map",
6016
+ "area",
6017
+ "iframe",
6018
+ "applet",
6019
+ "bdo",
6020
+ // frames
6021
+ "frameset",
6022
+ "frame",
6023
+ "noframes"
6024
+ ]);
5654
6025
  function isItemFixedLayout(packageDoc, itemId) {
5655
6026
  const spineItem = packageDoc.spine.find((s) => s.idref === itemId);
5656
6027
  if (!spineItem) return false;
@@ -5717,6 +6088,10 @@ var ContentValidator = class {
5717
6088
  if (refValidator) {
5718
6089
  this.extractSVGReferences(context, fullPath, opfDir, refValidator);
5719
6090
  }
6091
+ } else if (item.mediaType === "application/vnd.epub.search-key-map+xml") {
6092
+ const fullPath = resolveManifestHref(opfDir, item.href);
6093
+ const skmValidator = new SKMValidator();
6094
+ skmValidator.validate(context, fullPath, refValidator);
5720
6095
  } else if (item.mediaType === "application/smil+xml") {
5721
6096
  const fullPath = resolveManifestHref(opfDir, item.href);
5722
6097
  const smilValidator = new SMILValidator();
@@ -6479,6 +6854,9 @@ var ContentValidator = class {
6479
6854
  }
6480
6855
  }
6481
6856
  }
6857
+ if (context.version === "2.0") {
6858
+ this.checkEpub2XhtmlStrict(context, path, root);
6859
+ }
6482
6860
  this.checkDiscouragedElements(context, path, root);
6483
6861
  this.checkSSMLPh(context, path, root, content);
6484
6862
  this.checkObsoleteHTML(context, path, root);
@@ -6509,6 +6887,9 @@ var ContentValidator = class {
6509
6887
  this.validateEpubTypes(context, path, root);
6510
6888
  this.validateRegionBasedNav(context, path, root, manifestItem);
6511
6889
  }
6890
+ if (context.version.startsWith("3") && context.options.profile === "dict") {
6891
+ this.validateDictionaryContent(context, path, root);
6892
+ }
6512
6893
  if (context.version.startsWith("3") && context.options.profile === "edupub") {
6513
6894
  const isFxl = manifestItem && packageDoc ? isItemFixedLayout(packageDoc, manifestItem.id) : false;
6514
6895
  const isNonLinear = manifestItem && packageDoc ? packageDoc.spine.find((ref) => ref.idref === manifestItem.id)?.linear === false : false;
@@ -6517,7 +6898,7 @@ var ContentValidator = class {
6517
6898
  }
6518
6899
  }
6519
6900
  if (context.version.startsWith("3")) {
6520
- this.collectFeatures(context, root);
6901
+ this.collectFeatures(context, path, root);
6521
6902
  }
6522
6903
  this.validateEpubSwitch(context, path, root);
6523
6904
  this.validateEpubTrigger(context, path, root);
@@ -6955,6 +7336,9 @@ var ContentValidator = class {
6955
7336
  const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
6956
7337
  const opfDir = context.opfPath?.includes("/") ? context.opfPath.substring(0, context.opfPath.lastIndexOf("/")) : "";
6957
7338
  const tocAnchors = tocNav.find(".//html:a[@href]", HTML_NS);
7339
+ if (context.contentFeatures) {
7340
+ context.contentFeatures.tocLinkCount = (context.contentFeatures.tocLinkCount ?? 0) + tocAnchors.length;
7341
+ }
6958
7342
  const tocLinks = [];
6959
7343
  for (const anchor of tocAnchors) {
6960
7344
  const href = this.getAttribute(anchor, "href")?.trim();
@@ -8040,7 +8424,7 @@ var ContentValidator = class {
8040
8424
  }
8041
8425
  }
8042
8426
  }
8043
- collectFeatures(context, root) {
8427
+ collectFeatures(context, path, root) {
8044
8428
  const features = context.contentFeatures;
8045
8429
  if (!features) return;
8046
8430
  if (!features.hasTable && root.get(".//html:table", XHTML_NS)) {
@@ -8055,22 +8439,20 @@ var ContentValidator = class {
8055
8439
  if (!features.hasVideo && root.get(".//html:video", XHTML_NS)) {
8056
8440
  features.hasVideo = true;
8057
8441
  }
8058
- if (!features.hasPageBreak || !features.hasDictionary || !features.hasIndex) {
8059
- const epubTypeElements = root.find(".//*[@epub:type]", EPUB_OPS_NS);
8060
- for (const el of epubTypeElements) {
8061
- const attr = el.attr("type", "epub");
8062
- if (!attr?.value) continue;
8063
- const tokens = attr.value.trim().split(/\s+/);
8064
- if (!features.hasPageBreak && tokens.includes("pagebreak")) {
8065
- features.hasPageBreak = true;
8066
- }
8067
- if (!features.hasDictionary && tokens.includes("dictionary")) {
8068
- features.hasDictionary = true;
8069
- }
8070
- if (!features.hasIndex && tokens.includes("index")) {
8071
- features.hasIndex = true;
8072
- }
8073
- if (features.hasPageBreak && features.hasDictionary && features.hasIndex) break;
8442
+ const epubTypeElements = root.find(".//*[@epub:type]", EPUB_OPS_NS);
8443
+ for (const el of epubTypeElements) {
8444
+ const attr = el.attr("type", "epub");
8445
+ if (!attr?.value) continue;
8446
+ const tokens = attr.value.trim().split(/\s+/);
8447
+ if (!features.hasPageBreak && tokens.includes("pagebreak")) {
8448
+ features.hasPageBreak = true;
8449
+ }
8450
+ if (tokens.includes("dictionary")) {
8451
+ features.hasDictionary = true;
8452
+ (features.dictionaryContentPaths ??= /* @__PURE__ */ new Set()).add(path);
8453
+ }
8454
+ if (!features.hasIndex && tokens.includes("index")) {
8455
+ features.hasIndex = true;
8074
8456
  }
8075
8457
  }
8076
8458
  if (!features.hasMicrodata && root.get(".//*[@itemscope]")) {
@@ -8079,6 +8461,10 @@ var ContentValidator = class {
8079
8461
  if (!features.hasRDFa && root.get(".//*[@property]")) {
8080
8462
  features.hasRDFa = true;
8081
8463
  }
8464
+ if (context.options.profile === "edupub") {
8465
+ const sections = root.find(".//html:body//html:section", XHTML_NS);
8466
+ features.sectionCount = (features.sectionCount ?? 0) + sections.length;
8467
+ }
8082
8468
  }
8083
8469
  validateImages(context, path, root) {
8084
8470
  const packageDoc = context.packageDocument;
@@ -8171,6 +8557,158 @@ var ContentValidator = class {
8171
8557
  });
8172
8558
  }
8173
8559
  }
8560
+ this.validateRegionBasedNavRules(context, path, root);
8561
+ }
8562
+ }
8563
+ validateRegionBasedNavRules(context, path, root) {
8564
+ const XHTML_NS2 = { html: "http://www.w3.org/1999/xhtml" };
8565
+ let regionNavs;
8566
+ try {
8567
+ regionNavs = root.find(".//html:nav", XHTML_NS2);
8568
+ } catch {
8569
+ return;
8570
+ }
8571
+ const packageDoc = context.packageDocument;
8572
+ const opfDir = context.opfPath?.includes("/") ? context.opfPath.substring(0, context.opfPath.lastIndexOf("/")) : "";
8573
+ const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
8574
+ const manifestByPath = packageDoc ? new Map(packageDoc.manifest.map((m) => [resolveManifestHref(opfDir, m.href), m])) : void 0;
8575
+ for (const nav of regionNavs) {
8576
+ const epubType = nav.attr("type", "epub")?.value ?? "";
8577
+ if (!epubType.split(/\s+/).includes("region-based")) continue;
8578
+ const childEls = nav.find("./html:*", XHTML_NS2);
8579
+ if (childEls.length !== 1 || childEls[0]?.name !== "ol") {
8580
+ pushMessage(context.messages, {
8581
+ id: MessageId.RSC_017,
8582
+ message: "A region-based nav element must contain exactly one child ol element.",
8583
+ location: { path, line: nav.line }
8584
+ });
8585
+ }
8586
+ const liElements = nav.find(".//html:li", XHTML_NS2);
8587
+ for (const li of liElements) {
8588
+ const liChildren = li.find("./html:*", XHTML_NS2);
8589
+ const first = liChildren[0];
8590
+ if (!first || first.name !== "a" && first.name !== "span") {
8591
+ pushMessage(context.messages, {
8592
+ id: MessageId.RSC_017,
8593
+ message: "The first child of a region-based nav list item must be either an 'a' or 'span' element.",
8594
+ location: { path, line: li.line }
8595
+ });
8596
+ }
8597
+ if (liChildren.length > 1 && (liChildren.length !== 2 || liChildren[1]?.name !== "ol")) {
8598
+ pushMessage(context.messages, {
8599
+ id: MessageId.RSC_017,
8600
+ message: "The first child of a region-based nav list item can only be followed by a single 'ol' element.",
8601
+ location: { path, line: li.line }
8602
+ });
8603
+ }
8604
+ }
8605
+ const spans = nav.find(".//html:span", XHTML_NS2);
8606
+ for (const span of spans) {
8607
+ const spanChildren = span.find("./html:*", XHTML_NS2);
8608
+ const aChildren = spanChildren.filter((c) => c.name === "a");
8609
+ if (spanChildren.length !== 2 || aChildren.length !== 2) {
8610
+ pushMessage(context.messages, {
8611
+ id: MessageId.RSC_017,
8612
+ message: "'span' elements in region-based navs must contain exactly two 'a' elements.",
8613
+ location: { path, line: span.line }
8614
+ });
8615
+ }
8616
+ }
8617
+ const anchors = nav.find(".//html:a", XHTML_NS2);
8618
+ for (const a of anchors) {
8619
+ if (a.content.trim() !== "") {
8620
+ pushMessage(context.messages, {
8621
+ id: MessageId.RSC_017,
8622
+ message: "'a' elements in region-based navs should not contain text labels.",
8623
+ location: { path, line: a.line }
8624
+ });
8625
+ }
8626
+ }
8627
+ if (!packageDoc || !manifestByPath) continue;
8628
+ for (const a of anchors) {
8629
+ const href = a.attr("href")?.value;
8630
+ if (!href || !isRelativeURL(href)) continue;
8631
+ const resolved = this.resolveRelativePath(docDir, href, opfDir);
8632
+ const targetPath = parseURL(resolved).resource;
8633
+ if (!targetPath) continue;
8634
+ const item = manifestByPath.get(targetPath);
8635
+ if (!item) continue;
8636
+ if (!isItemFixedLayout(packageDoc, item.id)) {
8637
+ pushMessage(context.messages, {
8638
+ id: MessageId.NAV_009,
8639
+ message: "Region-based navigation links must point to Fixed-Layout Documents.",
8640
+ location: { path, line: a.line }
8641
+ });
8642
+ }
8643
+ }
8644
+ }
8645
+ }
8646
+ /**
8647
+ * EPUB Dictionaries content document rules.
8648
+ *
8649
+ * Mirrors ../epubcheck/src/main/resources/com/adobe/epubcheck/schema/30/dict/dict-xhtml.sch
8650
+ * (minimum set: `dictionary` must be on body/section with article children; each article or
8651
+ * `dictentry` must have a `dfn` descendant outside of optional `condensed-entry`).
8652
+ */
8653
+ validateDictionaryContent(context, path, root) {
8654
+ let typedElements;
8655
+ try {
8656
+ typedElements = root.find(".//*[@epub:type]", EPUB_OPS_NS);
8657
+ } catch {
8658
+ return;
8659
+ }
8660
+ for (const el of typedElements) {
8661
+ const tokens = el.attr("type", "epub")?.value.split(/\s+/) ?? [];
8662
+ if (tokens.includes("dictionary")) {
8663
+ if (el.name !== "body" && el.name !== "section") {
8664
+ pushMessage(context.messages, {
8665
+ id: MessageId.RSC_005,
8666
+ message: 'The "dictionary" type is only allowed on "body" or "section" elements.',
8667
+ location: { path, line: el.line }
8668
+ });
8669
+ }
8670
+ const articles = el.find("./html:article", XHTML_NS);
8671
+ if (articles.length === 0) {
8672
+ pushMessage(context.messages, {
8673
+ id: MessageId.RSC_005,
8674
+ message: 'A "dictionary" must have at least one article child.',
8675
+ location: { path, line: el.line }
8676
+ });
8677
+ }
8678
+ for (const article of articles) {
8679
+ this.checkDictionaryEntry(context, path, article);
8680
+ }
8681
+ }
8682
+ if (tokens.includes("dictentry")) {
8683
+ if (el.name !== "article") {
8684
+ pushMessage(context.messages, {
8685
+ id: MessageId.RSC_005,
8686
+ message: 'The "dictentry" type is only allowed on "article" elements.',
8687
+ location: { path, line: el.line }
8688
+ });
8689
+ } else {
8690
+ this.checkDictionaryEntry(context, path, el);
8691
+ }
8692
+ }
8693
+ }
8694
+ }
8695
+ checkDictionaryEntry(context, path, article) {
8696
+ const dfns = article.find(".//html:dfn", XHTML_NS);
8697
+ const hasDfnOutsideCondensed = dfns.some((dfn) => {
8698
+ let parent = dfn.parent;
8699
+ while (parent) {
8700
+ const type = parent.attr("type", "epub")?.value;
8701
+ if (type?.split(/\s+/).includes("condensed-entry")) return false;
8702
+ parent = parent.parent;
8703
+ }
8704
+ return true;
8705
+ });
8706
+ if (!hasDfnOutsideCondensed) {
8707
+ pushMessage(context.messages, {
8708
+ id: MessageId.RSC_005,
8709
+ message: 'A dictionary entry must have at least one "dfn" descendant (outside of the optional condensed entry "aside").',
8710
+ location: { path, line: article.line }
8711
+ });
8174
8712
  }
8175
8713
  }
8176
8714
  /**
@@ -9567,13 +10105,11 @@ var ContentValidator = class {
9567
10105
  }
9568
10106
  }
9569
10107
  checkUnknownElements(context, path, root) {
9570
- const XHTML_NS2 = "http://www.w3.org/1999/xhtml";
9571
10108
  try {
9572
10109
  const allElements = root.find(".//*");
9573
10110
  for (const el of allElements) {
9574
10111
  const xmlEl = el;
9575
- const ns = xmlEl.namespaceUri;
9576
- if (ns !== XHTML_NS2) continue;
10112
+ if (xmlEl.namespaceUri !== XHTML_NS_URI) continue;
9577
10113
  const localName = xmlEl.name.includes(":") ? xmlEl.name.substring(xmlEl.name.indexOf(":") + 1) : xmlEl.name;
9578
10114
  if (localName.includes("-")) continue;
9579
10115
  if (!HTML5_ELEMENTS.has(localName)) {
@@ -9587,6 +10123,51 @@ var ContentValidator = class {
9587
10123
  } catch {
9588
10124
  }
9589
10125
  }
10126
+ checkEpub2XhtmlStrict(context, path, root) {
10127
+ if (!root.namespaceUri) {
10128
+ pushMessage(context.messages, {
10129
+ id: MessageId.RSC_005,
10130
+ message: `element "${root.name}" from namespace "" is not allowed`,
10131
+ location: { path, line: root.line }
10132
+ });
10133
+ return;
10134
+ }
10135
+ const checkElement = (xmlEl) => {
10136
+ if (xmlEl.namespaceUri !== XHTML_NS_URI) return;
10137
+ const localName = xmlEl.name.includes(":") ? xmlEl.name.substring(xmlEl.name.indexOf(":") + 1) : xmlEl.name;
10138
+ if (!XHTML11_ELEMENTS.has(localName)) {
10139
+ pushMessage(context.messages, {
10140
+ id: MessageId.RSC_005,
10141
+ message: `element "${localName}" not allowed here`,
10142
+ location: { path, line: xmlEl.line }
10143
+ });
10144
+ }
10145
+ for (const attr of xmlEl.attrs) {
10146
+ const ns = attr.namespaceUri;
10147
+ if (!ns || ns === XHTML_NS_URI || ns === XML_NS_URI) continue;
10148
+ const qname = attr.prefix ? `${attr.prefix}:${attr.name}` : attr.name;
10149
+ pushMessage(context.messages, {
10150
+ id: MessageId.RSC_005,
10151
+ message: `attribute "${qname}" not allowed here`,
10152
+ location: { path, line: xmlEl.line }
10153
+ });
10154
+ }
10155
+ };
10156
+ checkElement(root);
10157
+ try {
10158
+ for (const el of root.find(".//*")) {
10159
+ checkElement(el);
10160
+ }
10161
+ for (const a of root.find(".//html:a//html:a", XHTML_NS)) {
10162
+ pushMessage(context.messages, {
10163
+ id: MessageId.RSC_005,
10164
+ message: 'The "a" element cannot contain any nested "a" elements',
10165
+ location: { path, line: a.line }
10166
+ });
10167
+ }
10168
+ } catch {
10169
+ }
10170
+ }
9590
10171
  checkForeignObjectContent(context, path, root, isSVGDoc) {
9591
10172
  const SVG_NS = { svg: "http://www.w3.org/2000/svg" };
9592
10173
  const XHTML_URI = "http://www.w3.org/1999/xhtml";
@@ -9795,6 +10376,7 @@ var NCXValidator = class {
9795
10376
  this.checkContentSrc(context, root, ncxPath, registry);
9796
10377
  this.checkEmptyLabels(context, root, ncxPath);
9797
10378
  this.checkPageTargets(context, root, ncxPath);
10379
+ this.checkIdSyntax(context, root, ncxPath);
9798
10380
  } finally {
9799
10381
  doc.dispose();
9800
10382
  }
@@ -9818,6 +10400,13 @@ var NCXValidator = class {
9818
10400
  });
9819
10401
  return;
9820
10402
  }
10403
+ if (uidContent !== uidContent.trim()) {
10404
+ pushMessage(context.messages, {
10405
+ id: MessageId.NCX_004,
10406
+ message: "NCX dtb:uid meta content has leading or trailing whitespace.",
10407
+ location: { path, line: uidElement.line }
10408
+ });
10409
+ }
9821
10410
  context.ncxUid = uidContent.trim();
9822
10411
  }
9823
10412
  checkNavMap(context, root, path) {
@@ -9898,10 +10487,21 @@ var NCXValidator = class {
9898
10487
  }
9899
10488
  }
9900
10489
  }
9901
- /**
9902
- * pageTarget@type must be one of "front", "normal", "special".
9903
- * Reported via RSC-005 to mirror Java Schematron output.
9904
- */
10490
+ checkIdSyntax(context, root, ncxPath) {
10491
+ const nodes = root.find(".//*[@id]", {
10492
+ ncx: "http://www.daisy.org/z3986/2005/ncx/"
10493
+ });
10494
+ for (const node of nodes) {
10495
+ const idValue = node.attr("id")?.value;
10496
+ if (idValue && !NC_NAME_REGEX.test(idValue)) {
10497
+ pushMessage(context.messages, {
10498
+ id: MessageId.RSC_005,
10499
+ message: `Invalid id "${idValue}"; must match xs:ID syntax (NCName)`,
10500
+ location: { path: ncxPath, line: node.line }
10501
+ });
10502
+ }
10503
+ }
10504
+ }
9905
10505
  checkPageTargets(context, root, ncxPath) {
9906
10506
  const pageTargets = root.find(".//ncx:pageTarget[@type]", {
9907
10507
  ncx: "http://www.daisy.org/z3986/2005/ncx/"
@@ -9919,6 +10519,7 @@ var NCXValidator = class {
9919
10519
  }
9920
10520
  }
9921
10521
  };
10522
+ var NC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9._-]*$/;
9922
10523
  var PAGE_TARGET_TYPES = /* @__PURE__ */ new Set(["front", "normal", "special"]);
9923
10524
  var OPS_MEDIA_TYPES = /* @__PURE__ */ new Set([
9924
10525
  "application/xhtml+xml",
@@ -10048,15 +10649,6 @@ function parseContainerContent(content, context, fileExists, getFileContent) {
10048
10649
  }
10049
10650
  }
10050
10651
  }
10051
- for (const rootfile of context.rootfiles) {
10052
- if (!fileExists(rootfile.path)) {
10053
- pushMessage(context.messages, {
10054
- id: MessageId.PKG_010,
10055
- message: `Rootfile "${rootfile.path}" not found in EPUB`,
10056
- location: { path: containerPath }
10057
- });
10058
- }
10059
- }
10060
10652
  }
10061
10653
  function validateMappingDocumentContent(xml, mappingPath, context) {
10062
10654
  const stripped = stripXmlComments(xml);
@@ -10444,8 +11036,8 @@ var OCFValidator = class {
10444
11036
  zip = ZipReader.open(context.data);
10445
11037
  } catch (error) {
10446
11038
  pushMessage(context.messages, {
10447
- id: MessageId.PKG_001,
10448
- message: `Failed to open EPUB file: ${error instanceof Error ? error.message : "Unknown error"}`
11039
+ id: MessageId.PKG_004,
11040
+ message: `Failed to open EPUB ZIP: ${error instanceof Error ? error.message : "Unknown error"}`
10449
11041
  });
10450
11042
  return;
10451
11043
  }
@@ -10479,8 +11071,8 @@ var OCFValidator = class {
10479
11071
  const compressionInfo = zip.getMimetypeCompressionInfo();
10480
11072
  if (compressionInfo === null) {
10481
11073
  pushMessage(messages, {
10482
- id: MessageId.PKG_006,
10483
- message: "Could not read ZIP header",
11074
+ id: MessageId.PKG_003,
11075
+ message: "Unable to read EPUB file header, likely corrupted",
10484
11076
  location: { path: "mimetype" }
10485
11077
  });
10486
11078
  return;
@@ -10975,19 +11567,7 @@ var ReferenceValidator = class {
10975
11567
  message: "Absolute paths are not allowed in EPUB",
10976
11568
  location: reference.location
10977
11569
  });
10978
- }
10979
- const forbiddenParentDirTypes = [
10980
- "hyperlink" /* HYPERLINK */,
10981
- "nav-toc-link" /* NAV_TOC_LINK */,
10982
- "nav-pagelist-link" /* NAV_PAGELIST_LINK */
10983
- ];
10984
- if (hasParentDirectoryReference(reference.url) && forbiddenParentDirTypes.includes(reference.type)) {
10985
- pushMessage(context.messages, {
10986
- id: MessageId.RSC_026,
10987
- message: "Parent directory references (..) are not allowed",
10988
- location: reference.location
10989
- });
10990
- } else if (!hasAbsolutePath(resourcePath) && !hasParentDirectoryReference(reference.url) && checkUrlLeaking(reference.url)) {
11570
+ } else if (checkUrlLeaking(reference.url, reference.location.path)) {
10991
11571
  pushMessage(context.messages, {
10992
11572
  id: MessageId.RSC_026,
10993
11573
  message: `URL "${reference.url}" leaks outside the container`,
@@ -11025,6 +11605,16 @@ var ReferenceValidator = class {
11025
11605
  location: reference.location
11026
11606
  });
11027
11607
  }
11608
+ if (reference.type === "search-key" /* SEARCH_KEY */ && !resource?.inSpine) {
11609
+ const isEpubCfi = reference.fragment?.startsWith("epubcfi(") ?? false;
11610
+ if (!isEpubCfi) {
11611
+ pushMessage(context.messages, {
11612
+ id: MessageId.RSC_021,
11613
+ message: `Search Key Map target "${resourcePath}" must be a Content Document in the spine`,
11614
+ location: reference.location
11615
+ });
11616
+ }
11617
+ }
11028
11618
  if (isHyperlinkLike || reference.type === "overlay-text-link" /* OVERLAY_TEXT_LINK */) {
11029
11619
  const targetMimeType = resource?.mimeType;
11030
11620
  if (targetMimeType && !this.isBlessedItemType(targetMimeType, context.version) && !this.isDeprecatedBlessedItemType(targetMimeType) && !resource.hasCoreMediaTypeFallback) {
@@ -11509,11 +12099,11 @@ var RelaxNGValidator = class extends BaseSchemaValidator {
11509
12099
  try {
11510
12100
  const libxml2 = await import('libxml2-wasm');
11511
12101
  const LibRelaxNGValidator = libxml2.RelaxNGValidator;
11512
- const { XmlDocument: XmlDocument4 } = libxml2;
11513
- const doc = XmlDocument4.fromString(xml);
12102
+ const { XmlDocument: XmlDocument5 } = libxml2;
12103
+ const doc = XmlDocument5.fromString(xml);
11514
12104
  try {
11515
12105
  const schemaContent = await loadSchema(schemaPath);
11516
- const schemaDoc = XmlDocument4.fromString(schemaContent);
12106
+ const schemaDoc = XmlDocument5.fromString(schemaContent);
11517
12107
  try {
11518
12108
  const validator = LibRelaxNGValidator.fromDoc(schemaDoc);
11519
12109
  try {
@@ -11747,7 +12337,7 @@ var EpubCheck = class _EpubCheck {
11747
12337
  await this.runPipeline(context);
11748
12338
  } catch (error) {
11749
12339
  pushMessage(context.messages, {
11750
- id: MessageId.PKG_025,
12340
+ id: MessageId.PKG_008,
11751
12341
  message: error instanceof Error ? error.message : "Unknown validation error"
11752
12342
  });
11753
12343
  } finally {
@@ -11789,7 +12379,7 @@ var EpubCheck = class _EpubCheck {
11789
12379
  await this.runPipeline(context);
11790
12380
  } catch (error) {
11791
12381
  pushMessage(context.messages, {
11792
- id: MessageId.PKG_025,
12382
+ id: MessageId.PKG_008,
11793
12383
  message: error instanceof Error ? error.message : "Unknown validation error"
11794
12384
  });
11795
12385
  } finally {
@@ -11856,7 +12446,7 @@ var EpubCheck = class _EpubCheck {
11856
12446
  }
11857
12447
  } catch (error) {
11858
12448
  pushMessage(context.messages, {
11859
- id: MessageId.PKG_025,
12449
+ id: MessageId.PKG_008,
11860
12450
  message: error instanceof Error ? error.message : "Unknown validation error"
11861
12451
  });
11862
12452
  } finally {
@@ -11906,6 +12496,15 @@ var EpubCheck = class _EpubCheck {
11906
12496
  const profile = context.options.profile;
11907
12497
  const opfPath = context.opfPath ?? "";
11908
12498
  if (profile === "edupub") {
12499
+ const sectionCount = features.sectionCount ?? 0;
12500
+ const tocLinkCount = features.tocLinkCount ?? 0;
12501
+ if (sectionCount > 0 && sectionCount !== tocLinkCount) {
12502
+ pushMessage(context.messages, {
12503
+ id: MessageId.NAV_004,
12504
+ message: "The Navigation Document should contain the full hierarchy of headings in the document for EDUPUB.",
12505
+ location: { path: opfPath }
12506
+ });
12507
+ }
11909
12508
  if (features.hasPageBreak && !features.hasPageList) {
11910
12509
  pushMessage(context.messages, {
11911
12510
  id: MessageId.NAV_003,
@@ -11977,6 +12576,73 @@ var EpubCheck = class _EpubCheck {
11977
12576
  location: { path: opfPath }
11978
12577
  });
11979
12578
  }
12579
+ if (profile === "dict" && context.packageDocument) {
12580
+ const opfDir = opfPath.includes("/") ? opfPath.substring(0, opfPath.lastIndexOf("/")) : "";
12581
+ const manifestByPath = /* @__PURE__ */ new Map();
12582
+ for (const item of context.packageDocument.manifest) {
12583
+ manifestByPath.set(resolveManifestHref(opfDir, item.href), item);
12584
+ }
12585
+ const dictPaths = features.dictionaryContentPaths ?? /* @__PURE__ */ new Set();
12586
+ for (const coll of context.packageDocument.collections) {
12587
+ if (coll.role !== "dictionary") continue;
12588
+ const hasDictContent = coll.links.some((href) => {
12589
+ const full = resolveManifestHref(opfDir, href);
12590
+ const item = manifestByPath.get(full);
12591
+ return item?.mediaType === "application/xhtml+xml" && dictPaths.has(full);
12592
+ });
12593
+ if (!hasDictContent) {
12594
+ pushMessage(context.messages, {
12595
+ id: MessageId.OPF_078,
12596
+ message: 'An EPUB Dictionary must contain at least one Content Document with dictionary content (epub:type "dictionary")',
12597
+ location: { path: opfPath }
12598
+ });
12599
+ }
12600
+ }
12601
+ }
12602
+ }
12603
+ /**
12604
+ * Mirrors ../epubcheck/src/main/resources/com/adobe/epubcheck/schema/30/edupub/
12605
+ * edu-ocf-metadata.sch (META-INF/metadata.xml) and edu-opf.sch (each OPF).
12606
+ *
12607
+ * Non-primary OPFs are otherwise unreached: runPipeline only runs OPFValidator
12608
+ * on context.opfPath.
12609
+ */
12610
+ validateEdupubMultiRendition(context) {
12611
+ if (context.options.profile !== "edupub") return;
12612
+ if (context.rootfiles.length <= 1) return;
12613
+ const metadataPath = "META-INF/metadata.xml";
12614
+ const metadataData = context.files.get(metadataPath);
12615
+ if (metadataData) {
12616
+ const content = typeof metadataData === "string" ? metadataData : new TextDecoder().decode(metadataData);
12617
+ const stripped = stripXmlComments(content);
12618
+ if (!/<dc:type\b[^>]*>\s*edupub\s*<\/dc:type>/i.test(stripped)) {
12619
+ pushMessage(context.messages, {
12620
+ id: MessageId.RSC_005,
12621
+ message: 'A dc:type element with the value "edupub" is required.',
12622
+ location: { path: metadataPath }
12623
+ });
12624
+ }
12625
+ }
12626
+ const primary = context.opfPath;
12627
+ for (const rootfile of context.rootfiles) {
12628
+ if (rootfile.path === primary) continue;
12629
+ if (rootfile.mediaType !== "application/oebps-package+xml") continue;
12630
+ const path = rootfile.path.normalize("NFC");
12631
+ const opfData = context.files.get(path);
12632
+ if (!opfData) continue;
12633
+ const xml = typeof opfData === "string" ? opfData : new TextDecoder().decode(opfData);
12634
+ const pkg = parseOPF(xml);
12635
+ const hasType = pkg.dcElements.some(
12636
+ (dc) => dc.name === "type" && dc.value.trim() === "edupub"
12637
+ );
12638
+ if (!hasType) {
12639
+ pushMessage(context.messages, {
12640
+ id: MessageId.RSC_005,
12641
+ message: 'The dc:type identifier "edupub" is required.',
12642
+ location: { path }
12643
+ });
12644
+ }
12645
+ }
11980
12646
  }
11981
12647
  /**
11982
12648
  * Validate NCX navigation document (EPUB 2 always, EPUB 3 when NCX present)
@@ -12122,6 +12788,7 @@ var EpubCheck = class _EpubCheck {
12122
12788
  const contentValidator = new ContentValidator();
12123
12789
  contentValidator.validate(context, registry, refValidator);
12124
12790
  this.validateCrossDocumentFeatures(context);
12791
+ this.validateEdupubMultiRendition(context);
12125
12792
  if (context.packageDocument) {
12126
12793
  this.validateNCX(context, registry);
12127
12794
  }
@@ -12178,7 +12845,14 @@ var EpubCheck = class _EpubCheck {
12178
12845
  message: "For maximum compatibility, use only lowercase characters for the EPUB file extension.",
12179
12846
  location: { path: filename }
12180
12847
  });
12848
+ return;
12181
12849
  }
12850
+ const isEpub2 = context.version.startsWith("2");
12851
+ pushMessage(context.messages, {
12852
+ id: isEpub2 ? MessageId.PKG_017 : MessageId.PKG_024,
12853
+ message: `EPUB file has an uncommon extension "${extension}".`,
12854
+ location: { path: filename }
12855
+ });
12182
12856
  }
12183
12857
  /**
12184
12858
  * Build a filtered report from validation context