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