✨ Add Node.js parsing-only entry point#4
Conversation
Add `@likecoin/epub-ts/node` subpath export that shims DOMParser, XMLSerializer, and document via linkedom, enabling EPUB parsing (metadata, spine, navigation, section rendering) in Node.js without a browser. - Add linkedom as optional peerDependency - Create src/node.ts with linkedom shims and parsing-safe re-exports - Add vite.config.node.ts for ESM + CJS node bundles - Guard window references in archive.ts, url.ts, replacements.ts - Replace window.decodeURIComponent with global decodeURIComponent - Add try/catch for CSS namespace selectors in querySelectorByType - Add getElementsByTagName fallback for parsers without NS support - Add 10 Node.js integration tests (191 total)
There was a problem hiding this comment.
Pull request overview
Adds a Node.js-focused, parsing-only entry point to epub.ts, allowing EPUB parsing (metadata/spine/navigation/section serialization) in non-browser environments by shimming required DOM globals via linkedom.
Changes:
- Add
@likecoin/epub-ts/nodesubpath export plus a dedicated Node build (vite.config.node.ts) producing ESM+CJS bundles. - Introduce
src/node.tsto installDOMParser/XMLSerializer/documentshims and re-export parsing-safe APIs. - Improve Node/SSR compatibility by guarding
windowusage, using globaldecodeURIComponent, and adding selector/namespace fallbacks; add Node integration tests.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| vite.config.node.ts | Adds a second Vite library build to output Node-specific ESM/CJS bundles. |
| src/node.ts | Node entry point: installs linkedom-based DOM shims and re-exports core classes/types. |
| package.json | Adds ./node export and updates build script; declares linkedom as optional peer + dev dependency. |
| package-lock.json | Locks linkedom and its transitive dependencies. |
| src/archive.ts | Removes window.decodeURIComponent usage and guards URL API access for Node. |
| src/utils/url.ts | Guards window.location usage to avoid crashing in Node/SSR. |
| src/utils/replacements.ts | Guards window.location usage to avoid crashing in Node/SSR. |
| src/utils/core.ts | Adds try/catch + fallback logic for environments lacking CSS namespace selector support. |
| src/packaging.ts | Adds a non-namespace fallback for DC metadata lookup for parsers without NS support. |
| test/node.test.ts | Adds Node environment integration tests for opening/parsing/rendering a fixture EPUB. |
| README.md | Documents the new Node parsing-only entry point and basic usage. |
| PROJECT_STATUS.md | Updates project status and artifact list to reflect Node entry point + added tests. |
| CHANGELOG.md | Adds release notes for the Node entry point and related compatibility fixes. |
| AGENTS.md | Documents src/node.ts as the Node entry point. |
src/archive.ts
Outdated
| const decodededUrl = decodeURIComponent(url.substr(1)); // Remove first slash | ||
| const entry = this.zip!.file(decodededUrl); |
There was a problem hiding this comment.
Typo in the new/modified variable name decodededUrl makes the code harder to read and search. Consider renaming to decodedUrl (and updating the subsequent uses) in this method.
src/archive.ts
Outdated
| const decodededUrl = decodeURIComponent(url.substr(1)); // Remove first slash | ||
| const entry = this.zip!.file(decodededUrl); |
There was a problem hiding this comment.
Typo in the new/modified variable name decodededUrl makes the code harder to read and search. Consider renaming to decodedUrl (and updating the subsequent uses) in this method.
src/node.ts
Outdated
|
|
||
| if (typeof globalThis.document === "undefined") { | ||
| const { document } = parseHTML("<!DOCTYPE html><html><head></head><body></body></html>"); | ||
| (globalThis as any).document = document; |
There was a problem hiding this comment.
(globalThis as any).document = document introduces an any escape hatch in a public entry point. Prefer a typed assignment (e.g., a narrow type assertion on globalThis or a declare global augmentation for document) so this stays type-safe under strict mode.
| (globalThis as any).document = document; | |
| (globalThis as typeof globalThis & { document: Document }).document = document as Document; |
README.md
Outdated
| import { readFileSync } from "node:fs"; | ||
|
|
||
| const data = readFileSync("book.epub"); | ||
| const book = new Book(data.buffer); |
There was a problem hiding this comment.
The Node.js README example passes data.buffer directly from a Node Buffer. For many buffers this includes extra bytes (because of byteOffset/byteLength), which can corrupt the EPUB input. Convert to an exact ArrayBuffer slice (like the Node test does) before constructing Book.
| const book = new Book(data.buffer); | |
| const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); | |
| const book = new Book(arrayBuffer); |
src/archive.ts
Outdated
| const decodededUrl = decodeURIComponent(url.substr(1)); // Remove first slash | ||
| const entry = this.zip!.file(decodededUrl); |
There was a problem hiding this comment.
Typo in the new/modified variable name decodededUrl makes the code harder to read and search. Consider renaming to decodedUrl (and updating the subsequent uses) in this method.
- Fix typo: decodededUrl → decodedUrl in archive.ts (6 occurrences) - Remove `any` escape: use typed assertion for globalThis.document in node.ts - Fix README example: slice Buffer to exact ArrayBuffer to avoid corruption
README.md
Outdated
|
|
||
| ### Node.js (parsing only) | ||
|
|
||
| Parse EPUB metadata, spine, navigation, and section content without a browser. Requires [`linkedom`](https://github.com/nicoleahmed/linkedom) as a peer dependency. |
There was a problem hiding this comment.
The linkedom GitHub URL is incorrect. It should point to https://github.com/WebReflection/linkedom (the official repository by WebReflection), not https://github.com/nicoleahmed/linkedom which does not exist.
| Parse EPUB metadata, spine, navigation, and section content without a browser. Requires [`linkedom`](https://github.com/nicoleahmed/linkedom) as a peer dependency. | |
| Parse EPUB metadata, spine, navigation, and section content without a browser. Requires [`linkedom`](https://github.com/WebReflection/linkedom) as a peer dependency. |
| if (typeof globalThis.DOMParser === "undefined") { | ||
| globalThis.DOMParser = LinkedomDOMParser as unknown as typeof globalThis.DOMParser; | ||
| } | ||
|
|
||
| if (typeof globalThis.XMLSerializer === "undefined") { | ||
| globalThis.XMLSerializer = class XMLSerializer { | ||
| serializeToString(node: Node): string { | ||
| return (node as unknown as { toString(): string }).toString(); | ||
| } | ||
| } as unknown as typeof globalThis.XMLSerializer; | ||
| } | ||
|
|
||
| if (typeof globalThis.document === "undefined") { | ||
| const { document } = parseHTML("<!DOCTYPE html><html><head></head><body></body></html>"); | ||
| (globalThis as typeof globalThis & { document: Document }).document = document as Document; | ||
| } |
There was a problem hiding this comment.
The src/node.ts module modifies globalThis (adding DOMParser, XMLSerializer, and document), which are side effects. However, package.json declares "sideEffects": false, which tells bundlers that no modules have side effects.
In practice, this works because:
- Users must import from this module to use it
- The globalThis modifications run at module initialization
- The package builds to single-file bundles
However, this is semantically inaccurate. For correctness, consider updating package.json to "sideEffects": ["dist/epub.node.js", "dist/epub.node.cjs"] to explicitly mark the node bundles as having side effects. This would prevent potential edge cases where advanced tree-shaking might skip the module initialization.
|
|
||
| console.log(book.packaging.metadata.title); | ||
| console.log(book.navigation.toc.map(item => item.label)); | ||
|
|
There was a problem hiding this comment.
The README example assumes book.archive exists, but if the book failed to open or if there's an issue, book.archive could be undefined. Consider adding a null check or wrapping in a try-catch for better error handling in the documentation example.
For example:
if (!book.archive) {
throw new Error("Failed to open EPUB archive");
}
const section = book.spine.first();
const html = await section.render(book.archive.request.bind(book.archive));This would make the example more robust and help users understand potential failure modes.
| if (!book.archive) { | |
| throw new Error("Failed to open EPUB archive"); | |
| } |
Add
@likecoin/epub-ts/nodesubpath export that shims DOMParser, XMLSerializer, and document via linkedom, enabling EPUB parsing (metadata, spine, navigation, section rendering) in Node.js without a browser.