@likecoin/epubcheck-ts 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1666,143 +1666,79 @@ 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
- ]);
1669
+ // src/references/url.ts
1670
+ function parseURL(urlString) {
1671
+ const hashIndex = urlString.indexOf("#");
1672
+ if (hashIndex === -1) {
1673
+ return {
1674
+ url: urlString,
1675
+ resource: urlString,
1676
+ hasFragment: false
1677
+ };
1678
+ }
1679
+ const resource = urlString.substring(0, hashIndex);
1680
+ const fragment = urlString.substring(hashIndex + 1);
1681
+ const result = {
1682
+ url: urlString,
1683
+ resource,
1684
+ hasFragment: true
1685
+ };
1686
+ if (fragment) {
1687
+ result.fragment = fragment;
1688
+ }
1689
+ return result;
1690
+ }
1691
+ function isDataURL(url) {
1692
+ return url.startsWith("data:");
1693
+ }
1694
+ function isFileURL(url) {
1695
+ return url.startsWith("file:");
1696
+ }
1697
+ function isRelativeURL(url) {
1698
+ const regex = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
1699
+ return regex.exec(url) === null;
1700
+ }
1701
+ function hasAbsolutePath(url) {
1702
+ return url.startsWith("/");
1703
+ }
1704
+ function isMalformedURL(url) {
1705
+ if (!url.trim()) return true;
1706
+ if (/[\s<>]/.test(url)) return true;
1707
+ return false;
1708
+ }
1709
+ function isHTTPS(url) {
1710
+ return url.startsWith("https://");
1711
+ }
1712
+ function isHTTP(url) {
1713
+ return url.startsWith("http://");
1714
+ }
1715
+ function isRemoteURL(url) {
1716
+ return isHTTP(url) || isHTTPS(url);
1717
+ }
1718
+ function checkUrlLeaking(href, resourcePath) {
1719
+ const TEST_BASE_A = "https://a.example.org/A/";
1720
+ const TEST_BASE_B = "https://b.example.org/B/";
1721
+ try {
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();
1726
+ return !urlA.startsWith(TEST_BASE_A) || !urlB.startsWith(TEST_BASE_B);
1727
+ } catch {
1728
+ return false;
1729
+ }
1730
+ }
1731
+ function resolveManifestHref(opfDir, href) {
1732
+ if (isRemoteURL(href)) return href;
1733
+ try {
1734
+ const decoded = decodeURIComponent(href);
1735
+ const path = opfDir ? `${opfDir}/${decoded}` : decoded;
1736
+ return path.normalize("NFC");
1737
+ } catch {
1738
+ const path = opfDir ? `${opfDir}/${href}` : href;
1739
+ return path.normalize("NFC");
1740
+ }
1741
+ }
1806
1742
 
1807
1743
  // src/smil/clock.ts
1808
1744
  var FULL_CLOCK_RE = /^(\d+):([0-5]\d):([0-5]\d)(\.\d+)?$/;
@@ -1848,491 +1784,91 @@ function isValidSmilClock(value) {
1848
1784
  return !Number.isNaN(parseSmilClock(value));
1849
1785
  }
1850
1786
 
1851
- // src/smil/validator.ts
1852
- var SMIL_NS = { smil: "http://www.w3.org/ns/SMIL" };
1853
- var BLESSED_AUDIO_TYPES = /* @__PURE__ */ new Set(["audio/mpeg", "audio/mp4"]);
1854
- var BLESSED_AUDIO_PATTERN = /^audio\/ogg\s*;\s*codecs=opus$/i;
1855
- function isBlessedAudioType(mimeType) {
1856
- return BLESSED_AUDIO_TYPES.has(mimeType) || BLESSED_AUDIO_PATTERN.test(mimeType);
1857
- }
1858
- var SMILValidator = class {
1859
- getAttribute(element, name) {
1860
- return element.attr(name)?.value ?? null;
1787
+ // src/types.ts
1788
+ var EPUB_VERSIONS = ["2.0", "3.0", "3.1", "3.2", "3.3"];
1789
+
1790
+ // src/util/doctype.ts
1791
+ var DOCTYPE_RE = /<!DOCTYPE\s+(\w+)\s*([^>]*)>/i;
1792
+ var PUBLIC_ID_RE = /\bPUBLIC\s+"([^"]*)"\s+"([^"]*)"/i;
1793
+ var SYSTEM_ID_RE = /\bSYSTEM\s+"([^"]*)"/i;
1794
+ var DEFAULT_MAX_BYTES = 2048;
1795
+ function parseDoctype(content, options = {}) {
1796
+ const { expectedRoot, maxBytes = DEFAULT_MAX_BYTES } = options;
1797
+ const scanned = content.length > maxBytes ? content.slice(0, maxBytes) : content;
1798
+ const doctypeMatch = DOCTYPE_RE.exec(scanned);
1799
+ if (!doctypeMatch) return null;
1800
+ const root = doctypeMatch[1] ?? "";
1801
+ if (expectedRoot && root.toLowerCase() !== expectedRoot.toLowerCase()) return null;
1802
+ const inner = doctypeMatch[2] ?? "";
1803
+ const publicMatch = PUBLIC_ID_RE.exec(inner);
1804
+ if (publicMatch) {
1805
+ return { root, publicId: publicMatch[1] ?? "", systemId: publicMatch[2] ?? "" };
1861
1806
  }
1862
- getEpubAttribute(element, localName) {
1863
- return element.attr(localName, "epub")?.value ?? null;
1807
+ const systemMatch = SYSTEM_ID_RE.exec(inner);
1808
+ if (systemMatch) {
1809
+ return { root, publicId: "", systemId: systemMatch[1] ?? "" };
1864
1810
  }
1865
- validate(context, path, manifestByPath) {
1866
- const result = {
1867
- textReferences: [],
1868
- referencedDocuments: /* @__PURE__ */ new Set(),
1869
- hasRemoteResources: false
1870
- };
1871
- const data = context.files.get(path);
1872
- if (!data) return result;
1873
- const content = typeof data === "string" ? data : new TextDecoder().decode(data);
1874
- let doc = null;
1875
- try {
1876
- doc = XmlDocument.fromString(content);
1877
- } catch {
1878
- pushMessage(context.messages, {
1879
- id: MessageId.RSC_016,
1880
- message: "Media Overlay document is not well-formed XML",
1881
- location: { path }
1882
- });
1883
- return result;
1884
- }
1885
- try {
1886
- const root = doc.root;
1887
- this.validateStructure(context, path, root);
1888
- this.validateAudioElements(context, path, root, manifestByPath, result);
1889
- this.validateEpubTypes(context, path, root);
1890
- this.extractTextReferences(path, root, result);
1891
- } finally {
1892
- doc.dispose();
1811
+ return { root, publicId: "", systemId: "" };
1812
+ }
1813
+
1814
+ // src/util/encoding.ts
1815
+ function sniffXmlEncoding(data) {
1816
+ if (data.length < 2) return null;
1817
+ if (data.length >= 4) {
1818
+ if (data[0] === 0 && data[1] === 0 && data[2] === 254 && data[3] === 255) {
1819
+ return "UCS-4";
1893
1820
  }
1894
- return result;
1895
- }
1896
- validateStructure(context, path, root) {
1897
- try {
1898
- for (const text of root.find(".//smil:seq/smil:text", SMIL_NS)) {
1899
- pushMessage(context.messages, {
1900
- id: MessageId.RSC_005,
1901
- message: "element 'text' not allowed here; expected 'seq' or 'par'",
1902
- location: { path, line: text.line }
1903
- });
1904
- }
1905
- for (const audio of root.find(".//smil:seq/smil:audio", SMIL_NS)) {
1906
- pushMessage(context.messages, {
1907
- id: MessageId.RSC_005,
1908
- message: "element 'audio' not allowed here; expected 'seq' or 'par'",
1909
- location: { path, line: audio.line }
1910
- });
1911
- }
1912
- } catch {
1821
+ if (data[0] === 255 && data[1] === 254 && data[2] === 0 && data[3] === 0) {
1822
+ return "UCS-4";
1913
1823
  }
1914
- try {
1915
- for (const seq of root.find(".//smil:par/smil:seq", SMIL_NS)) {
1916
- pushMessage(context.messages, {
1917
- id: MessageId.RSC_005,
1918
- message: "element 'seq' not allowed here; expected 'text' or 'audio'",
1919
- location: { path, line: seq.line }
1920
- });
1921
- }
1922
- const parElements = root.find(".//smil:par", SMIL_NS);
1923
- for (const par of parElements) {
1924
- const textChildren = par.find("./smil:text", SMIL_NS);
1925
- for (let i = 1; i < textChildren.length; i++) {
1926
- const extra = textChildren[i];
1927
- if (!extra) continue;
1928
- pushMessage(context.messages, {
1929
- id: MessageId.RSC_005,
1930
- message: "element 'text' not allowed here; only one 'text' element is allowed in 'par'",
1931
- location: { path, line: extra.line }
1932
- });
1933
- }
1934
- }
1935
- } catch {
1824
+ if (data[0] === 0 && data[1] === 0 && data[2] === 255 && data[3] === 254) {
1825
+ return "UCS-4";
1936
1826
  }
1937
- try {
1938
- const headMetaElements = root.find(".//smil:head/smil:meta", SMIL_NS);
1939
- for (const meta of headMetaElements) {
1940
- pushMessage(context.messages, {
1941
- id: MessageId.RSC_005,
1942
- message: "element 'meta' not allowed here; expected 'metadata'",
1943
- location: { path, line: meta.line }
1944
- });
1945
- }
1946
- } catch {
1827
+ if (data[0] === 254 && data[1] === 255 && data[2] === 0 && data[3] === 0) {
1828
+ return "UCS-4";
1947
1829
  }
1948
- }
1949
- validateAudioElements(context, path, root, manifestByPath, result) {
1950
- try {
1951
- const audioElements = root.find(".//smil:audio", SMIL_NS);
1952
- for (const audio of audioElements) {
1953
- const elem = audio;
1954
- const src = this.getAttribute(elem, "src");
1955
- if (src) {
1956
- if (/^https?:\/\//i.test(src)) {
1957
- result.hasRemoteResources = true;
1958
- }
1959
- if (src.includes("#")) {
1960
- pushMessage(context.messages, {
1961
- id: MessageId.MED_014,
1962
- message: `Media overlay audio file URLs must not have a fragment: "${src}"`,
1963
- location: { path, line: audio.line }
1964
- });
1965
- }
1966
- if (manifestByPath) {
1967
- const audioPath = this.resolveRelativePath(path, src.split("#")[0] ?? src);
1968
- const audioItem = manifestByPath.get(audioPath);
1969
- if (audioItem && !isBlessedAudioType(audioItem.mediaType)) {
1970
- pushMessage(context.messages, {
1971
- id: MessageId.MED_005,
1972
- message: `Media Overlay audio reference "${src}" to non-standard audio type "${audioItem.mediaType}"`,
1973
- location: { path, line: audio.line }
1974
- });
1975
- }
1976
- }
1977
- }
1978
- const clipBegin = this.getAttribute(elem, "clipBegin");
1979
- const clipEnd = this.getAttribute(elem, "clipEnd");
1980
- this.checkClipTiming(context, path, audio.line, clipBegin, clipEnd);
1981
- }
1982
- } catch {
1830
+ if (data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 60) {
1831
+ return "UCS-4";
1983
1832
  }
1984
- }
1985
- checkClipTiming(context, path, line, clipBegin, clipEnd) {
1986
- if (clipEnd === null) return;
1987
- const beginStr = clipBegin ?? "0";
1988
- const start = parseSmilClock(beginStr);
1989
- const end = parseSmilClock(clipEnd);
1990
- const location = line != null ? { path, line } : { path };
1991
- if (clipBegin !== null && Number.isNaN(start)) {
1992
- pushMessage(context.messages, {
1993
- id: MessageId.RSC_005,
1994
- message: `Invalid SMIL clock value "${clipBegin}" in clipBegin attribute`,
1995
- location
1996
- });
1833
+ if (data[0] === 60 && data[1] === 0 && data[2] === 0 && data[3] === 0) {
1834
+ return "UCS-4";
1997
1835
  }
1998
- if (Number.isNaN(end)) {
1999
- pushMessage(context.messages, {
2000
- id: MessageId.RSC_005,
2001
- message: `Invalid SMIL clock value "${clipEnd}" in clipEnd attribute`,
2002
- location
2003
- });
1836
+ if (data[0] === 0 && data[1] === 0 && data[2] === 60 && data[3] === 0) {
1837
+ return "UCS-4";
2004
1838
  }
2005
- if (Number.isNaN(start) || Number.isNaN(end)) return;
2006
- if (start > end) {
2007
- pushMessage(context.messages, {
2008
- id: MessageId.MED_008,
2009
- message: "The time specified in the clipBegin attribute must not be after clipEnd",
2010
- location
2011
- });
2012
- } else if (start === end) {
2013
- pushMessage(context.messages, {
2014
- id: MessageId.MED_009,
2015
- message: "The time specified in the clipBegin attribute must not be the same as clipEnd",
2016
- location
2017
- });
1839
+ if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 0) {
1840
+ return "UCS-4";
2018
1841
  }
2019
1842
  }
2020
- /**
2021
- * Validate epub:type attribute values against the EPUB SSV vocabulary.
2022
- * Only emits OPF-088 (usage) for unknown local names. Prefixed values
2023
- * from declared vocabularies are allowed.
2024
- */
2025
- validateEpubTypes(context, path, root) {
2026
- try {
2027
- const epubTypeElements = root.find(".//*[@epub:type]", {
2028
- epub: "http://www.idpf.org/2007/ops"
2029
- });
2030
- for (const elem of epubTypeElements) {
2031
- const elemTyped = elem;
2032
- const epubTypeAttr = elemTyped.attr("type", "epub");
2033
- if (!epubTypeAttr?.value) continue;
2034
- for (const part of epubTypeAttr.value.split(/\s+/)) {
2035
- if (!part || part.includes(":")) continue;
2036
- if (!EPUB_SSV_ALL.has(part)) {
2037
- pushMessage(context.messages, {
2038
- id: MessageId.OPF_088,
2039
- message: `Unrecognized epub:type value "${part}"`,
2040
- location: { path, line: elem.line }
2041
- });
2042
- }
2043
- }
2044
- }
2045
- } catch {
2046
- }
1843
+ if (data[0] === 254 && data[1] === 255) {
1844
+ return "UTF-16";
2047
1845
  }
2048
- extractTextReferences(path, root, result) {
2049
- try {
2050
- const textElements = root.find(".//smil:text", SMIL_NS);
2051
- for (const text of textElements) {
2052
- const src = this.getAttribute(text, "src");
2053
- if (!src) continue;
2054
- const hashIndex = src.indexOf("#");
2055
- const docRef = hashIndex >= 0 ? src.substring(0, hashIndex) : src;
2056
- const fragment = hashIndex >= 0 ? src.substring(hashIndex + 1) : void 0;
2057
- const docPath = this.resolveRelativePath(path, docRef);
2058
- result.textReferences.push({ docPath, fragment, line: text.line });
2059
- result.referencedDocuments.add(docPath);
2060
- }
2061
- const bodyElements = root.find(".//smil:body", SMIL_NS);
2062
- const seqElements = root.find(".//smil:seq", SMIL_NS);
2063
- for (const elem of [...bodyElements, ...seqElements]) {
2064
- const textref = this.getEpubAttribute(elem, "textref");
2065
- if (!textref) continue;
2066
- const hashIndex = textref.indexOf("#");
2067
- const docRef = hashIndex >= 0 ? textref.substring(0, hashIndex) : textref;
2068
- const fragment = hashIndex >= 0 ? textref.substring(hashIndex + 1) : void 0;
2069
- const docPath = this.resolveRelativePath(path, docRef);
2070
- result.textReferences.push({ docPath, fragment, line: elem.line });
2071
- result.referencedDocuments.add(docPath);
2072
- }
2073
- } catch {
2074
- }
1846
+ if (data[0] === 255 && data[1] === 254) {
1847
+ return "UTF-16";
2075
1848
  }
2076
- resolveRelativePath(basePath, relativePath) {
2077
- if (relativePath.startsWith("/") || /^[a-zA-Z]+:/.test(relativePath)) {
2078
- return relativePath;
1849
+ if (data.length >= 4) {
1850
+ if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 63) {
1851
+ return "UTF-16";
2079
1852
  }
2080
- const baseDir = basePath.includes("/") ? basePath.substring(0, basePath.lastIndexOf("/")) : "";
2081
- if (!baseDir) return relativePath;
2082
- const segments = `${baseDir}/${relativePath}`.split("/");
2083
- const resolved = [];
2084
- for (const seg of segments) {
2085
- if (seg === "..") {
2086
- resolved.pop();
2087
- } else if (seg !== ".") {
2088
- resolved.push(seg);
2089
- }
1853
+ if (data[0] === 60 && data[1] === 0 && data[2] === 63 && data[3] === 0) {
1854
+ return "UTF-16";
2090
1855
  }
2091
- return resolved.join("/");
2092
1856
  }
2093
- };
2094
-
2095
- // src/opf/types.ts
2096
- var CORE_MEDIA_TYPES = /* @__PURE__ */ new Set([
2097
- // Image types
2098
- "image/gif",
2099
- "image/jpeg",
2100
- "image/png",
2101
- "image/svg+xml",
2102
- "image/webp",
2103
- // Audio types
2104
- "audio/mpeg",
2105
- "audio/mp4",
2106
- "audio/ogg",
2107
- // CSS
2108
- "text/css",
2109
- // Fonts
2110
- "font/otf",
2111
- "font/ttf",
2112
- "font/woff",
2113
- "font/woff2",
2114
- "application/font-sfnt",
2115
- // deprecated alias for font/otf, font/ttf
2116
- "application/font-woff",
2117
- // deprecated alias for font/woff
2118
- "application/vnd.ms-opentype",
2119
- // deprecated alias
2120
- // Content documents
2121
- "application/xhtml+xml",
2122
- "application/x-dtbncx+xml",
2123
- // NCX
2124
- // JavaScript (EPUB 3)
2125
- "text/javascript",
2126
- "application/javascript",
2127
- // Media overlays
2128
- "application/smil+xml",
2129
- // PLS (Pronunciation Lexicon)
2130
- "application/pls+xml"
2131
- ]);
2132
- function isCoreMediaType(mimeType) {
2133
- if (CORE_MEDIA_TYPES.has(mimeType)) return true;
2134
- if (mimeType.startsWith("video/")) return true;
2135
- if (/^audio\/ogg\s*;\s*codecs=opus$/i.test(mimeType)) return true;
2136
- const semicolonIndex = mimeType.indexOf(";");
2137
- if (semicolonIndex >= 0) {
2138
- const baseType = mimeType.substring(0, semicolonIndex).trim();
2139
- if (CORE_MEDIA_TYPES.has(baseType)) return true;
2140
- if (baseType.startsWith("video/")) return true;
2141
- }
2142
- return false;
2143
- }
2144
- var ITEM_PROPERTIES = /* @__PURE__ */ new Set([
2145
- "cover-image",
2146
- "data-nav",
2147
- "dictionary",
2148
- "glossary",
2149
- "index",
2150
- "mathml",
2151
- "nav",
2152
- "remote-resources",
2153
- "scripted",
2154
- "search-key-map",
2155
- "svg",
2156
- "switch"
2157
- ]);
2158
- var LINK_PROPERTIES = /* @__PURE__ */ new Set(["onix", "marc21xml-record", "mods-record", "xmp-record"]);
2159
- var SPINE_PROPERTIES = /* @__PURE__ */ new Set([
2160
- "page-spread-left",
2161
- "page-spread-right",
2162
- "rendition:spread-none",
2163
- "rendition:spread-landscape",
2164
- "rendition:spread-portrait",
2165
- "rendition:spread-both",
2166
- "rendition:spread-auto",
2167
- "rendition:page-spread-center",
2168
- "rendition:layout-reflowable",
2169
- "rendition:layout-pre-paginated",
2170
- "rendition:orientation-auto",
2171
- "rendition:orientation-landscape",
2172
- "rendition:orientation-portrait",
2173
- "rendition:flow-auto",
2174
- "rendition:flow-paginated",
2175
- "rendition:flow-scrolled-continuous",
2176
- "rendition:flow-scrolled-doc",
2177
- "rendition:align-x-center"
2178
- ]);
2179
-
2180
- // src/references/url.ts
2181
- function parseURL(urlString) {
2182
- const hashIndex = urlString.indexOf("#");
2183
- if (hashIndex === -1) {
2184
- return {
2185
- url: urlString,
2186
- resource: urlString,
2187
- hasFragment: false
2188
- };
2189
- }
2190
- const resource = urlString.substring(0, hashIndex);
2191
- const fragment = urlString.substring(hashIndex + 1);
2192
- const result = {
2193
- url: urlString,
2194
- resource,
2195
- hasFragment: true
2196
- };
2197
- if (fragment) {
2198
- result.fragment = fragment;
2199
- }
2200
- return result;
2201
- }
2202
- function isDataURL(url) {
2203
- return url.startsWith("data:");
2204
- }
2205
- function isFileURL(url) {
2206
- return url.startsWith("file:");
2207
- }
2208
- function hasAbsolutePath(url) {
2209
- return url.startsWith("/");
2210
- }
2211
- function hasParentDirectoryReference(url) {
2212
- return url.includes("..");
2213
- }
2214
- function isMalformedURL(url) {
2215
- if (!url.trim()) return true;
2216
- if (/[\s<>]/.test(url)) return true;
2217
- return false;
2218
- }
2219
- function isHTTPS(url) {
2220
- return url.startsWith("https://");
2221
- }
2222
- function isHTTP(url) {
2223
- return url.startsWith("http://");
2224
- }
2225
- function isRemoteURL(url) {
2226
- return isHTTP(url) || isHTTPS(url);
2227
- }
2228
- function checkUrlLeaking(href) {
2229
- const TEST_BASE_A = "https://a.example.org/A/";
2230
- const TEST_BASE_B = "https://b.example.org/B/";
2231
- try {
2232
- const urlA = new URL(href, TEST_BASE_A).toString();
2233
- const urlB = new URL(href, TEST_BASE_B).toString();
2234
- return !urlA.startsWith(TEST_BASE_A) || !urlB.startsWith(TEST_BASE_B);
2235
- } catch {
2236
- return false;
2237
- }
2238
- }
2239
- function resolveManifestHref(opfDir, href) {
2240
- if (isRemoteURL(href)) return href;
2241
- try {
2242
- const decoded = decodeURIComponent(href);
2243
- const path = opfDir ? `${opfDir}/${decoded}` : decoded;
2244
- return path.normalize("NFC");
2245
- } catch {
2246
- const path = opfDir ? `${opfDir}/${href}` : href;
2247
- return path.normalize("NFC");
2248
- }
2249
- }
2250
-
2251
- // src/types.ts
2252
- var EPUB_VERSIONS = ["2.0", "3.0", "3.1", "3.2", "3.3"];
2253
-
2254
- // src/util/doctype.ts
2255
- var DOCTYPE_RE = /<!DOCTYPE\s+(\w+)\s*([^>]*)>/i;
2256
- var PUBLIC_ID_RE = /\bPUBLIC\s+"([^"]*)"\s+"([^"]*)"/i;
2257
- var SYSTEM_ID_RE = /\bSYSTEM\s+"([^"]*)"/i;
2258
- var DEFAULT_MAX_BYTES = 2048;
2259
- function parseDoctype(content, options = {}) {
2260
- const { expectedRoot, maxBytes = DEFAULT_MAX_BYTES } = options;
2261
- const scanned = content.length > maxBytes ? content.slice(0, maxBytes) : content;
2262
- const doctypeMatch = DOCTYPE_RE.exec(scanned);
2263
- if (!doctypeMatch) return null;
2264
- const root = doctypeMatch[1] ?? "";
2265
- if (expectedRoot && root.toLowerCase() !== expectedRoot.toLowerCase()) return null;
2266
- const inner = doctypeMatch[2] ?? "";
2267
- const publicMatch = PUBLIC_ID_RE.exec(inner);
2268
- if (publicMatch) {
2269
- return { root, publicId: publicMatch[1] ?? "", systemId: publicMatch[2] ?? "" };
2270
- }
2271
- const systemMatch = SYSTEM_ID_RE.exec(inner);
2272
- if (systemMatch) {
2273
- return { root, publicId: "", systemId: systemMatch[1] ?? "" };
2274
- }
2275
- return { root, publicId: "", systemId: "" };
2276
- }
2277
-
2278
- // src/util/encoding.ts
2279
- function sniffXmlEncoding(data) {
2280
- if (data.length < 2) return null;
2281
- if (data.length >= 4) {
2282
- if (data[0] === 0 && data[1] === 0 && data[2] === 254 && data[3] === 255) {
2283
- return "UCS-4";
2284
- }
2285
- if (data[0] === 255 && data[1] === 254 && data[2] === 0 && data[3] === 0) {
2286
- return "UCS-4";
2287
- }
2288
- if (data[0] === 0 && data[1] === 0 && data[2] === 255 && data[3] === 254) {
2289
- return "UCS-4";
2290
- }
2291
- if (data[0] === 254 && data[1] === 255 && data[2] === 0 && data[3] === 0) {
2292
- return "UCS-4";
2293
- }
2294
- if (data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 60) {
2295
- return "UCS-4";
2296
- }
2297
- if (data[0] === 60 && data[1] === 0 && data[2] === 0 && data[3] === 0) {
2298
- return "UCS-4";
2299
- }
2300
- if (data[0] === 0 && data[1] === 0 && data[2] === 60 && data[3] === 0) {
2301
- return "UCS-4";
2302
- }
2303
- if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 0) {
2304
- return "UCS-4";
2305
- }
2306
- }
2307
- if (data[0] === 254 && data[1] === 255) {
2308
- return "UTF-16";
2309
- }
2310
- if (data[0] === 255 && data[1] === 254) {
2311
- return "UTF-16";
2312
- }
2313
- if (data.length >= 4) {
2314
- if (data[0] === 0 && data[1] === 60 && data[2] === 0 && data[3] === 63) {
2315
- return "UTF-16";
2316
- }
2317
- if (data[0] === 60 && data[1] === 0 && data[2] === 63 && data[3] === 0) {
2318
- return "UTF-16";
2319
- }
2320
- }
2321
- if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) {
2322
- return null;
2323
- }
2324
- if (data.length >= 4 && data[0] === 76 && data[1] === 111 && data[2] === 167 && data[3] === 148) {
2325
- return "EBCDIC";
2326
- }
2327
- const prefix = String.fromCharCode(...data.slice(0, Math.min(256, data.length)));
2328
- const match = /^<\?xml[^?]*\bencoding\s*=\s*["']([^"']+)["']/.exec(prefix);
2329
- if (match) {
2330
- const declared = (match[1] ?? "").toUpperCase();
2331
- if (declared === "UTF-8") return null;
2332
- return declared;
2333
- }
2334
- return null;
2335
- }
1857
+ if (data.length >= 3 && data[0] === 239 && data[1] === 187 && data[2] === 191) {
1858
+ return null;
1859
+ }
1860
+ if (data.length >= 4 && data[0] === 76 && data[1] === 111 && data[2] === 167 && data[3] === 148) {
1861
+ return "EBCDIC";
1862
+ }
1863
+ const prefix = String.fromCharCode(...data.slice(0, Math.min(256, data.length)));
1864
+ const match = /^<\?xml[^?]*\bencoding\s*=\s*["']([^"']+)["']/.exec(prefix);
1865
+ if (match) {
1866
+ const declared = (match[1] ?? "").toUpperCase();
1867
+ if (declared === "UTF-8") return null;
1868
+ return declared;
1869
+ }
1870
+ return null;
1871
+ }
2336
1872
 
2337
1873
  // src/opf/parser.ts
2338
1874
  function parseOPF(xml) {
@@ -2358,6 +1894,8 @@ function parseOPF(xml) {
2358
1894
  }
2359
1895
  }
2360
1896
  }
1897
+ const nsRegex = /<package[^>]*\sxmlns=["']([^"']+)["']/;
1898
+ const isLegacyOebps12 = nsRegex.exec(xml)?.[1] === "http://openebook.org/namespaces/oeb-package/1.0/";
2361
1899
  const prefixes = parsePrefixes(xml);
2362
1900
  const dirRegex = /<package[^>]*\sdir=["']([^"']+)["']/;
2363
1901
  const dirMatch = dirRegex.exec(xml);
@@ -2396,6 +1934,9 @@ function parseOPF(xml) {
2396
1934
  if (!versionDeclared) {
2397
1935
  result.versionDeclared = false;
2398
1936
  }
1937
+ if (isLegacyOebps12) {
1938
+ result.isLegacyOebps12 = true;
1939
+ }
2399
1940
  if (Object.keys(prefixes).length > 0) {
2400
1941
  result.prefixes = prefixes;
2401
1942
  }
@@ -2486,7 +2027,7 @@ function parseDCElements(metadataXml) {
2486
2027
  }
2487
2028
  function parseMetaElements(metadataXml) {
2488
2029
  const elements = [];
2489
- const metaRegex = /<meta([^>]*property=["'][^"']+["'][^>]*)>([^<]*)<\/meta>/g;
2030
+ const metaRegex = /<(?:[A-Za-z_][\w.-]*:)?meta([^>]*property=["'][^"']+["'][^>]*)>([^<]*)<\/(?:[A-Za-z_][\w.-]*:)?meta>/g;
2490
2031
  let match;
2491
2032
  while ((match = metaRegex.exec(metadataXml)) !== null) {
2492
2033
  const attrsStr = match[1] ?? "";
@@ -2696,44 +2237,129 @@ function parseCollections(xml) {
2696
2237
  return roots;
2697
2238
  }
2698
2239
 
2699
- // src/opf/validator.ts
2700
- var DEPRECATED_MEDIA_TYPES = /* @__PURE__ */ new Set([
2701
- "text/x-oeb1-document",
2702
- "text/x-oeb1-css",
2703
- "application/x-oeb1-package",
2704
- "text/x-oeb1-html"
2705
- ]);
2706
- function getPreferredMediaType(mimeType, path) {
2707
- switch (mimeType) {
2708
- case "application/font-sfnt":
2709
- if (path.endsWith(".ttf")) return "font/ttf";
2710
- if (path.endsWith(".otf")) return "font/otf";
2711
- return "font/(ttf|otf)";
2712
- case "application/vnd.ms-opentype":
2713
- return "font/otf";
2714
- case "application/font-woff":
2715
- return "font/woff";
2716
- case "application/x-font-ttf":
2717
- return "font/ttf";
2718
- case "text/javascript":
2719
- case "application/ecmascript":
2720
- return "application/javascript";
2721
- default:
2722
- return null;
2723
- }
2724
- }
2725
- var VALID_RELATOR_CODES = /* @__PURE__ */ new Set([
2726
- "abr",
2727
- "acp",
2728
- "act",
2729
- "adi",
2730
- "adp",
2731
- "aft",
2732
- "anl",
2733
- "anm",
2734
- "ann",
2735
- "ant",
2736
- "ape",
2240
+ // src/opf/types.ts
2241
+ var CORE_MEDIA_TYPES = /* @__PURE__ */ new Set([
2242
+ // Image types
2243
+ "image/gif",
2244
+ "image/jpeg",
2245
+ "image/png",
2246
+ "image/svg+xml",
2247
+ "image/webp",
2248
+ // Audio types
2249
+ "audio/mpeg",
2250
+ "audio/mp4",
2251
+ "audio/ogg",
2252
+ // CSS
2253
+ "text/css",
2254
+ // Fonts
2255
+ "font/otf",
2256
+ "font/ttf",
2257
+ "font/woff",
2258
+ "font/woff2",
2259
+ "application/font-sfnt",
2260
+ // deprecated alias for font/otf, font/ttf
2261
+ "application/font-woff",
2262
+ // deprecated alias for font/woff
2263
+ "application/vnd.ms-opentype",
2264
+ // deprecated alias
2265
+ // Content documents
2266
+ "application/xhtml+xml",
2267
+ "application/x-dtbncx+xml",
2268
+ // NCX
2269
+ // JavaScript (EPUB 3)
2270
+ "text/javascript",
2271
+ "application/javascript",
2272
+ // Media overlays
2273
+ "application/smil+xml",
2274
+ // PLS (Pronunciation Lexicon)
2275
+ "application/pls+xml"
2276
+ ]);
2277
+ function isCoreMediaType(mimeType) {
2278
+ if (CORE_MEDIA_TYPES.has(mimeType)) return true;
2279
+ if (mimeType.startsWith("video/")) return true;
2280
+ if (/^audio\/ogg\s*;\s*codecs=opus$/i.test(mimeType)) return true;
2281
+ const semicolonIndex = mimeType.indexOf(";");
2282
+ if (semicolonIndex >= 0) {
2283
+ const baseType = mimeType.substring(0, semicolonIndex).trim();
2284
+ if (CORE_MEDIA_TYPES.has(baseType)) return true;
2285
+ if (baseType.startsWith("video/")) return true;
2286
+ }
2287
+ return false;
2288
+ }
2289
+ var ITEM_PROPERTIES = /* @__PURE__ */ new Set([
2290
+ "cover-image",
2291
+ "data-nav",
2292
+ "dictionary",
2293
+ "glossary",
2294
+ "index",
2295
+ "mathml",
2296
+ "nav",
2297
+ "remote-resources",
2298
+ "scripted",
2299
+ "search-key-map",
2300
+ "svg",
2301
+ "switch"
2302
+ ]);
2303
+ var LINK_PROPERTIES = /* @__PURE__ */ new Set(["onix", "marc21xml-record", "mods-record", "xmp-record"]);
2304
+ var SPINE_PROPERTIES = /* @__PURE__ */ new Set([
2305
+ "page-spread-left",
2306
+ "page-spread-right",
2307
+ "rendition:spread-none",
2308
+ "rendition:spread-landscape",
2309
+ "rendition:spread-portrait",
2310
+ "rendition:spread-both",
2311
+ "rendition:spread-auto",
2312
+ "rendition:page-spread-center",
2313
+ "rendition:layout-reflowable",
2314
+ "rendition:layout-pre-paginated",
2315
+ "rendition:orientation-auto",
2316
+ "rendition:orientation-landscape",
2317
+ "rendition:orientation-portrait",
2318
+ "rendition:flow-auto",
2319
+ "rendition:flow-paginated",
2320
+ "rendition:flow-scrolled-continuous",
2321
+ "rendition:flow-scrolled-doc",
2322
+ "rendition:align-x-center"
2323
+ ]);
2324
+
2325
+ // src/opf/validator.ts
2326
+ var DEPRECATED_MEDIA_TYPES = /* @__PURE__ */ new Set([
2327
+ "text/x-oeb1-document",
2328
+ "text/x-oeb1-css",
2329
+ "application/x-oeb1-package",
2330
+ "text/x-oeb1-html"
2331
+ ]);
2332
+ function getPreferredMediaType(mimeType, path) {
2333
+ switch (mimeType) {
2334
+ case "application/font-sfnt":
2335
+ if (path.endsWith(".ttf")) return "font/ttf";
2336
+ if (path.endsWith(".otf")) return "font/otf";
2337
+ return "font/(ttf|otf)";
2338
+ case "application/vnd.ms-opentype":
2339
+ return "font/otf";
2340
+ case "application/font-woff":
2341
+ return "font/woff";
2342
+ case "application/x-font-ttf":
2343
+ return "font/ttf";
2344
+ case "text/javascript":
2345
+ case "application/ecmascript":
2346
+ return "application/javascript";
2347
+ default:
2348
+ return null;
2349
+ }
2350
+ }
2351
+ var VALID_RELATOR_CODES = /* @__PURE__ */ new Set([
2352
+ "abr",
2353
+ "acp",
2354
+ "act",
2355
+ "adi",
2356
+ "adp",
2357
+ "aft",
2358
+ "anl",
2359
+ "anm",
2360
+ "ann",
2361
+ "ant",
2362
+ "ape",
2737
2363
  "apl",
2738
2364
  "app",
2739
2365
  "aqt",
@@ -3171,6 +2797,9 @@ var OPFValidator = class {
3171
2797
  }
3172
2798
  this.validatePackageAttributes(context, opfPath);
3173
2799
  this.validateMetadata(context, opfPath);
2800
+ if (this.packageDoc.version !== "2.0") {
2801
+ this.validateMetaPrefixes(context, opfPath, opfXml);
2802
+ }
3174
2803
  this.validateLinkElements(context, opfPath);
3175
2804
  this.validateManifest(context, opfPath);
3176
2805
  this.validateSpine(context, opfPath);
@@ -4364,6 +3993,7 @@ var OPFValidator = class {
4364
3993
  if (!this.packageDoc) return;
4365
3994
  const seenIds = /* @__PURE__ */ new Set();
4366
3995
  const seenHrefs = /* @__PURE__ */ new Set();
3996
+ const declaredPrefixes = this.packageDoc.prefixes ?? {};
4367
3997
  for (const item of this.packageDoc.manifest) {
4368
3998
  if (seenIds.has(item.id)) {
4369
3999
  pushMessage(context.messages, {
@@ -4407,7 +4037,7 @@ var OPFValidator = class {
4407
4037
  });
4408
4038
  }
4409
4039
  if (!item.href.startsWith("http") && !item.href.startsWith("mailto:")) {
4410
- const leaked = checkUrlLeaking(item.href);
4040
+ const leaked = checkUrlLeaking(item.href, opfPath);
4411
4041
  if (leaked) {
4412
4042
  pushMessage(context.messages, {
4413
4043
  id: MessageId.RSC_026,
@@ -4442,11 +4072,11 @@ var OPFValidator = class {
4442
4072
  if (DEPRECATED_MEDIA_TYPES.has(item.mediaType) || item.mediaType === "text/html") {
4443
4073
  if (this.packageDoc.version === "2.0" && item.mediaType === "text/html") {
4444
4074
  pushMessage(context.messages, {
4445
- id: MessageId.OPF_035,
4075
+ id: this.packageDoc.isLegacyOebps12 ? MessageId.OPF_038 : MessageId.OPF_035,
4446
4076
  message: `XHTML Content Document "${item.id}" is declared as "text/html"`,
4447
4077
  location: { path: opfPath }
4448
4078
  });
4449
- } else if (this.packageDoc.version === "2.0") {
4079
+ } else if (this.packageDoc.version === "2.0" && !this.packageDoc.isLegacyOebps12) {
4450
4080
  pushMessage(context.messages, {
4451
4081
  id: MessageId.OPF_037,
4452
4082
  message: `Found deprecated media-type "${item.mediaType}"`,
@@ -4454,6 +4084,13 @@ var OPFValidator = class {
4454
4084
  });
4455
4085
  }
4456
4086
  }
4087
+ if (this.packageDoc.version === "2.0" && this.packageDoc.isLegacyOebps12 && item.mediaType === "text/css" && !item.fallback) {
4088
+ pushMessage(context.messages, {
4089
+ id: MessageId.OPF_039,
4090
+ message: `Media type "${item.mediaType}" requires a fallback in legacy OEBPS 1.2 context`,
4091
+ location: { path: opfPath }
4092
+ });
4093
+ }
4457
4094
  const preferred = getPreferredMediaType(item.mediaType, fullPath);
4458
4095
  if (preferred !== null) {
4459
4096
  pushMessage(context.messages, {
@@ -4464,13 +4101,14 @@ var OPFValidator = class {
4464
4101
  }
4465
4102
  if (this.packageDoc.version !== "2.0" && item.properties) {
4466
4103
  for (const prop of item.properties) {
4467
- if (!ITEM_PROPERTIES.has(prop)) {
4468
- pushMessage(context.messages, {
4469
- id: MessageId.OPF_027,
4470
- message: `Undefined property: "${prop}"`,
4471
- location: { path: opfPath }
4472
- });
4473
- }
4104
+ if (ITEM_PROPERTIES.has(prop)) continue;
4105
+ const colon = prop.indexOf(":");
4106
+ if (colon > 0 && declaredPrefixes[prop.slice(0, colon)] !== void 0) continue;
4107
+ pushMessage(context.messages, {
4108
+ id: MessageId.OPF_027,
4109
+ message: `Undefined property: "${prop}"`,
4110
+ location: { path: opfPath }
4111
+ });
4474
4112
  }
4475
4113
  if (item.properties.includes("nav")) {
4476
4114
  if (item.mediaType !== "application/xhtml+xml") {
@@ -4633,6 +4271,42 @@ var OPFValidator = class {
4633
4271
  * emits one assertion failure per offending element (so two duplicate ids
4634
4272
  * produce two RSC-005 messages).
4635
4273
  */
4274
+ validateMetaPrefixes(context, opfPath, opfXml) {
4275
+ if (!this.packageDoc) return;
4276
+ const RESERVED = /* @__PURE__ */ new Set([
4277
+ "dcterms",
4278
+ "marc",
4279
+ "onix",
4280
+ "schema",
4281
+ "xsd",
4282
+ "a11y",
4283
+ "media",
4284
+ "rendition"
4285
+ ]);
4286
+ const declared = new Set(Object.keys(this.packageDoc.prefixes ?? {}));
4287
+ const reported = /* @__PURE__ */ new Set();
4288
+ const reportIfUndeclared = (prefix) => {
4289
+ if (!prefix || reported.has(prefix)) return;
4290
+ if (RESERVED.has(prefix) || declared.has(prefix)) return;
4291
+ reported.add(prefix);
4292
+ pushMessage(context.messages, {
4293
+ id: MessageId.OPF_028,
4294
+ message: `Undeclared prefix: "${prefix}"`,
4295
+ location: { path: opfPath }
4296
+ });
4297
+ };
4298
+ const stripped = stripXmlComments(opfXml);
4299
+ const attrRegex = /\b(?:property|scheme|rel)\s*=\s*["']([^"']+)["']/g;
4300
+ for (const match of stripped.matchAll(attrRegex)) {
4301
+ const value = match[1]?.trim();
4302
+ if (!value) continue;
4303
+ for (const token of value.split(/\s+/)) {
4304
+ const colon = token.indexOf(":");
4305
+ if (colon <= 0) continue;
4306
+ reportIfUndeclared(token.slice(0, colon));
4307
+ }
4308
+ }
4309
+ }
4636
4310
  validateOpfIdUniqueness(context, opfPath, opfXml) {
4637
4311
  const stripped = stripXmlComments(opfXml);
4638
4312
  const counts = /* @__PURE__ */ new Map();
@@ -4664,11 +4338,12 @@ var OPFValidator = class {
4664
4338
  for (const item of this.packageDoc.manifest) {
4665
4339
  const hrefBase = item.href.split("?")[0] ?? item.href;
4666
4340
  if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(hrefBase)) continue;
4667
- manifestPaths.add(resolvePath(opfPath, hrefBase).normalize("NFC"));
4341
+ manifestPaths.add(resolvePath(opfPath, tryDecodeUriComponent(hrefBase)).normalize("NFC"));
4668
4342
  }
4669
4343
  const rootfilePaths = new Set(context.rootfiles.map((r) => r.path.normalize("NFC")));
4670
4344
  for (const path of context.files.keys()) {
4671
4345
  if (path === "mimetype") continue;
4346
+ if (path.endsWith("/")) continue;
4672
4347
  if (path.startsWith("META-INF/")) continue;
4673
4348
  if (rootfilePaths.has(path)) continue;
4674
4349
  if (manifestPaths.has(path)) continue;
@@ -5254,26 +4929,493 @@ function isValidW3CDateFormat(dateStr) {
5254
4929
  if (seconds < 0 || seconds > 59) return false;
5255
4930
  return true;
5256
4931
  }
5257
- return false;
5258
- }
5259
-
5260
- // src/references/types.ts
5261
- var PUBLICATION_RESOURCE_TYPES = /* @__PURE__ */ new Set([
5262
- "generic" /* GENERIC */,
5263
- "stylesheet" /* STYLESHEET */,
5264
- "font" /* FONT */,
5265
- "image" /* IMAGE */,
5266
- "audio" /* AUDIO */,
5267
- "video" /* VIDEO */,
5268
- "track" /* TRACK */,
5269
- "media-overlay" /* MEDIA_OVERLAY */,
5270
- "svg-symbol" /* SVG_SYMBOL */,
5271
- "svg-paint" /* SVG_PAINT */,
5272
- "svg-clip-path" /* SVG_CLIP_PATH */
5273
- ]);
5274
- function isPublicationResourceReference(type) {
5275
- return PUBLICATION_RESOURCE_TYPES.has(type);
5276
- }
4932
+ return false;
4933
+ }
4934
+
4935
+ // src/references/types.ts
4936
+ var PUBLICATION_RESOURCE_TYPES = /* @__PURE__ */ new Set([
4937
+ "generic" /* GENERIC */,
4938
+ "stylesheet" /* STYLESHEET */,
4939
+ "font" /* FONT */,
4940
+ "image" /* IMAGE */,
4941
+ "audio" /* AUDIO */,
4942
+ "video" /* VIDEO */,
4943
+ "track" /* TRACK */,
4944
+ "media-overlay" /* MEDIA_OVERLAY */,
4945
+ "svg-symbol" /* SVG_SYMBOL */,
4946
+ "svg-paint" /* SVG_PAINT */,
4947
+ "svg-clip-path" /* SVG_CLIP_PATH */
4948
+ ]);
4949
+ function isPublicationResourceReference(type) {
4950
+ return PUBLICATION_RESOURCE_TYPES.has(type);
4951
+ }
4952
+
4953
+ // src/skm/validator.ts
4954
+ var OPS_NS_URI = "http://www.idpf.org/2007/ops";
4955
+ var SKM_NS = { ops: OPS_NS_URI };
4956
+ var SKMValidator = class {
4957
+ validate(context, path, refValidator) {
4958
+ const data = context.files.get(path);
4959
+ if (!data) return;
4960
+ const content = typeof data === "string" ? data : new TextDecoder().decode(data);
4961
+ let doc = null;
4962
+ try {
4963
+ doc = XmlDocument.fromString(content);
4964
+ } catch {
4965
+ pushMessage(context.messages, {
4966
+ id: MessageId.RSC_016,
4967
+ message: "Search Key Map document is not well-formed XML",
4968
+ location: { path }
4969
+ });
4970
+ return;
4971
+ }
4972
+ try {
4973
+ const root = doc.root;
4974
+ if (root.namespaceUri !== OPS_NS_URI || root.name !== "search-key-map") {
4975
+ pushMessage(context.messages, {
4976
+ id: MessageId.RSC_005,
4977
+ message: `Root element must be "search-key-map" in the OPS namespace`,
4978
+ location: { path, line: root.line }
4979
+ });
4980
+ return;
4981
+ }
4982
+ const groups = root.find("./ops:search-key-group", SKM_NS);
4983
+ if (groups.length === 0) {
4984
+ pushMessage(context.messages, {
4985
+ id: MessageId.RSC_005,
4986
+ message: 'A "search-key-map" must contain at least one "search-key-group"',
4987
+ location: { path, line: root.line }
4988
+ });
4989
+ }
4990
+ for (const group of groups) {
4991
+ const groupEl = group;
4992
+ const href = groupEl.attr("href")?.value;
4993
+ if (!href) {
4994
+ pushMessage(context.messages, {
4995
+ id: MessageId.RSC_005,
4996
+ message: 'The "href" attribute is required on "search-key-group"',
4997
+ location: { path, line: groupEl.line }
4998
+ });
4999
+ } else if (refValidator) {
5000
+ registerSkmRef(refValidator, path, href, groupEl.line);
5001
+ }
5002
+ const matches = groupEl.find("./ops:match", SKM_NS);
5003
+ if (matches.length === 0) {
5004
+ pushMessage(context.messages, {
5005
+ id: MessageId.RSC_005,
5006
+ message: 'A "search-key-group" must contain at least one "match"',
5007
+ location: { path, line: groupEl.line }
5008
+ });
5009
+ }
5010
+ for (const match of matches) {
5011
+ const matchEl = match;
5012
+ const matchHref = matchEl.attr("href")?.value;
5013
+ if (matchHref && refValidator) {
5014
+ registerSkmRef(refValidator, path, matchHref, matchEl.line);
5015
+ }
5016
+ }
5017
+ }
5018
+ } finally {
5019
+ doc.dispose();
5020
+ }
5021
+ }
5022
+ };
5023
+ function registerSkmRef(refValidator, path, href, line) {
5024
+ const parsed = parseURL(href);
5025
+ const targetResource = resolvePath(path, tryDecodeUriComponent(parsed.resource)).normalize("NFC");
5026
+ const location = line != null ? { path, line } : { path };
5027
+ const ref = {
5028
+ url: parsed.hasFragment ? `${targetResource}#${parsed.fragment ?? ""}` : targetResource,
5029
+ targetResource,
5030
+ type: "search-key" /* SEARCH_KEY */,
5031
+ location
5032
+ };
5033
+ if (parsed.fragment !== void 0) ref.fragment = parsed.fragment;
5034
+ refValidator.addReference(ref);
5035
+ }
5036
+
5037
+ // src/vocab/epub-ssv.ts
5038
+ var EPUB_SSV_DEPRECATED = /* @__PURE__ */ new Set([
5039
+ "annoref",
5040
+ "annotation",
5041
+ "biblioentry",
5042
+ "bridgehead",
5043
+ "endnote",
5044
+ "help",
5045
+ "marginalia",
5046
+ "note",
5047
+ "rearnote",
5048
+ "rearnotes",
5049
+ "sidebar",
5050
+ "subchapter",
5051
+ "warning"
5052
+ ]);
5053
+ var EPUB_SSV_DISALLOWED_ON_CONTENT = /* @__PURE__ */ new Set([
5054
+ "aside",
5055
+ "figure",
5056
+ "list",
5057
+ "list-item",
5058
+ "table",
5059
+ "table-cell",
5060
+ "table-row"
5061
+ ]);
5062
+ var EPUB_SSV_ALL = /* @__PURE__ */ new Set([
5063
+ ...EPUB_SSV_DEPRECATED,
5064
+ ...EPUB_SSV_DISALLOWED_ON_CONTENT,
5065
+ "abstract",
5066
+ "acknowledgments",
5067
+ "afterword",
5068
+ "appendix",
5069
+ "assessment",
5070
+ "assessments",
5071
+ "backlink",
5072
+ "backmatter",
5073
+ "balloon",
5074
+ "bibliography",
5075
+ "biblioref",
5076
+ "bodymatter",
5077
+ "case-study",
5078
+ "chapter",
5079
+ "colophon",
5080
+ "concluding-sentence",
5081
+ "conclusion",
5082
+ "contributors",
5083
+ "copyright-page",
5084
+ "cover",
5085
+ "covertitle",
5086
+ "credit",
5087
+ "credits",
5088
+ "dedication",
5089
+ "division",
5090
+ "endnotes",
5091
+ "epigraph",
5092
+ "epilogue",
5093
+ "errata",
5094
+ "fill-in-the-blank-problem",
5095
+ "footnote",
5096
+ "footnotes",
5097
+ "foreword",
5098
+ "frontmatter",
5099
+ "fulltitle",
5100
+ "general-problem",
5101
+ "glossary",
5102
+ "glossdef",
5103
+ "glossref",
5104
+ "glossterm",
5105
+ "halftitle",
5106
+ "halftitlepage",
5107
+ "imprimatur",
5108
+ "imprint",
5109
+ "index",
5110
+ "index-editor-note",
5111
+ "index-entry",
5112
+ "index-entry-list",
5113
+ "index-group",
5114
+ "index-headnotes",
5115
+ "index-legend",
5116
+ "index-locator",
5117
+ "index-locator-list",
5118
+ "index-locator-range",
5119
+ "index-term",
5120
+ "index-term-categories",
5121
+ "index-term-category",
5122
+ "index-xref-preferred",
5123
+ "index-xref-related",
5124
+ "introduction",
5125
+ "keyword",
5126
+ "keywords",
5127
+ "label",
5128
+ "landmarks",
5129
+ "learning-objective",
5130
+ "learning-objectives",
5131
+ "learning-outcome",
5132
+ "learning-outcomes",
5133
+ "learning-resource",
5134
+ "learning-resources",
5135
+ "learning-standard",
5136
+ "learning-standards",
5137
+ "loa",
5138
+ "loi",
5139
+ "lot",
5140
+ "lov",
5141
+ "match-problem",
5142
+ "multiple-choice-problem",
5143
+ "noteref",
5144
+ "notice",
5145
+ "ordinal",
5146
+ "other-credits",
5147
+ "page-list",
5148
+ "pagebreak",
5149
+ "panel",
5150
+ "panel-group",
5151
+ "part",
5152
+ "practice",
5153
+ "practices",
5154
+ "preamble",
5155
+ "preface",
5156
+ "prologue",
5157
+ "pullquote",
5158
+ "qna",
5159
+ "question",
5160
+ "referrer",
5161
+ "revision-history",
5162
+ "seriespage",
5163
+ "sound-area",
5164
+ "subtitle",
5165
+ "tip",
5166
+ "title",
5167
+ "titlepage",
5168
+ "toc",
5169
+ "toc-brief",
5170
+ "topic-sentence",
5171
+ "true-false-problem",
5172
+ "volume"
5173
+ ]);
5174
+
5175
+ // src/smil/validator.ts
5176
+ var SMIL_NS = { smil: "http://www.w3.org/ns/SMIL" };
5177
+ var BLESSED_AUDIO_TYPES = /* @__PURE__ */ new Set(["audio/mpeg", "audio/mp4"]);
5178
+ var BLESSED_AUDIO_PATTERN = /^audio\/ogg\s*;\s*codecs=opus$/i;
5179
+ function isBlessedAudioType(mimeType) {
5180
+ return BLESSED_AUDIO_TYPES.has(mimeType) || BLESSED_AUDIO_PATTERN.test(mimeType);
5181
+ }
5182
+ var SMILValidator = class {
5183
+ getAttribute(element, name) {
5184
+ return element.attr(name)?.value ?? null;
5185
+ }
5186
+ getEpubAttribute(element, localName) {
5187
+ return element.attr(localName, "epub")?.value ?? null;
5188
+ }
5189
+ validate(context, path, manifestByPath) {
5190
+ const result = {
5191
+ textReferences: [],
5192
+ referencedDocuments: /* @__PURE__ */ new Set(),
5193
+ hasRemoteResources: false
5194
+ };
5195
+ const data = context.files.get(path);
5196
+ if (!data) return result;
5197
+ const content = typeof data === "string" ? data : new TextDecoder().decode(data);
5198
+ let doc = null;
5199
+ try {
5200
+ doc = XmlDocument.fromString(content);
5201
+ } catch {
5202
+ pushMessage(context.messages, {
5203
+ id: MessageId.RSC_016,
5204
+ message: "Media Overlay document is not well-formed XML",
5205
+ location: { path }
5206
+ });
5207
+ return result;
5208
+ }
5209
+ try {
5210
+ const root = doc.root;
5211
+ this.validateStructure(context, path, root);
5212
+ this.validateAudioElements(context, path, root, manifestByPath, result);
5213
+ this.validateEpubTypes(context, path, root);
5214
+ this.extractTextReferences(path, root, result);
5215
+ } finally {
5216
+ doc.dispose();
5217
+ }
5218
+ return result;
5219
+ }
5220
+ validateStructure(context, path, root) {
5221
+ try {
5222
+ for (const text of root.find(".//smil:seq/smil:text", SMIL_NS)) {
5223
+ pushMessage(context.messages, {
5224
+ id: MessageId.RSC_005,
5225
+ message: "element 'text' not allowed here; expected 'seq' or 'par'",
5226
+ location: { path, line: text.line }
5227
+ });
5228
+ }
5229
+ for (const audio of root.find(".//smil:seq/smil:audio", SMIL_NS)) {
5230
+ pushMessage(context.messages, {
5231
+ id: MessageId.RSC_005,
5232
+ message: "element 'audio' not allowed here; expected 'seq' or 'par'",
5233
+ location: { path, line: audio.line }
5234
+ });
5235
+ }
5236
+ } catch {
5237
+ }
5238
+ try {
5239
+ for (const seq of root.find(".//smil:par/smil:seq", SMIL_NS)) {
5240
+ pushMessage(context.messages, {
5241
+ id: MessageId.RSC_005,
5242
+ message: "element 'seq' not allowed here; expected 'text' or 'audio'",
5243
+ location: { path, line: seq.line }
5244
+ });
5245
+ }
5246
+ const parElements = root.find(".//smil:par", SMIL_NS);
5247
+ for (const par of parElements) {
5248
+ const textChildren = par.find("./smil:text", SMIL_NS);
5249
+ for (let i = 1; i < textChildren.length; i++) {
5250
+ const extra = textChildren[i];
5251
+ if (!extra) continue;
5252
+ pushMessage(context.messages, {
5253
+ id: MessageId.RSC_005,
5254
+ message: "element 'text' not allowed here; only one 'text' element is allowed in 'par'",
5255
+ location: { path, line: extra.line }
5256
+ });
5257
+ }
5258
+ }
5259
+ } catch {
5260
+ }
5261
+ try {
5262
+ const headMetaElements = root.find(".//smil:head/smil:meta", SMIL_NS);
5263
+ for (const meta of headMetaElements) {
5264
+ pushMessage(context.messages, {
5265
+ id: MessageId.RSC_005,
5266
+ message: "element 'meta' not allowed here; expected 'metadata'",
5267
+ location: { path, line: meta.line }
5268
+ });
5269
+ }
5270
+ } catch {
5271
+ }
5272
+ }
5273
+ validateAudioElements(context, path, root, manifestByPath, result) {
5274
+ try {
5275
+ const audioElements = root.find(".//smil:audio", SMIL_NS);
5276
+ for (const audio of audioElements) {
5277
+ const elem = audio;
5278
+ const src = this.getAttribute(elem, "src");
5279
+ if (src) {
5280
+ if (isRemoteURL(src)) {
5281
+ result.hasRemoteResources = true;
5282
+ }
5283
+ if (src.includes("#")) {
5284
+ pushMessage(context.messages, {
5285
+ id: MessageId.MED_014,
5286
+ message: `Media overlay audio file URLs must not have a fragment: "${src}"`,
5287
+ location: { path, line: audio.line }
5288
+ });
5289
+ }
5290
+ if (manifestByPath) {
5291
+ const audioPath = this.resolveRelativePath(path, src.split("#")[0] ?? src);
5292
+ const audioItem = manifestByPath.get(audioPath);
5293
+ if (audioItem && !isBlessedAudioType(audioItem.mediaType)) {
5294
+ pushMessage(context.messages, {
5295
+ id: MessageId.MED_005,
5296
+ message: `Media Overlay audio reference "${src}" to non-standard audio type "${audioItem.mediaType}"`,
5297
+ location: { path, line: audio.line }
5298
+ });
5299
+ }
5300
+ }
5301
+ }
5302
+ const clipBegin = this.getAttribute(elem, "clipBegin");
5303
+ const clipEnd = this.getAttribute(elem, "clipEnd");
5304
+ this.checkClipTiming(context, path, audio.line, clipBegin, clipEnd);
5305
+ }
5306
+ } catch {
5307
+ }
5308
+ }
5309
+ checkClipTiming(context, path, line, clipBegin, clipEnd) {
5310
+ if (clipEnd === null) return;
5311
+ const beginStr = clipBegin ?? "0";
5312
+ const start = parseSmilClock(beginStr);
5313
+ const end = parseSmilClock(clipEnd);
5314
+ const location = line != null ? { path, line } : { path };
5315
+ if (clipBegin !== null && Number.isNaN(start)) {
5316
+ pushMessage(context.messages, {
5317
+ id: MessageId.RSC_005,
5318
+ message: `Invalid SMIL clock value "${clipBegin}" in clipBegin attribute`,
5319
+ location
5320
+ });
5321
+ }
5322
+ if (Number.isNaN(end)) {
5323
+ pushMessage(context.messages, {
5324
+ id: MessageId.RSC_005,
5325
+ message: `Invalid SMIL clock value "${clipEnd}" in clipEnd attribute`,
5326
+ location
5327
+ });
5328
+ }
5329
+ if (Number.isNaN(start) || Number.isNaN(end)) return;
5330
+ if (start > end) {
5331
+ pushMessage(context.messages, {
5332
+ id: MessageId.MED_008,
5333
+ message: "The time specified in the clipBegin attribute must not be after clipEnd",
5334
+ location
5335
+ });
5336
+ } else if (start === end) {
5337
+ pushMessage(context.messages, {
5338
+ id: MessageId.MED_009,
5339
+ message: "The time specified in the clipBegin attribute must not be the same as clipEnd",
5340
+ location
5341
+ });
5342
+ }
5343
+ }
5344
+ /**
5345
+ * Validate epub:type attribute values against the EPUB SSV vocabulary.
5346
+ * Only emits OPF-088 (usage) for unknown local names. Prefixed values
5347
+ * from declared vocabularies are allowed.
5348
+ */
5349
+ validateEpubTypes(context, path, root) {
5350
+ try {
5351
+ const epubTypeElements = root.find(".//*[@epub:type]", {
5352
+ epub: "http://www.idpf.org/2007/ops"
5353
+ });
5354
+ for (const elem of epubTypeElements) {
5355
+ const elemTyped = elem;
5356
+ const epubTypeAttr = elemTyped.attr("type", "epub");
5357
+ if (!epubTypeAttr?.value) continue;
5358
+ for (const part of epubTypeAttr.value.split(/\s+/)) {
5359
+ if (!part || part.includes(":")) continue;
5360
+ if (!EPUB_SSV_ALL.has(part)) {
5361
+ pushMessage(context.messages, {
5362
+ id: MessageId.OPF_088,
5363
+ message: `Unrecognized epub:type value "${part}"`,
5364
+ location: { path, line: elem.line }
5365
+ });
5366
+ }
5367
+ }
5368
+ }
5369
+ } catch {
5370
+ }
5371
+ }
5372
+ extractTextReferences(path, root, result) {
5373
+ try {
5374
+ const textElements = root.find(".//smil:text", SMIL_NS);
5375
+ for (const text of textElements) {
5376
+ const src = this.getAttribute(text, "src");
5377
+ if (!src) continue;
5378
+ const hashIndex = src.indexOf("#");
5379
+ const docRef = hashIndex >= 0 ? src.substring(0, hashIndex) : src;
5380
+ const fragment = hashIndex >= 0 ? src.substring(hashIndex + 1) : void 0;
5381
+ const docPath = this.resolveRelativePath(path, docRef);
5382
+ result.textReferences.push({ docPath, fragment, line: text.line });
5383
+ result.referencedDocuments.add(docPath);
5384
+ }
5385
+ const bodyElements = root.find(".//smil:body", SMIL_NS);
5386
+ const seqElements = root.find(".//smil:seq", SMIL_NS);
5387
+ for (const elem of [...bodyElements, ...seqElements]) {
5388
+ const textref = this.getEpubAttribute(elem, "textref");
5389
+ if (!textref) continue;
5390
+ const hashIndex = textref.indexOf("#");
5391
+ const docRef = hashIndex >= 0 ? textref.substring(0, hashIndex) : textref;
5392
+ const fragment = hashIndex >= 0 ? textref.substring(hashIndex + 1) : void 0;
5393
+ const docPath = this.resolveRelativePath(path, docRef);
5394
+ result.textReferences.push({ docPath, fragment, line: elem.line });
5395
+ result.referencedDocuments.add(docPath);
5396
+ }
5397
+ } catch {
5398
+ }
5399
+ }
5400
+ resolveRelativePath(basePath, relativePath) {
5401
+ const decoded = tryDecodeUriComponent(relativePath);
5402
+ if (decoded.startsWith("/") || /^[a-zA-Z]+:/.test(decoded)) {
5403
+ return decoded.normalize("NFC");
5404
+ }
5405
+ const baseDir = basePath.includes("/") ? basePath.substring(0, basePath.lastIndexOf("/")) : "";
5406
+ if (!baseDir) return decoded.normalize("NFC");
5407
+ const segments = `${baseDir}/${decoded}`.split("/");
5408
+ const resolved = [];
5409
+ for (const seg of segments) {
5410
+ if (seg === "..") {
5411
+ resolved.pop();
5412
+ } else if (seg !== ".") {
5413
+ resolved.push(seg);
5414
+ }
5415
+ }
5416
+ return resolved.join("/").normalize("NFC");
5417
+ }
5418
+ };
5277
5419
 
5278
5420
  // src/references/uri-schemes.ts
5279
5421
  var URI_SCHEMES = /* @__PURE__ */ new Set([
@@ -5364,7 +5506,9 @@ var ABSOLUTE_URI_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
5364
5506
  var SPECIAL_URL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ftp", "ws", "wss"]);
5365
5507
  var CSS_CHARSET_RE = /^@charset\s+"([^"]+)"\s*;/;
5366
5508
  var EPUB_XMLNS_RE = /xmlns:epub\s*=\s*"([^"]*)"/;
5367
- var XHTML_NS = { html: "http://www.w3.org/1999/xhtml" };
5509
+ var XHTML_NS_URI = "http://www.w3.org/1999/xhtml";
5510
+ var XML_NS_URI = "http://www.w3.org/XML/1998/namespace";
5511
+ var XHTML_NS = { html: XHTML_NS_URI };
5368
5512
  var EPUB_OPS_NS = { epub: "http://www.idpf.org/2007/ops" };
5369
5513
  var EPUB_TYPE_FORBIDDEN_ELEMENTS = /* @__PURE__ */ new Set([
5370
5514
  "head",
@@ -5648,6 +5792,112 @@ var HTML5_ELEMENTS = /* @__PURE__ */ new Set([
5648
5792
  "video",
5649
5793
  "wbr"
5650
5794
  ]);
5795
+ var XHTML11_ELEMENTS = /* @__PURE__ */ new Set([
5796
+ // struct
5797
+ "html",
5798
+ "head",
5799
+ "title",
5800
+ "body",
5801
+ "meta",
5802
+ "link",
5803
+ "base",
5804
+ "style",
5805
+ "script",
5806
+ "noscript",
5807
+ // text
5808
+ "br",
5809
+ "span",
5810
+ "abbr",
5811
+ "acronym",
5812
+ "cite",
5813
+ "code",
5814
+ "dfn",
5815
+ "em",
5816
+ "kbd",
5817
+ "q",
5818
+ "samp",
5819
+ "strong",
5820
+ "var",
5821
+ "div",
5822
+ "p",
5823
+ "address",
5824
+ "blockquote",
5825
+ "pre",
5826
+ "h1",
5827
+ "h2",
5828
+ "h3",
5829
+ "h4",
5830
+ "h5",
5831
+ "h6",
5832
+ // pres / legacy
5833
+ "hr",
5834
+ "b",
5835
+ "big",
5836
+ "i",
5837
+ "small",
5838
+ "sub",
5839
+ "sup",
5840
+ "tt",
5841
+ "basefont",
5842
+ "center",
5843
+ "font",
5844
+ "s",
5845
+ "strike",
5846
+ "u",
5847
+ "dir",
5848
+ "menu",
5849
+ "isindex",
5850
+ // list
5851
+ "dl",
5852
+ "dt",
5853
+ "dd",
5854
+ "ol",
5855
+ "ul",
5856
+ "li",
5857
+ // table
5858
+ "table",
5859
+ "caption",
5860
+ "tr",
5861
+ "th",
5862
+ "td",
5863
+ "col",
5864
+ "colgroup",
5865
+ "tbody",
5866
+ "thead",
5867
+ "tfoot",
5868
+ // hypertext / image / object / form / edit / ruby / map / iframe / applet / bdo / param
5869
+ "a",
5870
+ "img",
5871
+ "object",
5872
+ "param",
5873
+ "form",
5874
+ "label",
5875
+ "input",
5876
+ "select",
5877
+ "option",
5878
+ "optgroup",
5879
+ "fieldset",
5880
+ "button",
5881
+ "legend",
5882
+ "textarea",
5883
+ "ins",
5884
+ "del",
5885
+ "ruby",
5886
+ "rbc",
5887
+ "rtc",
5888
+ "rb",
5889
+ "rt",
5890
+ "rp",
5891
+ "map",
5892
+ "area",
5893
+ "iframe",
5894
+ "applet",
5895
+ "bdo",
5896
+ // frames
5897
+ "frameset",
5898
+ "frame",
5899
+ "noframes"
5900
+ ]);
5651
5901
  function isItemFixedLayout(packageDoc, itemId) {
5652
5902
  const spineItem = packageDoc.spine.find((s) => s.idref === itemId);
5653
5903
  if (!spineItem) return false;
@@ -5714,6 +5964,10 @@ var ContentValidator = class {
5714
5964
  if (refValidator) {
5715
5965
  this.extractSVGReferences(context, fullPath, opfDir, refValidator);
5716
5966
  }
5967
+ } else if (item.mediaType === "application/vnd.epub.search-key-map+xml") {
5968
+ const fullPath = resolveManifestHref(opfDir, item.href);
5969
+ const skmValidator = new SKMValidator();
5970
+ skmValidator.validate(context, fullPath, refValidator);
5717
5971
  } else if (item.mediaType === "application/smil+xml") {
5718
5972
  const fullPath = resolveManifestHref(opfDir, item.href);
5719
5973
  const smilValidator = new SMILValidator();
@@ -6476,6 +6730,9 @@ var ContentValidator = class {
6476
6730
  }
6477
6731
  }
6478
6732
  }
6733
+ if (context.version === "2.0") {
6734
+ this.checkEpub2XhtmlStrict(context, path, root);
6735
+ }
6479
6736
  this.checkDiscouragedElements(context, path, root);
6480
6737
  this.checkSSMLPh(context, path, root, content);
6481
6738
  this.checkObsoleteHTML(context, path, root);
@@ -6506,6 +6763,9 @@ var ContentValidator = class {
6506
6763
  this.validateEpubTypes(context, path, root);
6507
6764
  this.validateRegionBasedNav(context, path, root, manifestItem);
6508
6765
  }
6766
+ if (context.version.startsWith("3") && context.options.profile === "dict") {
6767
+ this.validateDictionaryContent(context, path, root);
6768
+ }
6509
6769
  if (context.version.startsWith("3") && context.options.profile === "edupub") {
6510
6770
  const isFxl = manifestItem && packageDoc ? isItemFixedLayout(packageDoc, manifestItem.id) : false;
6511
6771
  const isNonLinear = manifestItem && packageDoc ? packageDoc.spine.find((ref) => ref.idref === manifestItem.id)?.linear === false : false;
@@ -6514,7 +6774,7 @@ var ContentValidator = class {
6514
6774
  }
6515
6775
  }
6516
6776
  if (context.version.startsWith("3")) {
6517
- this.collectFeatures(context, root);
6777
+ this.collectFeatures(context, path, root);
6518
6778
  }
6519
6779
  this.validateEpubSwitch(context, path, root);
6520
6780
  this.validateEpubTrigger(context, path, root);
@@ -7724,6 +7984,7 @@ var ContentValidator = class {
7724
7984
  }
7725
7985
  }
7726
7986
  checkUsemapAttribute(context, path, root) {
7987
+ if (context.version === "2.0") return;
7727
7988
  try {
7728
7989
  const elements = root.find(".//html:*[@usemap]", XHTML_NS);
7729
7990
  for (const elem of elements) {
@@ -8036,7 +8297,7 @@ var ContentValidator = class {
8036
8297
  }
8037
8298
  }
8038
8299
  }
8039
- collectFeatures(context, root) {
8300
+ collectFeatures(context, path, root) {
8040
8301
  const features = context.contentFeatures;
8041
8302
  if (!features) return;
8042
8303
  if (!features.hasTable && root.get(".//html:table", XHTML_NS)) {
@@ -8051,22 +8312,20 @@ var ContentValidator = class {
8051
8312
  if (!features.hasVideo && root.get(".//html:video", XHTML_NS)) {
8052
8313
  features.hasVideo = true;
8053
8314
  }
8054
- if (!features.hasPageBreak || !features.hasDictionary || !features.hasIndex) {
8055
- const epubTypeElements = root.find(".//*[@epub:type]", EPUB_OPS_NS);
8056
- for (const el of epubTypeElements) {
8057
- const attr = el.attr("type", "epub");
8058
- if (!attr?.value) continue;
8059
- const tokens = attr.value.trim().split(/\s+/);
8060
- if (!features.hasPageBreak && tokens.includes("pagebreak")) {
8061
- features.hasPageBreak = true;
8062
- }
8063
- if (!features.hasDictionary && tokens.includes("dictionary")) {
8064
- features.hasDictionary = true;
8065
- }
8066
- if (!features.hasIndex && tokens.includes("index")) {
8067
- features.hasIndex = true;
8068
- }
8069
- if (features.hasPageBreak && features.hasDictionary && features.hasIndex) break;
8315
+ const epubTypeElements = root.find(".//*[@epub:type]", EPUB_OPS_NS);
8316
+ for (const el of epubTypeElements) {
8317
+ const attr = el.attr("type", "epub");
8318
+ if (!attr?.value) continue;
8319
+ const tokens = attr.value.trim().split(/\s+/);
8320
+ if (!features.hasPageBreak && tokens.includes("pagebreak")) {
8321
+ features.hasPageBreak = true;
8322
+ }
8323
+ if (tokens.includes("dictionary")) {
8324
+ features.hasDictionary = true;
8325
+ (features.dictionaryContentPaths ??= /* @__PURE__ */ new Set()).add(path);
8326
+ }
8327
+ if (!features.hasIndex && tokens.includes("index")) {
8328
+ features.hasIndex = true;
8070
8329
  }
8071
8330
  }
8072
8331
  if (!features.hasMicrodata && root.get(".//*[@itemscope]")) {
@@ -8167,6 +8426,158 @@ var ContentValidator = class {
8167
8426
  });
8168
8427
  }
8169
8428
  }
8429
+ this.validateRegionBasedNavRules(context, path, root);
8430
+ }
8431
+ }
8432
+ validateRegionBasedNavRules(context, path, root) {
8433
+ const XHTML_NS2 = { html: "http://www.w3.org/1999/xhtml" };
8434
+ let regionNavs;
8435
+ try {
8436
+ regionNavs = root.find(".//html:nav", XHTML_NS2);
8437
+ } catch {
8438
+ return;
8439
+ }
8440
+ const packageDoc = context.packageDocument;
8441
+ const opfDir = context.opfPath?.includes("/") ? context.opfPath.substring(0, context.opfPath.lastIndexOf("/")) : "";
8442
+ const docDir = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : "";
8443
+ const manifestByPath = packageDoc ? new Map(packageDoc.manifest.map((m) => [resolveManifestHref(opfDir, m.href), m])) : void 0;
8444
+ for (const nav of regionNavs) {
8445
+ const epubType = nav.attr("type", "epub")?.value ?? "";
8446
+ if (!epubType.split(/\s+/).includes("region-based")) continue;
8447
+ const childEls = nav.find("./html:*", XHTML_NS2);
8448
+ if (childEls.length !== 1 || childEls[0]?.name !== "ol") {
8449
+ pushMessage(context.messages, {
8450
+ id: MessageId.RSC_017,
8451
+ message: "A region-based nav element must contain exactly one child ol element.",
8452
+ location: { path, line: nav.line }
8453
+ });
8454
+ }
8455
+ const liElements = nav.find(".//html:li", XHTML_NS2);
8456
+ for (const li of liElements) {
8457
+ const liChildren = li.find("./html:*", XHTML_NS2);
8458
+ const first = liChildren[0];
8459
+ if (!first || first.name !== "a" && first.name !== "span") {
8460
+ pushMessage(context.messages, {
8461
+ id: MessageId.RSC_017,
8462
+ message: "The first child of a region-based nav list item must be either an 'a' or 'span' element.",
8463
+ location: { path, line: li.line }
8464
+ });
8465
+ }
8466
+ if (liChildren.length > 1 && (liChildren.length !== 2 || liChildren[1]?.name !== "ol")) {
8467
+ pushMessage(context.messages, {
8468
+ id: MessageId.RSC_017,
8469
+ message: "The first child of a region-based nav list item can only be followed by a single 'ol' element.",
8470
+ location: { path, line: li.line }
8471
+ });
8472
+ }
8473
+ }
8474
+ const spans = nav.find(".//html:span", XHTML_NS2);
8475
+ for (const span of spans) {
8476
+ const spanChildren = span.find("./html:*", XHTML_NS2);
8477
+ const aChildren = spanChildren.filter((c) => c.name === "a");
8478
+ if (spanChildren.length !== 2 || aChildren.length !== 2) {
8479
+ pushMessage(context.messages, {
8480
+ id: MessageId.RSC_017,
8481
+ message: "'span' elements in region-based navs must contain exactly two 'a' elements.",
8482
+ location: { path, line: span.line }
8483
+ });
8484
+ }
8485
+ }
8486
+ const anchors = nav.find(".//html:a", XHTML_NS2);
8487
+ for (const a of anchors) {
8488
+ if (a.content.trim() !== "") {
8489
+ pushMessage(context.messages, {
8490
+ id: MessageId.RSC_017,
8491
+ message: "'a' elements in region-based navs should not contain text labels.",
8492
+ location: { path, line: a.line }
8493
+ });
8494
+ }
8495
+ }
8496
+ if (!packageDoc || !manifestByPath) continue;
8497
+ for (const a of anchors) {
8498
+ const href = a.attr("href")?.value;
8499
+ if (!href || !isRelativeURL(href)) continue;
8500
+ const resolved = this.resolveRelativePath(docDir, href, opfDir);
8501
+ const targetPath = parseURL(resolved).resource;
8502
+ if (!targetPath) continue;
8503
+ const item = manifestByPath.get(targetPath);
8504
+ if (!item) continue;
8505
+ if (!isItemFixedLayout(packageDoc, item.id)) {
8506
+ pushMessage(context.messages, {
8507
+ id: MessageId.NAV_009,
8508
+ message: "Region-based navigation links must point to Fixed-Layout Documents.",
8509
+ location: { path, line: a.line }
8510
+ });
8511
+ }
8512
+ }
8513
+ }
8514
+ }
8515
+ /**
8516
+ * EPUB Dictionaries content document rules.
8517
+ *
8518
+ * Mirrors ../epubcheck/src/main/resources/com/adobe/epubcheck/schema/30/dict/dict-xhtml.sch
8519
+ * (minimum set: `dictionary` must be on body/section with article children; each article or
8520
+ * `dictentry` must have a `dfn` descendant outside of optional `condensed-entry`).
8521
+ */
8522
+ validateDictionaryContent(context, path, root) {
8523
+ let typedElements;
8524
+ try {
8525
+ typedElements = root.find(".//*[@epub:type]", EPUB_OPS_NS);
8526
+ } catch {
8527
+ return;
8528
+ }
8529
+ for (const el of typedElements) {
8530
+ const tokens = el.attr("type", "epub")?.value.split(/\s+/) ?? [];
8531
+ if (tokens.includes("dictionary")) {
8532
+ if (el.name !== "body" && el.name !== "section") {
8533
+ pushMessage(context.messages, {
8534
+ id: MessageId.RSC_005,
8535
+ message: 'The "dictionary" type is only allowed on "body" or "section" elements.',
8536
+ location: { path, line: el.line }
8537
+ });
8538
+ }
8539
+ const articles = el.find("./html:article", XHTML_NS);
8540
+ if (articles.length === 0) {
8541
+ pushMessage(context.messages, {
8542
+ id: MessageId.RSC_005,
8543
+ message: 'A "dictionary" must have at least one article child.',
8544
+ location: { path, line: el.line }
8545
+ });
8546
+ }
8547
+ for (const article of articles) {
8548
+ this.checkDictionaryEntry(context, path, article);
8549
+ }
8550
+ }
8551
+ if (tokens.includes("dictentry")) {
8552
+ if (el.name !== "article") {
8553
+ pushMessage(context.messages, {
8554
+ id: MessageId.RSC_005,
8555
+ message: 'The "dictentry" type is only allowed on "article" elements.',
8556
+ location: { path, line: el.line }
8557
+ });
8558
+ } else {
8559
+ this.checkDictionaryEntry(context, path, el);
8560
+ }
8561
+ }
8562
+ }
8563
+ }
8564
+ checkDictionaryEntry(context, path, article) {
8565
+ const dfns = article.find(".//html:dfn", XHTML_NS);
8566
+ const hasDfnOutsideCondensed = dfns.some((dfn) => {
8567
+ let parent = dfn.parent;
8568
+ while (parent) {
8569
+ const type = parent.attr("type", "epub")?.value;
8570
+ if (type?.split(/\s+/).includes("condensed-entry")) return false;
8571
+ parent = parent.parent;
8572
+ }
8573
+ return true;
8574
+ });
8575
+ if (!hasDfnOutsideCondensed) {
8576
+ pushMessage(context.messages, {
8577
+ id: MessageId.RSC_005,
8578
+ message: 'A dictionary entry must have at least one "dfn" descendant (outside of the optional condensed entry "aside").',
8579
+ location: { path, line: article.line }
8580
+ });
8170
8581
  }
8171
8582
  }
8172
8583
  /**
@@ -9563,13 +9974,11 @@ var ContentValidator = class {
9563
9974
  }
9564
9975
  }
9565
9976
  checkUnknownElements(context, path, root) {
9566
- const XHTML_NS2 = "http://www.w3.org/1999/xhtml";
9567
9977
  try {
9568
9978
  const allElements = root.find(".//*");
9569
9979
  for (const el of allElements) {
9570
9980
  const xmlEl = el;
9571
- const ns = xmlEl.namespaceUri;
9572
- if (ns !== XHTML_NS2) continue;
9981
+ if (xmlEl.namespaceUri !== XHTML_NS_URI) continue;
9573
9982
  const localName = xmlEl.name.includes(":") ? xmlEl.name.substring(xmlEl.name.indexOf(":") + 1) : xmlEl.name;
9574
9983
  if (localName.includes("-")) continue;
9575
9984
  if (!HTML5_ELEMENTS.has(localName)) {
@@ -9583,6 +9992,51 @@ var ContentValidator = class {
9583
9992
  } catch {
9584
9993
  }
9585
9994
  }
9995
+ checkEpub2XhtmlStrict(context, path, root) {
9996
+ if (!root.namespaceUri) {
9997
+ pushMessage(context.messages, {
9998
+ id: MessageId.RSC_005,
9999
+ message: `element "${root.name}" from namespace "" is not allowed`,
10000
+ location: { path, line: root.line }
10001
+ });
10002
+ return;
10003
+ }
10004
+ const checkElement = (xmlEl) => {
10005
+ if (xmlEl.namespaceUri !== XHTML_NS_URI) return;
10006
+ const localName = xmlEl.name.includes(":") ? xmlEl.name.substring(xmlEl.name.indexOf(":") + 1) : xmlEl.name;
10007
+ if (!XHTML11_ELEMENTS.has(localName)) {
10008
+ pushMessage(context.messages, {
10009
+ id: MessageId.RSC_005,
10010
+ message: `element "${localName}" not allowed here`,
10011
+ location: { path, line: xmlEl.line }
10012
+ });
10013
+ }
10014
+ for (const attr of xmlEl.attrs) {
10015
+ const ns = attr.namespaceUri;
10016
+ if (!ns || ns === XHTML_NS_URI || ns === XML_NS_URI) continue;
10017
+ const qname = attr.prefix ? `${attr.prefix}:${attr.name}` : attr.name;
10018
+ pushMessage(context.messages, {
10019
+ id: MessageId.RSC_005,
10020
+ message: `attribute "${qname}" not allowed here`,
10021
+ location: { path, line: xmlEl.line }
10022
+ });
10023
+ }
10024
+ };
10025
+ checkElement(root);
10026
+ try {
10027
+ for (const el of root.find(".//*")) {
10028
+ checkElement(el);
10029
+ }
10030
+ for (const a of root.find(".//html:a//html:a", XHTML_NS)) {
10031
+ pushMessage(context.messages, {
10032
+ id: MessageId.RSC_005,
10033
+ message: 'The "a" element cannot contain any nested "a" elements',
10034
+ location: { path, line: a.line }
10035
+ });
10036
+ }
10037
+ } catch {
10038
+ }
10039
+ }
9586
10040
  checkForeignObjectContent(context, path, root, isSVGDoc) {
9587
10041
  const SVG_NS = { svg: "http://www.w3.org/2000/svg" };
9588
10042
  const XHTML_URI = "http://www.w3.org/1999/xhtml";
@@ -9791,6 +10245,7 @@ var NCXValidator = class {
9791
10245
  this.checkContentSrc(context, root, ncxPath, registry);
9792
10246
  this.checkEmptyLabels(context, root, ncxPath);
9793
10247
  this.checkPageTargets(context, root, ncxPath);
10248
+ this.checkIdSyntax(context, root, ncxPath);
9794
10249
  } finally {
9795
10250
  doc.dispose();
9796
10251
  }
@@ -9838,8 +10293,8 @@ var NCXValidator = class {
9838
10293
  const hashIdx = src.indexOf("#");
9839
10294
  const srcBase = hashIdx >= 0 ? src.substring(0, hashIdx) : src;
9840
10295
  const fragment = hashIdx >= 0 ? src.substring(hashIdx + 1) : "";
9841
- const isRemote = srcBase.startsWith("http://") || srcBase.startsWith("https://");
9842
- const fullPath = isRemote ? srcBase : resolvePath(ncxPath, srcBase);
10296
+ const isRemote = isRemoteURL(srcBase);
10297
+ const fullPath = isRemote ? srcBase : resolvePath(ncxPath, tryDecodeUriComponent(srcBase)).normalize("NFC");
9843
10298
  if (!context.files.has(fullPath) && !isRemote) {
9844
10299
  const line = contentElem.line;
9845
10300
  pushMessage(context.messages, {
@@ -9894,10 +10349,21 @@ var NCXValidator = class {
9894
10349
  }
9895
10350
  }
9896
10351
  }
9897
- /**
9898
- * pageTarget@type must be one of "front", "normal", "special".
9899
- * Reported via RSC-005 to mirror Java Schematron output.
9900
- */
10352
+ checkIdSyntax(context, root, ncxPath) {
10353
+ const nodes = root.find(".//*[@id]", {
10354
+ ncx: "http://www.daisy.org/z3986/2005/ncx/"
10355
+ });
10356
+ for (const node of nodes) {
10357
+ const idValue = node.attr("id")?.value;
10358
+ if (idValue && !NC_NAME_REGEX.test(idValue)) {
10359
+ pushMessage(context.messages, {
10360
+ id: MessageId.RSC_005,
10361
+ message: `Invalid id "${idValue}"; must match xs:ID syntax (NCName)`,
10362
+ location: { path: ncxPath, line: node.line }
10363
+ });
10364
+ }
10365
+ }
10366
+ }
9901
10367
  checkPageTargets(context, root, ncxPath) {
9902
10368
  const pageTargets = root.find(".//ncx:pageTarget[@type]", {
9903
10369
  ncx: "http://www.daisy.org/z3986/2005/ncx/"
@@ -9915,6 +10381,7 @@ var NCXValidator = class {
9915
10381
  }
9916
10382
  }
9917
10383
  };
10384
+ var NC_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9._-]*$/;
9918
10385
  var PAGE_TARGET_TYPES = /* @__PURE__ */ new Set(["front", "normal", "special"]);
9919
10386
  var OPS_MEDIA_TYPES = /* @__PURE__ */ new Set([
9920
10387
  "application/xhtml+xml",
@@ -10044,15 +10511,6 @@ function parseContainerContent(content, context, fileExists, getFileContent) {
10044
10511
  }
10045
10512
  }
10046
10513
  }
10047
- for (const rootfile of context.rootfiles) {
10048
- if (!fileExists(rootfile.path)) {
10049
- pushMessage(context.messages, {
10050
- id: MessageId.PKG_010,
10051
- message: `Rootfile "${rootfile.path}" not found in EPUB`,
10052
- location: { path: containerPath }
10053
- });
10054
- }
10055
- }
10056
10514
  }
10057
10515
  function validateMappingDocumentContent(xml, mappingPath, context) {
10058
10516
  const stripped = stripXmlComments(xml);
@@ -10971,19 +11429,7 @@ var ReferenceValidator = class {
10971
11429
  message: "Absolute paths are not allowed in EPUB",
10972
11430
  location: reference.location
10973
11431
  });
10974
- }
10975
- const forbiddenParentDirTypes = [
10976
- "hyperlink" /* HYPERLINK */,
10977
- "nav-toc-link" /* NAV_TOC_LINK */,
10978
- "nav-pagelist-link" /* NAV_PAGELIST_LINK */
10979
- ];
10980
- if (hasParentDirectoryReference(reference.url) && forbiddenParentDirTypes.includes(reference.type)) {
10981
- pushMessage(context.messages, {
10982
- id: MessageId.RSC_026,
10983
- message: "Parent directory references (..) are not allowed",
10984
- location: reference.location
10985
- });
10986
- } else if (!hasAbsolutePath(resourcePath) && !hasParentDirectoryReference(reference.url) && checkUrlLeaking(reference.url)) {
11432
+ } else if (checkUrlLeaking(reference.url, reference.location.path)) {
10987
11433
  pushMessage(context.messages, {
10988
11434
  id: MessageId.RSC_026,
10989
11435
  message: `URL "${reference.url}" leaks outside the container`,
@@ -11021,6 +11467,16 @@ var ReferenceValidator = class {
11021
11467
  location: reference.location
11022
11468
  });
11023
11469
  }
11470
+ if (reference.type === "search-key" /* SEARCH_KEY */ && !resource?.inSpine) {
11471
+ const isEpubCfi = reference.fragment?.startsWith("epubcfi(") ?? false;
11472
+ if (!isEpubCfi) {
11473
+ pushMessage(context.messages, {
11474
+ id: MessageId.RSC_021,
11475
+ message: `Search Key Map target "${resourcePath}" must be a Content Document in the spine`,
11476
+ location: reference.location
11477
+ });
11478
+ }
11479
+ }
11024
11480
  if (isHyperlinkLike || reference.type === "overlay-text-link" /* OVERLAY_TEXT_LINK */) {
11025
11481
  const targetMimeType = resource?.mimeType;
11026
11482
  if (targetMimeType && !this.isBlessedItemType(targetMimeType, context.version) && !this.isDeprecatedBlessedItemType(targetMimeType) && !resource.hasCoreMediaTypeFallback) {
@@ -11505,11 +11961,11 @@ var RelaxNGValidator = class extends BaseSchemaValidator {
11505
11961
  try {
11506
11962
  const libxml2 = await import('libxml2-wasm');
11507
11963
  const LibRelaxNGValidator = libxml2.RelaxNGValidator;
11508
- const { XmlDocument: XmlDocument4 } = libxml2;
11509
- const doc = XmlDocument4.fromString(xml);
11964
+ const { XmlDocument: XmlDocument5 } = libxml2;
11965
+ const doc = XmlDocument5.fromString(xml);
11510
11966
  try {
11511
11967
  const schemaContent = await loadSchema(schemaPath);
11512
- const schemaDoc = XmlDocument4.fromString(schemaContent);
11968
+ const schemaDoc = XmlDocument5.fromString(schemaContent);
11513
11969
  try {
11514
11970
  const validator = LibRelaxNGValidator.fromDoc(schemaDoc);
11515
11971
  try {
@@ -11973,6 +12429,73 @@ var EpubCheck = class _EpubCheck {
11973
12429
  location: { path: opfPath }
11974
12430
  });
11975
12431
  }
12432
+ if (profile === "dict" && context.packageDocument) {
12433
+ const opfDir = opfPath.includes("/") ? opfPath.substring(0, opfPath.lastIndexOf("/")) : "";
12434
+ const manifestByPath = /* @__PURE__ */ new Map();
12435
+ for (const item of context.packageDocument.manifest) {
12436
+ manifestByPath.set(resolveManifestHref(opfDir, item.href), item);
12437
+ }
12438
+ const dictPaths = features.dictionaryContentPaths ?? /* @__PURE__ */ new Set();
12439
+ for (const coll of context.packageDocument.collections) {
12440
+ if (coll.role !== "dictionary") continue;
12441
+ const hasDictContent = coll.links.some((href) => {
12442
+ const full = resolveManifestHref(opfDir, href);
12443
+ const item = manifestByPath.get(full);
12444
+ return item?.mediaType === "application/xhtml+xml" && dictPaths.has(full);
12445
+ });
12446
+ if (!hasDictContent) {
12447
+ pushMessage(context.messages, {
12448
+ id: MessageId.OPF_078,
12449
+ message: 'An EPUB Dictionary must contain at least one Content Document with dictionary content (epub:type "dictionary")',
12450
+ location: { path: opfPath }
12451
+ });
12452
+ }
12453
+ }
12454
+ }
12455
+ }
12456
+ /**
12457
+ * Mirrors ../epubcheck/src/main/resources/com/adobe/epubcheck/schema/30/edupub/
12458
+ * edu-ocf-metadata.sch (META-INF/metadata.xml) and edu-opf.sch (each OPF).
12459
+ *
12460
+ * Non-primary OPFs are otherwise unreached: runPipeline only runs OPFValidator
12461
+ * on context.opfPath.
12462
+ */
12463
+ validateEdupubMultiRendition(context) {
12464
+ if (context.options.profile !== "edupub") return;
12465
+ if (context.rootfiles.length <= 1) return;
12466
+ const metadataPath = "META-INF/metadata.xml";
12467
+ const metadataData = context.files.get(metadataPath);
12468
+ if (metadataData) {
12469
+ const content = typeof metadataData === "string" ? metadataData : new TextDecoder().decode(metadataData);
12470
+ const stripped = stripXmlComments(content);
12471
+ if (!/<dc:type\b[^>]*>\s*edupub\s*<\/dc:type>/i.test(stripped)) {
12472
+ pushMessage(context.messages, {
12473
+ id: MessageId.RSC_005,
12474
+ message: 'A dc:type element with the value "edupub" is required.',
12475
+ location: { path: metadataPath }
12476
+ });
12477
+ }
12478
+ }
12479
+ const primary = context.opfPath;
12480
+ for (const rootfile of context.rootfiles) {
12481
+ if (rootfile.path === primary) continue;
12482
+ if (rootfile.mediaType !== "application/oebps-package+xml") continue;
12483
+ const path = rootfile.path.normalize("NFC");
12484
+ const opfData = context.files.get(path);
12485
+ if (!opfData) continue;
12486
+ const xml = typeof opfData === "string" ? opfData : new TextDecoder().decode(opfData);
12487
+ const pkg = parseOPF(xml);
12488
+ const hasType = pkg.dcElements.some(
12489
+ (dc) => dc.name === "type" && dc.value.trim() === "edupub"
12490
+ );
12491
+ if (!hasType) {
12492
+ pushMessage(context.messages, {
12493
+ id: MessageId.RSC_005,
12494
+ message: 'The dc:type identifier "edupub" is required.',
12495
+ location: { path }
12496
+ });
12497
+ }
12498
+ }
11976
12499
  }
11977
12500
  /**
11978
12501
  * Validate NCX navigation document (EPUB 2 always, EPUB 3 when NCX present)
@@ -12118,6 +12641,7 @@ var EpubCheck = class _EpubCheck {
12118
12641
  const contentValidator = new ContentValidator();
12119
12642
  contentValidator.validate(context, registry, refValidator);
12120
12643
  this.validateCrossDocumentFeatures(context);
12644
+ this.validateEdupubMultiRendition(context);
12121
12645
  if (context.packageDocument) {
12122
12646
  this.validateNCX(context, registry);
12123
12647
  }