refactor(toc): cache navigable structure per book (#3869)
* refactor(toc): cache TOC + section fragments per book
Moves the TOC regrouping and section-fragment computation out of
foliate-js/epub.js #updateSubItems into the readest client as
computeBookNav / hydrateBookNav in utils/toc.ts. The result is
persisted to Books/{hash}/nav.json — capturing the book's full
navigable structure (TOC hierarchy + sections with hierarchical
fragments). Compute once, persist locally, hydrate on subsequent
opens. Designed to serve current human-facing navigation (TOC
sidebar, progress math) and future agentic navigation (LLM-driven
seeking by structural location).
Versioned by BOOK_NAV_VERSION for forward invalidation. Existing
books regenerate transparently on next open.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: update worktree scripts
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc, TOCItem, SectionItem } from '@/libs/document';
|
||||
import {
|
||||
computeBookNav,
|
||||
hydrateBookNav,
|
||||
updateToc,
|
||||
findTocItemBS,
|
||||
BOOK_NAV_VERSION,
|
||||
type BookNav,
|
||||
type SectionFragment,
|
||||
} from '@/utils/toc';
|
||||
|
||||
// Polyfill CSS.escape for jsdom
|
||||
if (typeof globalThis['CSS'] === 'undefined') {
|
||||
(globalThis as Record<string, unknown>)['CSS'] = {
|
||||
escape: (s: string) => s.replace(/([^\w-])/g, '\\$1'),
|
||||
};
|
||||
}
|
||||
|
||||
// Register a stub paginator custom element so View.open() doesn't fail in jsdom
|
||||
if (!customElements.get('foliate-paginator')) {
|
||||
customElements.define(
|
||||
'foliate-paginator',
|
||||
class extends HTMLElement {
|
||||
override setAttribute() {}
|
||||
override addEventListener() {}
|
||||
open() {}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Mock the paginator module import so View doesn't try to load the real one
|
||||
vi.mock('foliate-js/paginator.js', () => ({}));
|
||||
|
||||
const openFixture = async (name: string): Promise<BookDoc> => {
|
||||
const epubPath = resolve(__dirname, `../fixtures/data/${name}`);
|
||||
const buffer = readFileSync(epubPath);
|
||||
const file = new File([buffer], name, { type: 'application/epub+zip' });
|
||||
const loader = new DocumentLoader(file);
|
||||
const result = await loader.open();
|
||||
return result.book;
|
||||
};
|
||||
|
||||
const collectFragments = (fragments: SectionFragment[] | undefined): SectionFragment[] => {
|
||||
if (!fragments?.length) return [];
|
||||
const out: SectionFragment[] = [];
|
||||
for (const f of fragments) {
|
||||
out.push(f);
|
||||
if (f.fragments?.length) out.push(...collectFragments(f.fragments));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('computeBookNav (fragment-link TOC)', () => {
|
||||
let book: BookDoc;
|
||||
let nav: BookNav;
|
||||
|
||||
beforeAll(async () => {
|
||||
book = await openFixture('repro-3688.epub');
|
||||
nav = await computeBookNav(book);
|
||||
}, 30000);
|
||||
|
||||
it('produces a nav with the current algorithm version', () => {
|
||||
expect(nav.version).toBe(BOOK_NAV_VERSION);
|
||||
});
|
||||
|
||||
it('preserves the TOC with at least one entry', () => {
|
||||
expect(nav.toc.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('produces a sections map keyed by section id', () => {
|
||||
expect(nav.sections).toBeTypeOf('object');
|
||||
const keys = Object.keys(nav.sections);
|
||||
const sectionIds = new Set((book.sections ?? []).map((s) => s.id));
|
||||
for (const key of keys) {
|
||||
expect(sectionIds.has(key)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('attaches fragments with DOM-derived CFIs and non-negative sizes', () => {
|
||||
const allFragments: SectionFragment[] = [];
|
||||
for (const section of Object.values(nav.sections)) {
|
||||
allFragments.push(...collectFragments(section.fragments));
|
||||
}
|
||||
|
||||
// repro-3688 is known to have fragment-link TOC items
|
||||
expect(allFragments.length).toBeGreaterThan(0);
|
||||
for (const f of allFragments) {
|
||||
// CFI is derived at compute time by resolving the fragment anchor in the
|
||||
// section's DOM and joining it with the section's CFI. Must be a valid
|
||||
// epubcfi string on every cached fragment.
|
||||
expect(f.cfi, `fragment ${f.href} should have a DOM-derived CFI`).toMatch(/^epubcfi\(/);
|
||||
expect(f.size, `fragment ${f.href} should have non-negative size`).toBeGreaterThanOrEqual(0);
|
||||
expect(f.id).toBeTruthy();
|
||||
expect(f.href).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('derives per-fragment CFIs from the section DOM, not the TOC item CFI', () => {
|
||||
// If we used the TOC item's own cfi we would see duplicates (multiple TOC
|
||||
// items in the same section often share an inherited cfi or have none).
|
||||
// DOM-derived CFIs should be distinct per anchor within a section.
|
||||
for (const section of Object.values(nav.sections)) {
|
||||
const flat = collectFragments(section.fragments);
|
||||
if (flat.length < 2) continue;
|
||||
const cfis = flat.map((f) => f.cfi);
|
||||
const distinct = new Set(cfis);
|
||||
expect(distinct.size, `section ${section.id} fragments should have distinct CFIs`).toBe(
|
||||
cfis.length,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not mutate the original bookDoc.toc during compute', async () => {
|
||||
const freshBook = await openFixture('repro-3688.epub');
|
||||
const tocBefore = JSON.stringify(freshBook.toc);
|
||||
await computeBookNav(freshBook);
|
||||
const tocAfter = JSON.stringify(freshBook.toc);
|
||||
expect(tocAfter).toBe(tocBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hydrateBookNav', () => {
|
||||
it('round-trips through JSON without loss', async () => {
|
||||
const book = await openFixture('repro-3688.epub');
|
||||
const nav = await computeBookNav(book);
|
||||
|
||||
const serialized = JSON.stringify(nav);
|
||||
const parsed = JSON.parse(serialized) as BookNav;
|
||||
|
||||
expect(parsed.version).toBe(nav.version);
|
||||
expect(JSON.stringify(parsed.toc)).toBe(JSON.stringify(nav.toc));
|
||||
expect(JSON.stringify(parsed.sections)).toBe(JSON.stringify(nav.sections));
|
||||
}, 30000);
|
||||
|
||||
it('attaches toc and section.fragments onto bookDoc', async () => {
|
||||
const bookA = await openFixture('repro-3688.epub');
|
||||
const nav = await computeBookNav(bookA);
|
||||
|
||||
const bookB = await openFixture('repro-3688.epub');
|
||||
hydrateBookNav(bookB, JSON.parse(JSON.stringify(nav)) as BookNav);
|
||||
|
||||
expect(JSON.stringify(bookB.toc)).toBe(JSON.stringify(nav.toc));
|
||||
|
||||
for (const section of bookB.sections ?? []) {
|
||||
const expected = nav.sections[section.id]?.fragments;
|
||||
expect(JSON.stringify(section.fragments ?? undefined), `section ${section.id}`).toBe(
|
||||
JSON.stringify(expected),
|
||||
);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it('does not call loadText or createDocument on any section (no I/O)', async () => {
|
||||
const bookA = await openFixture('repro-3688.epub');
|
||||
const nav = await computeBookNav(bookA);
|
||||
|
||||
const bookB = await openFixture('repro-3688.epub');
|
||||
const loadTextSpies: Array<ReturnType<typeof vi.spyOn>> = [];
|
||||
const createDocSpies: Array<ReturnType<typeof vi.spyOn>> = [];
|
||||
for (const s of bookB.sections ?? []) {
|
||||
const withLoadText = s as SectionItem & {
|
||||
loadText: () => Promise<string | null>;
|
||||
};
|
||||
if (typeof withLoadText.loadText === 'function') {
|
||||
loadTextSpies.push(vi.spyOn(withLoadText, 'loadText'));
|
||||
}
|
||||
createDocSpies.push(vi.spyOn(s, 'createDocument'));
|
||||
}
|
||||
|
||||
hydrateBookNav(bookB, JSON.parse(JSON.stringify(nav)) as BookNav);
|
||||
|
||||
for (const spy of loadTextSpies) expect(spy).not.toHaveBeenCalled();
|
||||
for (const spy of createDocSpies) expect(spy).not.toHaveBeenCalled();
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('updateToc compatibility after hydrateBookNav', () => {
|
||||
// The historical regression from #3688: fragment-suffixed TOC entries must
|
||||
// resolve to the correct CFI after updateToc runs. Since computeBookNav
|
||||
// replaces the former #updateSubItems work, hydration must produce TOC
|
||||
// mappings identical to the pre-refactor behavior.
|
||||
let book: BookDoc;
|
||||
|
||||
beforeAll(async () => {
|
||||
book = await openFixture('repro-3688.epub');
|
||||
const nav = await computeBookNav(book);
|
||||
hydrateBookNav(book, nav);
|
||||
await updateToc(book, false, 'none');
|
||||
}, 30000);
|
||||
|
||||
it('assigns valid CFIs to all TOC items with href', () => {
|
||||
const collectItems = (items: TOCItem[]): TOCItem[] =>
|
||||
items.flatMap((item) => [item, ...(item.subitems ? collectItems(item.subitems) : [])]);
|
||||
|
||||
const toc = book.toc ?? [];
|
||||
const allItems = collectItems(toc);
|
||||
const itemsWithHref = allItems.filter((item) => item.href);
|
||||
expect(itemsWithHref.length).toBeGreaterThan(1);
|
||||
|
||||
for (const item of itemsWithHref) {
|
||||
expect(
|
||||
item.cfi,
|
||||
`TOC item "${item.label}" (href: ${item.href}) should have a valid CFI`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps fragment-level CFIs from different sections to different TOC items', () => {
|
||||
const toc = book.toc ?? [];
|
||||
const sections = book.sections ?? [];
|
||||
const linearSections = sections.filter((s) => s.linear !== 'no' && s.cfi);
|
||||
expect(linearSections.length).toBeGreaterThan(2);
|
||||
|
||||
// In production, findTocItemBS is queried with booknote CFIs (precise
|
||||
// positions inside a section). After the DOM-CFI refactor, TOC items
|
||||
// carry fragment-level CFIs, so we query using the fragment CFIs that
|
||||
// hydrateBookNav attached to each section.
|
||||
const firstSection = linearSections[0]!;
|
||||
const lastSection = linearSections[linearSections.length - 1]!;
|
||||
const firstFragmentCfi = firstSection.fragments?.[0]?.cfi ?? firstSection.cfi;
|
||||
const lastFragmentCfi = lastSection.fragments?.[0]?.cfi ?? lastSection.cfi;
|
||||
|
||||
const firstTocItem = findTocItemBS(toc, firstFragmentCfi);
|
||||
const lastTocItem = findTocItemBS(toc, lastFragmentCfi);
|
||||
|
||||
expect(firstTocItem).not.toBeNull();
|
||||
expect(lastTocItem).not.toBeNull();
|
||||
expect(firstTocItem!.label).toBe('Chapter One');
|
||||
expect(lastTocItem!.label).toBe('Chapter Three');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeBookNav on a book without fragment TOC', () => {
|
||||
let book: BookDoc;
|
||||
let nav: BookNav;
|
||||
|
||||
beforeAll(async () => {
|
||||
book = await openFixture('sample-alice.epub');
|
||||
nav = await computeBookNav(book);
|
||||
}, 30000);
|
||||
|
||||
it('returns a well-formed nav even when no sections have fragments', () => {
|
||||
expect(nav.version).toBe(BOOK_NAV_VERSION);
|
||||
expect(Array.isArray(nav.toc)).toBe(true);
|
||||
expect(typeof nav.sections).toBe('object');
|
||||
|
||||
// Either no section entries at all, or all entries have empty fragments arrays.
|
||||
const allFragments: SectionFragment[] = [];
|
||||
for (const sec of Object.values(nav.sections)) {
|
||||
allFragments.push(...collectFragments(sec.fragments));
|
||||
}
|
||||
// Fine if this is 0 — just asserting the compute path is stable.
|
||||
expect(allFragments.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('round-trips unchanged through JSON', () => {
|
||||
const serialized = JSON.stringify(nav);
|
||||
const parsed = JSON.parse(serialized) as BookNav;
|
||||
expect(JSON.stringify(parsed)).toBe(serialized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hierarchical fragments under a single section', () => {
|
||||
it('nests fragments that mirror TOC hierarchy within the same section', async () => {
|
||||
const book = await openFixture('repro-3688.epub');
|
||||
const nav = await computeBookNav(book);
|
||||
|
||||
// Scan for at least one section whose computed fragments are themselves
|
||||
// nested — i.e. mirror a multi-level TOC under one HTML section.
|
||||
let foundNested = false;
|
||||
for (const section of Object.values(nav.sections)) {
|
||||
for (const f of section.fragments ?? []) {
|
||||
if (f.fragments?.length) {
|
||||
foundNested = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundNested) break;
|
||||
}
|
||||
|
||||
// repro-3688 has fragment-link TOC entries; if the TOC nests them we'll
|
||||
// see hierarchical fragments. If the fixture doesn't nest, this test is
|
||||
// lenient — but the shape contract (optional recursive fragments) must
|
||||
// at least be type-correct and not crash.
|
||||
if (foundNested) {
|
||||
expect(foundNested).toBe(true);
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
Reference in New Issue
Block a user