@storyteller-platform/epub 1.0.1 → 1.0.3

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.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- /** @jsxImportSource @storyteller-platform/epub */
2
1
  import type { Document, Element, Node } from "@xmldom/xmldom";
3
2
  import type { AdapterOptions, ArchiveSource, EpubStorageAdapter, EpubStorageAdapterClass, EpubStorageKind } from "./adapters/interface.ts";
4
3
  import * as Upgrade from "./upgrade.tsx";
@@ -24,6 +23,17 @@ declare global {
24
23
  }
25
24
  }
26
25
  }
26
+ declare module "@esfx/async-readerwriterlock" {
27
+ interface AsyncReaderWriterLockReader {
28
+ [Symbol.dispose](): void;
29
+ }
30
+ interface AsyncReaderWriterLockWriter {
31
+ [Symbol.dispose](): void;
32
+ }
33
+ interface AsyncReaderWriterLockUpgradeableReader {
34
+ [Symbol.dispose](): void;
35
+ }
36
+ }
27
37
  export type ManifestItem = {
28
38
  id: string;
29
39
  href: string;
@@ -177,7 +187,7 @@ export declare class Epub {
177
187
  private rootfile;
178
188
  private manifest;
179
189
  private spine;
180
- private packageMutex;
190
+ private packageLock;
181
191
  /**
182
192
  * Storage backend kind in use for this instance
183
193
  *
@@ -246,14 +256,15 @@ export declare class Epub {
246
256
  getRootfile(): Promise<string>;
247
257
  getPackageDocumentSource(): Promise<string>;
248
258
  private getPackageDocument;
249
- getPackageElement(): Promise<Element>;
259
+ getPackageElement(lock?: boolean): Promise<Element>;
250
260
  /**
251
261
  * Safely modify the package document, without race conditions.
252
262
  *
253
263
  * Since the reading the package document is an async process,
254
264
  * multiple simultaneously dispatched function calls that all
255
265
  * attempt to modify it can clobber each other's changes. This
256
- * method uses a mutex to ensure that each update runs exclusively.
266
+ * method uses a read/write lock to ensure that each update runs
267
+ * exclusively.
257
268
  *
258
269
  * @param producer The function to update the package document. If
259
270
  * it returns a new package document, that will be persisted, otherwise
@@ -264,7 +275,9 @@ export declare class Epub {
264
275
  * Retrieve the manifest for the Epub.
265
276
  *
266
277
  * This is represented as a map from each manifest items'
267
- * id to the rest of its properties.
278
+ * id to the rest of its properties. Manifest hrefs are
279
+ * always URI-encoded (e.g. `chapter one.xhtml` -> `chapter%20one.xhtml`),
280
+ * even if the raw XML is (incorrectly) not URI-encoded.
268
281
  *
269
282
  * @link https://www.w3.org/TR/epub-33/#sec-pkg-manifest
270
283
  */
package/dist/index.js CHANGED
@@ -1,5 +1,58 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
1
53
  import { jsx as _jsx, jsxs as _jsxs } from "@storyteller-platform/epub/jsx-runtime";
2
- import { Mutex } from "async-mutex";
54
+ /** @jsxImportSource @storyteller-platform/epub */
55
+ import { AsyncReaderWriterLock } from "@esfx/async-readerwriterlock";
3
56
  import memoize from "mem";
4
57
  import { MediaType } from "@storyteller-platform/media-types";
5
58
  import { copyFile } from "#files";
@@ -24,6 +77,16 @@ export class EpubReadOnlyError extends Error {
24
77
  * makes it easy to distinguish normal hrefs from external/invalid ones
25
78
  */
26
79
  const ENTRY_ORIGIN = "epub:///";
80
+ function ensureEncodedPath(href) {
81
+ let decoded = href;
82
+ try {
83
+ decoded = decodeURIComponent(href);
84
+ }
85
+ catch {
86
+ // href may be un-encoded but contain a raw % symbol
87
+ }
88
+ return entryToUrlPath(decoded);
89
+ }
27
90
  /**
28
91
  * Needs to be convoluted like this, bc
29
92
  * - `encodeURIComponent` escapes `/` and `\`
@@ -132,7 +195,7 @@ export class Epub {
132
195
  rootfile = null;
133
196
  manifest = null;
134
197
  spine = null;
135
- packageMutex = new Mutex();
198
+ packageLock = new AsyncReaderWriterLock();
136
199
  /**
137
200
  * Storage backend kind in use for this instance
138
201
  *
@@ -263,15 +326,32 @@ export class Epub {
263
326
  ensureOpfNamespace(packageDocument);
264
327
  return packageDocument;
265
328
  }
266
- async getPackageElement() {
267
- const packageDocument = await this.getPackageDocument();
268
- const packageElement = packageDocument.documentElement?.localName === "package"
269
- ? packageDocument.documentElement
270
- : null;
271
- if (!packageElement) {
272
- throw new Error("Failed to parse EPUB: Found no package element in package document");
329
+ async getPackageElement(lock = true) {
330
+ const env_1 = { stack: [], error: void 0, hasError: false };
331
+ try {
332
+ const stack = __addDisposableResource(env_1, new DisposableStack(), false);
333
+ if (!lock) {
334
+ stack.adopt(await this.packageLock.read(), (lock) => {
335
+ lock.unlock();
336
+ });
337
+ }
338
+ const packageDocument = await this.getPackageDocument();
339
+ stack.dispose();
340
+ const packageElement = packageDocument.documentElement?.localName === "package"
341
+ ? packageDocument.documentElement
342
+ : null;
343
+ if (!packageElement) {
344
+ throw new Error("Failed to parse EPUB: Found no package element in package document");
345
+ }
346
+ return packageElement;
347
+ }
348
+ catch (e_1) {
349
+ env_1.error = e_1;
350
+ env_1.hasError = true;
351
+ }
352
+ finally {
353
+ __disposeResources(env_1);
273
354
  }
274
- return packageElement;
275
355
  }
276
356
  /**
277
357
  * Safely modify the package document, without race conditions.
@@ -279,15 +359,18 @@ export class Epub {
279
359
  * Since the reading the package document is an async process,
280
360
  * multiple simultaneously dispatched function calls that all
281
361
  * attempt to modify it can clobber each other's changes. This
282
- * method uses a mutex to ensure that each update runs exclusively.
362
+ * method uses a read/write lock to ensure that each update runs
363
+ * exclusively.
283
364
  *
284
365
  * @param producer The function to update the package document. If
285
366
  * it returns a new package document, that will be persisted, otherwise
286
367
  * it will be assumed that the package document was modified in place.
287
368
  */
288
369
  async withPackage(producer) {
289
- this.assertWritable();
290
- await this.packageMutex.runExclusive(async () => {
370
+ const env_2 = { stack: [], error: void 0, hasError: false };
371
+ try {
372
+ this.assertWritable();
373
+ const _ = __addDisposableResource(env_2, await this.packageLock.write(), false);
291
374
  const packageDocument = await this.getPackageDocument();
292
375
  const packageElement = packageDocument.documentElement?.localName === "package"
293
376
  ? packageDocument.documentElement
@@ -299,43 +382,65 @@ export class Epub {
299
382
  const updatedPackageDocument = serialize(produced ?? packageDocument);
300
383
  const rootfile = await this.getRootfile();
301
384
  await this.writeEntryContents(rootfile, updatedPackageDocument, "utf-8");
302
- });
385
+ }
386
+ catch (e_2) {
387
+ env_2.error = e_2;
388
+ env_2.hasError = true;
389
+ }
390
+ finally {
391
+ __disposeResources(env_2);
392
+ }
303
393
  }
304
394
  /**
305
395
  * Retrieve the manifest for the Epub.
306
396
  *
307
397
  * This is represented as a map from each manifest items'
308
- * id to the rest of its properties.
398
+ * id to the rest of its properties. Manifest hrefs are
399
+ * always URI-encoded (e.g. `chapter one.xhtml` -> `chapter%20one.xhtml`),
400
+ * even if the raw XML is (incorrectly) not URI-encoded.
309
401
  *
310
402
  * @link https://www.w3.org/TR/epub-33/#sec-pkg-manifest
311
403
  */
312
404
  async getManifest() {
313
- if (this.manifest !== null)
314
- return this.manifest;
315
- const packageElement = await this.getPackageElement();
316
- const manifest = findXmlChildByNameNS(NS.opf, "manifest", packageElement);
317
- if (!manifest) {
318
- throw new Error("Failed to parse EPUB: Found no manifest element in package document");
319
- }
320
- this.manifest = Array.from(manifest.children).reduce((acc, item) => {
321
- const id = item.getAttribute("id");
322
- const href = item.getAttribute("href");
323
- if (!id || !href) {
324
- return acc;
405
+ const env_3 = { stack: [], error: void 0, hasError: false };
406
+ try {
407
+ if (this.manifest !== null)
408
+ return this.manifest;
409
+ const _ = __addDisposableResource(env_3, await this.packageLock.read(), false);
410
+ const packageElement = await this.getPackageElement(false);
411
+ const manifest = findXmlChildByNameNS(NS.opf, "manifest", packageElement);
412
+ if (!manifest) {
413
+ throw new Error("Failed to parse EPUB: Found no manifest element in package document");
325
414
  }
326
- return {
327
- ...acc,
328
- [id]: {
415
+ const manifestRecord = {};
416
+ for (const item of Array.from(manifest.children)) {
417
+ const id = item.getAttribute("id");
418
+ const hrefAttr = item.getAttribute("href");
419
+ if (!hrefAttr)
420
+ continue;
421
+ const href = ensureEncodedPath(hrefAttr);
422
+ if (!id || !href) {
423
+ continue;
424
+ }
425
+ manifestRecord[id] = {
329
426
  id,
330
427
  href,
331
428
  mediaType: item.getAttribute("media-type") ?? undefined,
332
429
  mediaOverlay: item.getAttribute("media-overlay") ?? undefined,
333
430
  fallback: item.getAttribute("fallback") ?? undefined,
334
431
  properties: item.getAttribute("properties")?.split(" ") ?? undefined,
335
- },
336
- };
337
- }, {});
338
- return this.manifest;
432
+ };
433
+ }
434
+ this.manifest = manifestRecord;
435
+ return this.manifest;
436
+ }
437
+ catch (e_3) {
438
+ env_3.error = e_3;
439
+ env_3.hasError = true;
440
+ }
441
+ finally {
442
+ __disposeResources(env_3);
443
+ }
339
444
  }
340
445
  /**
341
446
  * Returns the first index in the metadata element's children array
@@ -477,12 +582,12 @@ export class Epub {
477
582
  * @link https://www.w3.org/TR/epub-33/#sec-opf-dcidentifier
478
583
  */
479
584
  async setUniqueIdentifier(identifier) {
480
- await this.withPackage(async (packageElement) => {
585
+ await this.withPackage((packageElement) => {
481
586
  const metadata = findXmlChildByNameNS(NS.opf, "metadata", packageElement);
482
587
  if (!metadata) {
483
588
  throw new Error("Failed to parse EPUB: found no metadata element in package document");
484
589
  }
485
- let uniqueId = await this.getUniqueIdentifierId();
590
+ let uniqueId = packageElement.getAttribute("unique-identifier") ?? null;
486
591
  if (!uniqueId) {
487
592
  // Create a new unique identifier id
488
593
  const newUniqueId = xmlId();
@@ -1187,8 +1292,12 @@ export class Epub {
1187
1292
  * @link https://www.w3.org/TR/epub-33/#sec-prefix-attr
1188
1293
  */
1189
1294
  async setPackageVocabularyPrefix(prefix, uri) {
1190
- await this.withPackage(async (packageElement) => {
1191
- const prefixes = await this.getPackageVocabularyPrefixes();
1295
+ await this.withPackage((packageElement) => {
1296
+ const prefixValue = packageElement.getAttribute("prefix");
1297
+ const matches = prefixValue?.matchAll(/(?:([a-z]+): +(\S+)\s*)/gs);
1298
+ const prefixes = matches
1299
+ ? Array.from(matches).reduce((acc, match) => match[1] && match[2] ? { ...acc, [match[1]]: match[2] } : acc, {})
1300
+ : {};
1192
1301
  prefixes[prefix] = uri;
1193
1302
  packageElement.setAttribute("prefix", Object.entries(prefixes)
1194
1303
  .map(([p, u]) => `${p}: ${u}`)
@@ -1538,21 +1647,32 @@ export class Epub {
1538
1647
  return this.addCreator(contributor, index, "contributor");
1539
1648
  }
1540
1649
  async getSpine() {
1541
- if (this.spine !== null)
1650
+ const env_4 = { stack: [], error: void 0, hasError: false };
1651
+ try {
1652
+ if (this.spine !== null)
1653
+ return this.spine;
1654
+ const _ = __addDisposableResource(env_4, await this.packageLock.read(), false);
1655
+ const packageElement = await this.getPackageElement(false);
1656
+ const spine = findXmlChildByNameNS(NS.opf, "spine", packageElement);
1657
+ if (!spine)
1658
+ throw new Error("Failed to parse EPUB: Found no spine element in package document");
1659
+ this.spine = Array.from(spine.children)
1660
+ .map((itemref) => {
1661
+ return {
1662
+ itemRef: itemref.getAttribute("idref"),
1663
+ linear: itemref.getAttribute("linear") !== "no",
1664
+ };
1665
+ })
1666
+ .filter((itemref) => !!itemref.itemRef);
1542
1667
  return this.spine;
1543
- const packageElement = await this.getPackageElement();
1544
- const spine = findXmlChildByNameNS(NS.opf, "spine", packageElement);
1545
- if (!spine)
1546
- throw new Error("Failed to parse EPUB: Found no spine element in package document");
1547
- this.spine = Array.from(spine.children)
1548
- .map((itemref) => {
1549
- return {
1550
- itemRef: itemref.getAttribute("idref"),
1551
- linear: itemref.getAttribute("linear") !== "no",
1552
- };
1553
- })
1554
- .filter((itemref) => !!itemref.itemRef);
1555
- return this.spine;
1668
+ }
1669
+ catch (e_4) {
1670
+ env_4.error = e_4;
1671
+ env_4.hasError = true;
1672
+ }
1673
+ finally {
1674
+ __disposeResources(env_4);
1675
+ }
1556
1676
  }
1557
1677
  /**
1558
1678
  * Retrieve the manifest items that make up the Epub's spine.
@@ -1605,9 +1725,9 @@ export class Epub {
1605
1725
  insert(spine, item, index === undefined
1606
1726
  ? null
1607
1727
  : (Array.from(spine.children)[index] ?? null));
1728
+ // Reset the spine cache
1729
+ this.spine = null;
1608
1730
  });
1609
- // Reset the spine cache
1610
- this.spine = null;
1611
1731
  }
1612
1732
  /**
1613
1733
  * Remove the spine item at the specified index.
@@ -1622,9 +1742,9 @@ export class Epub {
1622
1742
  const itemref = Array.from(spine.children)[index];
1623
1743
  if (itemref)
1624
1744
  spine.removeChild(itemref);
1745
+ // Reset the spine cache
1746
+ this.spine = null;
1625
1747
  });
1626
- // Reset the spine cache
1627
- this.spine = null;
1628
1748
  }
1629
1749
  async getNavigationChildren(ol, navHref, { resolveToRoot } = {}) {
1630
1750
  const children = [];
@@ -1781,8 +1901,6 @@ export class Epub {
1781
1901
  Use if resolving a relative href from a file other than the package document.
1782
1902
  */
1783
1903
  async resolveHref(href, relativeTo, { toRoot } = {}) {
1784
- if (href.startsWith("#"))
1785
- return href;
1786
1904
  const rootfile = await this.getRootfile();
1787
1905
  const from = relativeTo
1788
1906
  ? await this.resolveEntry(rootfile, relativeTo)
@@ -1790,7 +1908,7 @@ export class Epub {
1790
1908
  const url = resolveEntryUrl(from, href);
1791
1909
  if (!url)
1792
1910
  return href;
1793
- const entry = (await this.entryForUrl(url)).split("/");
1911
+ const entry = url.pathname.slice(1).split("/");
1794
1912
  const base = toRoot ? [] : rootfile.split("/").slice(0, -1);
1795
1913
  let shared = 0;
1796
1914
  while (shared < base.length && base[shared] === entry[shared]) {
@@ -1798,9 +1916,9 @@ export class Epub {
1798
1916
  }
1799
1917
  const relative = [
1800
1918
  ...Array(base.length - shared).fill(".."),
1801
- ...entry.slice(shared).map(encodeURIComponent),
1919
+ ...entry.slice(shared),
1802
1920
  ];
1803
- return relative.join("/") + url.hash;
1921
+ return ensureEncodedPath(relative.join("/")) + url.hash;
1804
1922
  }
1805
1923
  async readFileContents(href, relativeTo, encoding) {
1806
1924
  const rootfile = await this.getRootfile();
@@ -1946,17 +2064,20 @@ export class Epub {
1946
2064
  if (!href)
1947
2065
  return;
1948
2066
  await this.removeEntry(href);
2067
+ // Reset the cached manifest and spine, so that they will be read from
2068
+ // the updated XML next time
2069
+ this.manifest = null;
2070
+ this.spine = null;
1949
2071
  });
1950
- // Reset the cached manifest and spine, so that they will be read from
1951
- // the updated XML next time
1952
- this.manifest = null;
1953
- this.spine = null;
1954
2072
  }
1955
2073
  async addManifestItem(item, contents, encoding) {
1956
2074
  const existingItems = Object.values(await this.getManifest());
1957
2075
  if (existingItems.some((existing) => existing.id === item.id)) {
1958
2076
  throw new Error(`Cannot add manifest item: an item with the id "${item.id}" already exists`);
1959
2077
  }
2078
+ if (item.href !== ensureEncodedPath(item.href)) {
2079
+ throw new Error(`Manifest hrefs must be URI encoded. Received ${item.href}`);
2080
+ }
1960
2081
  // 4.2.1: hrefs that normalize and case-fold to the same path would
1961
2082
  // overwrite each other on a normalizing filesystem.
1962
2083
  const foldedHref = foldPathName(item.href);
@@ -1970,10 +2091,10 @@ export class Epub {
1970
2091
  if (!manifest)
1971
2092
  throw new Error("Failed to parse EPUB: Found no manifest element in package document");
1972
2093
  insert(manifest, _jsx("item", { id: item.id, href: item.href, ...(item.mediaType && { "media-type": item.mediaType }), ...(item.fallback && { fallback: item.fallback }), ...(item.mediaOverlay && { "media-overlay": item.mediaOverlay }), ...(item.properties && { properties: item.properties.join(" ") }) }));
2094
+ // Reset the cached manifest, so that it will be read from
2095
+ // the updated XML next time
2096
+ this.manifest = null;
1973
2097
  });
1974
- // Reset the cached manifest, so that it will be read from
1975
- // the updated XML next time
1976
- this.manifest = null;
1977
2098
  const rootfile = await this.getRootfile();
1978
2099
  const filename = await this.resolveEntry(rootfile, item.href);
1979
2100
  /* oxlint-disable typescript/no-unnecessary-type-assertion -- tsgolint treats these casts as unnecessary, but each one still narrows the value that is written out */
@@ -2006,10 +2127,10 @@ export class Epub {
2006
2127
  }), ...(newItem.properties && {
2007
2128
  properties: newItem.properties.join(" "),
2008
2129
  }) }), existing);
2130
+ // Reset the cached manifest, so that it will be read from
2131
+ // the updated XML next time
2132
+ this.manifest = null;
2009
2133
  });
2010
- // Reset the cached manifest, so that it will be read from
2011
- // the updated XML next time
2012
- this.manifest = null;
2013
2134
  }
2014
2135
  /**
2015
2136
  * Add a new metadata entry to the Epub.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@storyteller-platform/epub",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "types": "dist/index.d.ts",
6
6
  "license": "MIT",
@@ -80,12 +80,12 @@
80
80
  "vitest": "^5.0.0"
81
81
  },
82
82
  "dependencies": {
83
+ "@esfx/async-readerwriterlock": "^1.0.0",
83
84
  "@storyteller-platform/fs": "^0.3.0",
84
85
  "@storyteller-platform/media-types": "^0.2.1",
85
86
  "@storyteller-platform/path": "^0.3.1",
86
87
  "@xmldom/xmldom": "^0.9.12",
87
88
  "@zip.js/zip.js": "^2.18.2",
88
- "async-mutex": "^0.5.0",
89
89
  "mem": "^8.0.0",
90
90
  "nanoid": "^5.1.5",
91
91
  "yauzl-promise": "^4.0.0",