fix(import): isolate bad chapters and bound nesting depth

A deeply nested document could overflow the stack and abort the entire
import. Bound block recursion depth and wrap each chapter build in a
try/catch so one unreadable chapter is skipped instead of failing the
whole import.

Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk
This commit is contained in:
pj committed 2026-06-22 13:40:24 -04:00
1 parent b94b51140a
commit 457209f934
1 file changed
+17 -12
+17 -12
View File
@@ -184,12 +184,12 @@ function hasBlockChild(el: Element): boolean {
return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase())); return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase()));
} }
function listItems(el: Element): JSONContent[] { function listItems(el: Element, depth: number): JSONContent[] {
const items: JSONContent[] = []; const items: JSONContent[] = [];
Array.from(el.children) Array.from(el.children)
.filter((c) => c.localName.toLowerCase() === "li") .filter((c) => c.localName.toLowerCase() === "li")
.forEach((li) => { .forEach((li) => {
const content = blocksFrom(li); const content = blocksFrom(li, depth + 1);
items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] }); items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] });
}); });
return items; return items;
@@ -212,7 +212,7 @@ function tableBlocks(el: Element): JSONContent[] {
return out; return out;
} }
function blockFromElement(el: Element): JSONContent[] { function blockFromElement(el: Element, depth: number): JSONContent[] {
const tag = el.localName.toLowerCase(); const tag = el.localName.toLowerCase();
switch (tag) { switch (tag) {
case "p": case "p":
@@ -228,15 +228,15 @@ function blockFromElement(el: Element): JSONContent[] {
return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : []; return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : [];
} }
case "blockquote": { case "blockquote": {
const inner = blocksFrom(el); const inner = blocksFrom(el, depth + 1);
return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }]; return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }];
} }
case "ul": { case "ul": {
const items = listItems(el); const items = listItems(el, depth);
return items.length ? [{ type: "bulletList", content: items }] : []; return items.length ? [{ type: "bulletList", content: items }] : [];
} }
case "ol": { case "ol": {
const items = listItems(el); const items = listItems(el, depth);
return items.length ? [{ type: "orderedList", content: items }] : []; return items.length ? [{ type: "orderedList", content: items }] : [];
} }
case "hr": case "hr":
@@ -253,18 +253,19 @@ function blockFromElement(el: Element): JSONContent[] {
case "style": case "style":
return []; return [];
default: default:
return hasBlockChild(el) ? blocksFrom(el) : paragraphAndFigures(el); return hasBlockChild(el) ? blocksFrom(el, depth + 1) : paragraphAndFigures(el);
} }
} }
function blocksFrom(parent: Node): JSONContent[] { function blocksFrom(parent: Node, depth = 0): JSONContent[] {
if (depth > 64) return [];
const out: JSONContent[] = []; const out: JSONContent[] = [];
parent.childNodes.forEach((child) => { parent.childNodes.forEach((child) => {
if (child.nodeType === 3) { if (child.nodeType === 3) {
const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim(); const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim();
if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] }); if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] });
} else if (child.nodeType === 1) { } else if (child.nodeType === 1) {
out.push(...blockFromElement(child as Element)); out.push(...blockFromElement(child as Element, depth));
} }
}); });
return out; return out;
@@ -491,9 +492,13 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
spine.forEach((item, i) => { spine.forEach((item, i) => {
const file = byPath.get(normalize(item.href)); const file = byPath.get(normalize(item.href));
if (!file) return; if (!file) return;
chapters.push( try {
buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest), chapters.push(
); buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest),
);
} catch {
return;
}
}); });
if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found."); if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found.");