import { SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SveltePublicationTree } from './svelte_publication_tree.svelte.ts'; import type { NDKEvent } from '../../utils/nostrUtils.ts'; import { indexKind } from '../../consts.ts'; export interface TocEntry { address: string; title: string; href?: string; children: TocEntry[]; parent?: TocEntry; depth: number; childrenResolved: boolean; resolveChildren: () => Promise; } /** * Maintains a table of contents (ToC) for a `SveltePublicationTree`. Since publication trees are * conceptually infinite and lazy-loading, the ToC represents only the portion of the tree that has * been "discovered". The ToC is updated as new nodes are resolved within the publication tree. * * @see SveltePublicationTree */ export class TableOfContents { public addressMap: SvelteMap = new SvelteMap(); public expandedMap: SvelteMap = new SvelteMap(); public leaves: SvelteSet = new SvelteSet(); #root: TocEntry | null = null; #publicationTree: SveltePublicationTree; #pagePathname: string; /** * Constructs a `TableOfContents` from a `SveltePublicationTree`. * * @param rootAddress The address of the root event. * @param publicationTree The SveltePublicationTree instance. * @param pagePathname The current page pathname for href generation. */ constructor(rootAddress: string, publicationTree: SveltePublicationTree, pagePathname: string) { this.#publicationTree = publicationTree; this.#pagePathname = pagePathname; this.#init(rootAddress); } // #region Public Methods /** * Returns the root entry of the ToC. * * @returns The root entry of the ToC, or `null` if the ToC has not been initialized. */ getRootEntry(): TocEntry | null { return this.#root; } getEntry(address: string): TocEntry | undefined { return this.addressMap.get(address); } /** * Builds a table of contents from the DOM subtree rooted at `parentElement`. * * @param parentElement The root of the DOM subtree containing the content to be added to the * ToC. * @param parentAddress The address of the event corresponding to the DOM subtree root indicated * by `parentElement`. * * This function is intended for use on segments of HTML markup that are not directly derived * from a structure publication of the kind supported by `PublicationTree`. It may be used to * produce a table of contents from the contents of a kind `30041` event with AsciiDoc markup, or * from a kind `30023` event with Markdown content. */ buildTocFromDocument( parentElement: HTMLElement, parentEntry: TocEntry, depth: number = 1 ) { parentElement .querySelectorAll(`h${depth}`) .forEach((header) => { const title = header.textContent?.trim(); const id = header.id; // Only create an entry if the header has an ID and a title. if (id && title) { const href = `${this.#pagePathname}#${id}`; // TODO: Check this logic. const tocEntry: TocEntry = { address: parentEntry.address, title, href, depth, children: [], childrenResolved: true, resolveChildren: () => Promise.resolve(), }; parentEntry.children.push(tocEntry); this.expandedMap.set(tocEntry.address, false); this.buildTocFromDocument(header, tocEntry, depth + 1); } }); } // #endregion // #region Iterator Methods /** * Iterates over all ToC entries in depth-first order. */ *[Symbol.iterator](): IterableIterator { function* traverse(entry: TocEntry | null): IterableIterator { if (!entry) { return; } yield entry; if (entry.children) { for (const child of entry.children) { yield* traverse(child); } } } yield* traverse(this.#root); } // #endregion // #region Private Methods async #init(rootAddress: string) { const rootEvent = await this.#publicationTree.getEvent(rootAddress); if (!rootEvent) { throw new Error(`[ToC] Root event ${rootAddress} not found.`); } this.#root = await this.#buildTocEntry(rootAddress); this.addressMap.set(rootAddress, this.#root); // TODO: Parallelize this. // Handle any other nodes that have already been resolved. this.#publicationTree.resolvedAddresses.forEach((address) => { this.#buildTocEntryFromResolvedNode(address); }); // Set up an observer to handle progressive resolution of the publication tree. this.#publicationTree.onNodeResolved((address: string) => { this.#buildTocEntryFromResolvedNode(address); }); } #getTitle(event: NDKEvent | null): string { if (!event) { // TODO: What do we want to return in this case? return '[untitled]'; } const titleTag = event.getMatchingTags?.('title')?.[0]?.[1]; return titleTag || event.tagAddress() || '[untitled]'; } async #buildTocEntry(address: string): Promise { const resolver = async () => { if (entry.childrenResolved) { return; } const event = await this.#publicationTree.getEvent(entry.address); if (event?.kind !== indexKind) { // TODO: Build ToC entries from HTML markup in this case. return; } const childAddresses = await this.#publicationTree.getChildAddresses(entry.address); for (const childAddress of childAddresses) { if (!childAddress) { continue; } // Michael J - 16 June 2025 - This duplicates logic in the outer function, but is necessary // here so that we can determine whether to render an entry as a leaf before it is fully // resolved. if (childAddress.split(':')[0] !== indexKind.toString()) { this.leaves.add(childAddress); } // Michael J - 05 June 2025 - The `getChildAddresses` method forces node resolution on the // publication tree. This is acceptable here, because the tree is always resolved // top-down. Therefore, by the time we handle a node's resolution, its parent and // siblings have already been resolved. const childEntry = await this.#buildTocEntry(childAddress); childEntry.parent = entry; childEntry.depth = entry.depth + 1; entry.children.push(childEntry); this.addressMap.set(childAddress, childEntry); } await this.#matchChildrenToTagOrder(entry); entry.childrenResolved = true; } const event = await this.#publicationTree.getEvent(address); if (!event) { throw new Error(`[ToC] Event ${address} not found.`); } const depth = (await this.#publicationTree.getHierarchy(address)).length; const entry: TocEntry = { address, title: this.#getTitle(event), href: `${this.#pagePathname}#${address}`, children: [], depth, childrenResolved: false, resolveChildren: resolver, }; this.expandedMap.set(address, false); // Michael J - 16 June 2025 - We determine whether to add a leaf both here and in the inner // resolver function. The resolver function is called when entries are resolved by expanding // a ToC entry, and we'll reach the block below when entries are resolved by the publication // tree. if (event.kind !== indexKind) { this.leaves.add(address); } return entry; } /** * Reorders the children of a ToC entry to match the order of 'a' tags in the corresponding * Nostr index event. * * @param entry The ToC entry to reorder. * * This function has a time complexity of `O(n log n)`, where `n` is the number of children the * parent event has. Average size of `n` is small enough to be negligible. */ async #matchChildrenToTagOrder(entry: TocEntry) { const parentEvent = await this.#publicationTree.getEvent(entry.address); if (parentEvent?.kind === indexKind) { const tagOrder = parentEvent.getMatchingTags('a').map(tag => tag[1]); const addressToOrdinal = new Map(); // Build map of addresses to their ordinals from tag order tagOrder.forEach((address, index) => { addressToOrdinal.set(address, index); }); entry.children.sort((a, b) => { const aOrdinal = addressToOrdinal.get(a.address) ?? Number.MAX_SAFE_INTEGER; const bOrdinal = addressToOrdinal.get(b.address) ?? Number.MAX_SAFE_INTEGER; return aOrdinal - bOrdinal; }); } } #buildTocEntryFromResolvedNode(address: string) { if (this.addressMap.has(address)) { return; } this.#buildTocEntry(address).then((entry) => { this.addressMap.set(address, entry); }); } // #endregion }