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