// deno-lint-ignore-file no-this-alias import NDK, { NDKEvent } from "@nostr-dev-kit/ndk"; import Processor from "asciidoctor"; import type { AbstractBlock, AbstractNode, Block, Document, Extensions, ProcessorOptions, Section, } from "asciidoctor"; import he from "he"; import { type Writable, writable } from "svelte/store"; import { zettelKinds } from "./consts.ts"; import { getMatchingTags } from "./utils/nostrUtils.ts"; interface IndexMetadata { authors?: string[]; version?: string; edition?: string; isbn?: string; publicationDate?: string; publisher?: string; summary?: string; coverImage?: string; } export enum SiblingSearchDirection { Previous, Next, } export enum InsertLocation { Before, After, } /** * @classdesc Pharos is an extension of the Asciidoctor class that adds Nostr Knowledge Base (NKB) * features to core Asciidoctor functionality. Asciidoctor is used to parse an AsciiDoc document * into an Abstract Syntax Tree (AST), and Phraos generates NKB events from the nodes in that tree. * @class * @augments Asciidoctor */ export default class Pharos { /** * Key to terminology used in the class: * * Nostr Knowledge Base (NKB) entities: * - Zettel: Bite-sized pieces of text contained within kind 30041 events. * - Index: A kind 30040 event describing a collection of zettels or other Nostr events. * - Event: The generic term for a Nostr event. * * Asciidoctor entities: * - Document: The entirety of an AsciiDoc document. The document title is denoted by a level 0 * header, and the document may contain metadata, such as author and edition, immediately below * the title. * - Section: A section of an AsciiDoc document demarcated by a header. A section may contain * blocks and/or other sections. * - Block: A block of content within an AsciiDoc document. Blocks are demarcated on either side * by newline characters. Blocks may contain other blocks or inline content. Blocks may be * images, paragraphs, sections, a document, or other types of content. * - Node: A unit of the parsed AsciiDoc document. All blocks are nodes. Nodes are related * hierarchically to form the Abstract Syntax Tree (AST) representation of the document. */ private asciidoctor; private pharosExtensions: Extensions.Registry; private ndk: NDK; private contextCounters: Map = new Map(); /** * The HTML content of the converted document. */ private html?: string | Document; /** * The ID of the root node in the document. */ private rootNodeId?: string; /** * Metadata to be used to populate the tags on the root index event. */ private rootIndexMetadata: IndexMetadata = {}; /** * A map of node IDs to the nodes themselves. */ private nodes: Map = new Map(); /** * A map of event d tags to the events themselves. */ private events: Map = new Map(); /** * A map of event d tags to the context name assigned to each event's originating node by the * Asciidoctor parser. */ private eventToContextMap: Map = new Map(); /** * A map of node IDs to the integer event kind that will be used to represent the node. */ private eventToKindMap: Map = new Map(); /** * A map of index IDs to the IDs of the nodes they reference. */ private indexToChildEventsMap: Map> = new Map< string, Set >(); /** * A map of node IDs to the Nostr event IDs of the events they generate. */ private eventIds: Map = new Map(); /** * A map of the levels of the event tree to a list of event IDs at each level. */ private eventsByLevelMap: Map = new Map(); /** * A map of blog entries */ private blogEntries: Map = new Map(); /** * When `true`, `getEvents()` should regenerate the event tree to propagate updates. */ private shouldUpdateEventTree: boolean = false; // #region Public API constructor(ndk: NDK) { this.asciidoctor = Processor(); this.pharosExtensions = this.asciidoctor.Extensions.create(); this.ndk = ndk; const pharos = this; this.pharosExtensions.treeProcessor(function () { const dsl = this; dsl.process(function (document) { const treeProcessor = this; pharos.treeProcessor(treeProcessor, document); }); }); // Add advanced extensions for math, PlantUML, BPMN, and TikZ this.loadAdvancedExtensions(); } /** * Loads advanced extensions for math, PlantUML, BPMN, and TikZ rendering */ private async loadAdvancedExtensions(): Promise { try { const { createAdvancedExtensions } = await import( "./utils/markup/asciidoctorExtensions.ts" ); createAdvancedExtensions(); // Note: Extensions merging might not be available in this version // We'll handle this in the parse method instead } catch (error) { console.warn("Advanced extensions not available:", error); } } parse(content: string, options?: ProcessorOptions | undefined): void { // Ensure the content is valid AsciiDoc and has a header and the doctype book content = ensureAsciiDocHeader(content); try { const mergedAttributes = Object.assign( {}, options && typeof options.attributes === "object" ? options.attributes : {}, { "source-highlighter": "highlightjs" }, ); this.html = this.asciidoctor.convert(content, { ...options, extension_registry: this.pharosExtensions, attributes: mergedAttributes, }) as string | Document | undefined; } catch (error) { console.error(error); throw new Error("Failed to parse AsciiDoc document."); } } /** * Fetches and parses the event tree for a publication given the event or event ID of the * publication's root index. * @param event The event or event ID of the publication's root index. */ async fetch(event: NDKEvent | string): Promise { let content: string; if (typeof event === "string") { const index = await this.ndk.fetchEvent({ ids: [event] }); if (!index) { throw new Error("Failed to fetch publication."); } content = await this.getPublicationContent(index); } else { content = await this.getPublicationContent(event); } this.parse(content); } getBlogEntries() { return this.blogEntries; } getIndexMetadata(): IndexMetadata { return this.rootIndexMetadata; } /** * Generates and stores Nostr events from the parsed AsciiDoc document. The events can be * modified via the parser's API and retrieved via the `getEvents()` method. * @param pubkey The public key (as a hex string) of the user that will sign and publish the * events. */ generate(pubkey: string): void { const stack = this.stackEventNodes(); this.generateEvents(stack, pubkey); } /** * @param pubkey The public key (as a hex string) of the user generating the events. * @returns An array of Nostr events generated from the parsed AsciiDoc document. * @remarks This method returns the events as they are currently stored in the parser. If none * are stored, they will be freshly generated. */ getEvents(pubkey: string): NDKEvent[] { if (this.shouldUpdateEventTree) { const stack = this.stackEventNodes(); return this.generateEvents(stack, pubkey); } return Array.from(this.events.values()); } /** * Gets the entire HTML content of the AsciiDoc document. * @returns The HTML content of the converted document. */ getHtml(): string { return this.html?.toString() || ""; } /** * @returns The ID of the root index of the converted document. * @remarks The root index ID may be used to retrieve metadata or children from the root index. */ getRootIndexId(): string { return this.normalizeId(this.rootNodeId) ?? ""; } /** * @returns The title, if available, from the metadata of the index with the given ID. */ getIndexTitle(id: string): string | undefined { const section = this.nodes.get(id) as Section; const title = section.getTitle() ?? ""; return he.decode(title); } /** * @returns The IDs of any child indices of the index with the given ID. */ getChildIndexIds(id: string): string[] { return Array.from(this.indexToChildEventsMap.get(id) ?? []).filter( (id) => this.eventToKindMap.get(id) === 30040, ); } /** * @returns The IDs of any child zettels of the index with the given ID. */ getChildZettelIds(id: string): string[] { return Array.from(this.indexToChildEventsMap.get(id) ?? []).filter( (id) => this.eventToKindMap.get(id) !== 30040, ); } /** * @returns The IDs of any child nodes in the order in which they should be rendered. */ getOrderedChildIds(id: string): string[] { return Array.from(this.indexToChildEventsMap.get(id) ?? []); } /** * @returns The content of the node with the given ID. The presentation of the returned content * varies by the node's context. * @remarks By default, the content is returned as HTML produced by the * Asciidoctor converter. However, other formats are returned for specific contexts: * - Paragraph: The content is returned as a plain string. */ getContent(id: string): string { const normalizedId = this.normalizeId(id); const block = this.nodes.get(normalizedId!) as AbstractBlock; switch (block.getContext()) { case "paragraph": return block.getContent() ?? ""; } return block.convert(); } /** * Checks if the node with the given ID is a floating title (discrete header). * @param id The ID of the node to check. * @returns True if the node is a floating title, false otherwise. */ isFloatingTitle(id: string): boolean { const normalizedId = this.normalizeId(id); if (!normalizedId || !this.nodes.has(normalizedId)) { return false; } const context = this.eventToContextMap.get(normalizedId); return context === "floating_title"; } /** * Updates the `content` field of a Nostr event in-place. * @param dTag The d tag of the event to update. * @param content The new content to assign to the event. * @returns The updated event. * @remarks Changing the content of a Nostr event changes its hash, but regenerating the event * tree is expensive. Thus, the event tree will not be regenerated until the consumer next * invokes `getEvents()`. */ updateEventContent(dTag: string, content: string): NDKEvent { const event = this.events.get(dTag); if (!event) { throw new Error(`No event found for #d:${dTag}.`); } this.updateEventByContext(dTag, content, this.eventToContextMap.get(dTag)!); return event; } /** * Finds the nearest sibling of the event with the given d tag. * @param targetDTag The d tag of the target event. * @param parentDTag The d tag of the target event's parent. * @param depth The depth of the target event within the parser tree. * @param direction The direction in which to search for a sibling. * @returns A tuple containing the d tag of the nearest sibling and the d tag of the nearest * sibling's parent. */ getNearestSibling( targetDTag: string, depth: number, direction: SiblingSearchDirection, ): [string | null, string | null] { const eventsAtLevel = this.eventsByLevelMap.get(depth); if (!eventsAtLevel) { throw new Error(`No events found at level ${depth}.`); } const targetIndex = eventsAtLevel.indexOf(targetDTag); if (targetIndex === -1) { throw new Error( `The event indicated by #d:${targetDTag} does not exist at level ${depth} of the event tree.`, ); } const parentDTag = this.getParent(targetDTag); if (!parentDTag) { throw new Error( `The event indicated by #d:${targetDTag} does not have a parent.`, ); } const grandparentDTag = this.getParent(parentDTag); // If the target is the first node at its level and we're searching for a previous sibling, // look among the siblings of the target's parent at the previous level. if (targetIndex === 0 && direction === SiblingSearchDirection.Previous) { // * Base case: The target is at the first level of the tree and has no previous sibling. if (!grandparentDTag) { return [null, null]; } return this.getNearestSibling(parentDTag, depth - 1, direction); } // If the target is the last node at its level and we're searching for a next sibling, // look among the siblings of the target's parent at the previous level. if ( targetIndex === eventsAtLevel.length - 1 && direction === SiblingSearchDirection.Next ) { // * Base case: The target is at the last level of the tree and has no subsequent sibling. if (!grandparentDTag) { return [null, null]; } return this.getNearestSibling(parentDTag, depth - 1, direction); } // * Base case: There is an adjacent sibling at the same depth as the target. switch (direction) { case SiblingSearchDirection.Previous: return [eventsAtLevel[targetIndex - 1], parentDTag]; case SiblingSearchDirection.Next: return [eventsAtLevel[targetIndex + 1], parentDTag]; } return [null, null]; } /** * Gets the d tag of the parent of the event with the given d tag. * @param dTag The d tag of the target event. * @returns The d tag of the parent event, or null if the target event does not have a parent. * @throws An error if the target event does not exist in the parser tree. */ getParent(dTag: string): string | null { // Check if the event exists in the parser tree. if (!this.eventIds.has(dTag)) { throw new Error( `The event indicated by #d:${dTag} does not exist in the parser tree.`, ); } // Iterate through all the index to child mappings. // This may be expensive on large trees. for (const [indexId, childIds] of this.indexToChildEventsMap) { // If this parent contains our target as a child, we found the parent if (childIds.has(dTag)) { return indexId; } } return null; } /** * Moves an event within the event tree. * @param targetDTag The d tag of the event to be moved. * @param destinationDTag The d tag another event, next to which the target will be placed. * @param insertAfter If true, the target will be placed after the destination event, otherwise, * it will be placed before the destination event. * @throws Throws an error if the parameters specify an invalid move. * @remarks Moving the target event within the tree changes the hash of several events, so the * event tree will be regenerated when the consumer next invokes `getEvents()`. */ moveEvent( targetDTag: string, destinationDTag: string, insertAfter: boolean = false, ): void { const targetEvent = this.events.get(targetDTag); const destinationEvent = this.events.get(destinationDTag); const targetParent = this.getParent(targetDTag); const destinationParent = this.getParent(destinationDTag); if (!targetEvent) { throw new Error(`No event found for #d:${targetDTag}.`); } if (!destinationEvent) { throw new Error(`No event found for #d:${destinationDTag}.`); } if (!targetParent) { throw new Error( `The event indicated by #d:${targetDTag} does not have a parent.`, ); } if (!destinationParent) { throw new Error( `The event indicated by #d:${destinationDTag} does not have a parent.`, ); } // Remove the target from among the children of its current parent. this.indexToChildEventsMap.get(targetParent)?.delete(targetDTag); // If necessary, remove the target event from among the children of its destination parent. this.indexToChildEventsMap.get(destinationParent)?.delete(targetDTag); // Get the index of the destination event among the children of its parent. const destinationIndex = Array.from( this.indexToChildEventsMap.get(destinationParent) ?? [], ).indexOf(destinationDTag); // Insert next to the index of the destination event, either before or after as specified by // the insertAfter flag. const destinationChildren = Array.from( this.indexToChildEventsMap.get(destinationParent) ?? [], ); insertAfter ? destinationChildren.splice(destinationIndex + 1, 0, targetDTag) : destinationChildren.splice(destinationIndex, 0, targetDTag); this.indexToChildEventsMap.set( destinationParent, new Set(destinationChildren), ); this.shouldUpdateEventTree = true; } /** * Resets the parser to its initial state, removing any parsed data. */ reset(): void { this.contextCounters.clear(); this.html = undefined; this.rootNodeId = undefined; this.rootIndexMetadata = {}; this.nodes.clear(); this.eventToKindMap.clear(); this.indexToChildEventsMap.clear(); this.eventsByLevelMap.clear(); this.eventIds.clear(); } // #endregion // #region Tree Processor Extensions /** * Walks the Asciidoctor Abstract Syntax Tree (AST) and performs the following mappings: * - Each node ID is mapped to the node itself. * - Each node ID is mapped to an integer event kind that will be used to represent the node. * - Each ID of a node containing children is mapped to the set of IDs of its children. */ private treeProcessor( _: Extensions.TreeProcessor, document: Document, ) { this.rootNodeId = this.generateNodeId(document); document.setId(this.rootNodeId); this.nodes.set(this.rootNodeId, document); this.eventToKindMap.set(this.rootNodeId, 30040); this.indexToChildEventsMap.set(this.rootNodeId, new Set()); /** FIFO queue (uses `Array.push()` and `Array.shift()`). */ const nodeQueue: AbstractNode[] = document.getBlocks(); while (nodeQueue.length > 0) { const block = nodeQueue.shift(); if (!block) { continue; } if (block.getContext() === "section") { const children = this.processSection(block as Section); nodeQueue.push(...children); } else { this.processBlock(block as Block); } } this.buildEventsByLevelMap(this.rootNodeId!, 0); } /** * Processes a section of the Asciidoctor AST. * @param section The section to process. * @returns An array of the section's child nodes. If there are no child nodes, returns an empty * array. * @remarks Sections are mapped as kind 30040 indexToChildEventsMap by default. */ private processSection(section: Section): AbstractNode[] { let sectionId = this.normalizeId(section.getId()); if (!sectionId) { sectionId = this.generateNodeId(section); } // Prevent duplicates. if (this.nodes.has(sectionId)) { return []; } this.nodes.set(sectionId, section); this.eventToKindMap.set(sectionId, 30040); // Sections are indexToChildEventsMap by default. this.indexToChildEventsMap.set(sectionId, new Set()); const parentId = this.normalizeId(section.getParent()?.getId()); if (!parentId) { return []; } // Add the section to its parent index. this.indexToChildEventsMap.get(parentId)?.add(sectionId); // Limit to 5 levels of section depth. if (section.getLevel() >= 5) { return []; } return section.getBlocks(); } /** * Processes a block of the Asciidoctor AST. * @param block The block to process. * @remarks Blocks are mapped as kind 30041 zettels by default. */ private processBlock(block: Block): void { // Obtain or generate a unique ID for the block. let blockId = this.normalizeId(block.getId()); if (!blockId) { blockId = this.generateNodeId(block); block.setId(blockId); } // Prevent duplicates. if (this.nodes.has(blockId)) { return; } this.nodes.set(blockId, block); this.eventToKindMap.set(blockId, 30041); // Blocks are zettels by default. const parentId = this.normalizeId(block.getParent()?.getId()); if (!parentId) { return; } // Add the block to its parent index. this.indexToChildEventsMap.get(parentId)?.add(blockId); } //#endregion // #region Event Tree Operations /** * Recursively walks the event tree and builds a map of the events at each level. * @param parentNodeId The ID of the parent node. * @param depth The depth of the parent node. */ private buildEventsByLevelMap(parentNodeId: string, depth: number): void { // If we're at the root level, clear the map so it can be freshly rebuilt. if (depth === 0) { this.eventsByLevelMap.clear(); } const children = this.indexToChildEventsMap.get(parentNodeId); if (!children) { return; } const eventsAtLevel = this.eventsByLevelMap.get(depth) ?? []; eventsAtLevel.push(...children); this.eventsByLevelMap.set(depth, eventsAtLevel); for (const child of children) { this.buildEventsByLevelMap(child, depth + 1); } } /** * Uses the NDK to crawl the event tree of a publication and return its content as a string. * @param event The root index event of the publication. * @returns The content of the publication as a string. * @remarks This function does a depth-first crawl of the event tree using the relays specified * on the NDK instance. */ private async getPublicationContent( event: NDKEvent, depth: number = 0, ): Promise { let content: string = ""; // Format title into AsciiDoc header. const title = getMatchingTags(event, "title")[0][1]; let titleLevel = ""; for (let i = 0; i <= depth; i++) { titleLevel += "="; } content += `${titleLevel} ${title}\n\n`; // TODO: Deprecate `e` tags in favor of `a` tags required by NIP-62. let tags = getMatchingTags(event, "a"); if (tags.length === 0) { tags = getMatchingTags(event, "e"); } // Base case: The event is a zettel. if (zettelKinds.includes(event.kind ?? -1)) { content += event.content; return content; } // Recursive case: The event is an index. const childEvents = await Promise.all( tags.map((tag) => this.ndk.fetchEventFromTag(tag, event)), ); // if a blog, save complete events for later if ( getMatchingTags(event, "type").length > 0 && getMatchingTags(event, "type")[0][1] === "blog" ) { childEvents.forEach((child) => { if (child) { this.blogEntries.set(getMatchingTags(child, "d")?.[0]?.[1], child); } }); } // populate metadata if (event.created_at) { this.rootIndexMetadata.publicationDate = new Date( event.created_at * 1000, ).toDateString(); } if (getMatchingTags(event, "image").length > 0) { this.rootIndexMetadata.coverImage = getMatchingTags(event, "image")[0][1]; } // Michael J - 15 December 2024 - This could be further parallelized by recursively fetching // children of index events before processing them for content. We won't make that change now, // as it would increase complexity, but if performance suffers, we can revisit this option. const childContentPromises: Promise[] = []; for (let i = 0; i < childEvents.length; i++) { const childEvent = childEvents[i]; if (!childEvent) { console.warn(`NDK could not find event ${tags[i][1]}.`); continue; } childContentPromises.push( this.getPublicationContent(childEvent, depth + 1), ); } const childContents = await Promise.all(childContentPromises); content += childContents.join("\n\n"); return content; } // #endregion // #region NDKEvent Generation /** * Generates a stack of node IDs such that processing them in LIFO order will generate any events * used by an index before generating that index itself. * @returns An array of node IDs in the order they should be processed to generate events. */ private stackEventNodes(): string[] { const tempNodeIdStack: string[] = [this.rootNodeId!]; const nodeIdStack: string[] = []; while (tempNodeIdStack.length > 0) { const parentId = tempNodeIdStack.pop()!; nodeIdStack.push(parentId); if (!this.indexToChildEventsMap.has(parentId)) { continue; } const childIds = Array.from(this.indexToChildEventsMap.get(parentId)!); tempNodeIdStack.push(...childIds); } return nodeIdStack; } /** * Generates Nostr events for each node in the given stack. * @param nodeIdStack An array of node IDs ordered such that processing them in LIFO order will * produce any child event before it is required by a parent index event. * @param pubkey The public key (as a hex string) of the user generating the events. * @returns An array of Nostr events. */ private generateEvents(nodeIdStack: string[], pubkey: string): NDKEvent[] { const events: NDKEvent[] = []; while (nodeIdStack.length > 0) { const nodeId = nodeIdStack.pop(); switch (this.eventToKindMap.get(nodeId!)) { case 30040: events.push(this.generateIndexEvent(nodeId!, pubkey)); break; case 30041: default: // Kind 30041 (zettel) is currently the default kind for contentful events. events.push(this.generateZettelEvent(nodeId!, pubkey)); break; } } this.shouldUpdateEventTree = false; return events; } /** * Generates a kind 30040 index event for the node with the given ID. * @param nodeId The ID of the AsciiDoc document node from which to generate an index event. The * node ID will be used as the event's unique d tag identifier. * @param pubkey The public key (not encoded in npub form) of the user generating the events. * @returns An unsigned NDKEvent with the requisite tags, including e tags pointing to each of its * children, and dated to the present moment. */ private generateIndexEvent(nodeId: string, pubkey: string): NDKEvent { const title = (this.nodes.get(nodeId)! as AbstractBlock).getTitle(); // TODO: Use a tags as per NIP-62. const childTags = Array.from(this.indexToChildEventsMap.get(nodeId)!).map( (id) => ["#e", this.eventIds.get(id)!], ); const event = new NDKEvent(this.ndk); event.kind = 30040; event.content = ""; event.tags = [["title", title!], ["#d", nodeId], ...childTags]; event.created_at = Date.now(); event.pubkey = pubkey; // Add optional metadata to the root index event. if (nodeId === this.rootNodeId) { const document = this.nodes.get(nodeId) as Document; // Store the metadata so it is available if we need it later. this.rootIndexMetadata = { authors: document .getAuthors() .map((author) => author.getName()) .filter((name): name is string => name != null), version: document.getRevisionNumber(), edition: document.getRevisionRemark(), publicationDate: document.getRevisionDate(), }; if (this.rootIndexMetadata.authors) { event.tags.push(["author", ...this.rootIndexMetadata.authors!]); } if (this.rootIndexMetadata.version || this.rootIndexMetadata.edition) { const versionTags: string[] = ["version"]; if (this.rootIndexMetadata.version) { versionTags.push(this.rootIndexMetadata.version); } if (this.rootIndexMetadata.edition) { versionTags.push(this.rootIndexMetadata.edition); } event.tags.push(versionTags); } if (this.rootIndexMetadata.publicationDate) { event.tags.push([ "published_on", this.rootIndexMetadata.publicationDate!, ]); } } // Event ID generation must be the last step. const eventId = event.getEventHash(); this.eventIds.set(nodeId, eventId); event.id = eventId; this.events.set(nodeId, event); return event; } /** * Generates a kind 30041 zettel event for the node with the given ID. * @param nodeId The ID of the AsciiDoc document node from which to generate an index event. The * node ID will be used as the event's unique d tag identifier. * @param pubkey The public key (not encoded in npub form) of the user generating the events. * @returns An unsigned NDKEvent containing the content of the zettel, the requisite tags, and * dated to the present moment. */ private generateZettelEvent(nodeId: string, pubkey: string): NDKEvent { const title = (this.nodes.get(nodeId)! as Block).getTitle(); const content = (this.nodes.get(nodeId)! as Block).getSource(); // AsciiDoc source content. const event = new NDKEvent(this.ndk); event.kind = 30041; event.content = content!; event.tags = [ ["title", title!], ["#d", nodeId], ...this.extractAndNormalizeWikilinks(content!), ]; // Extract image from content if present const imageUrl = this.extractImageFromContent(content!); if (imageUrl) { event.tags.push(["image", imageUrl]); } event.created_at = Date.now(); event.pubkey = pubkey; // Event ID generation must be the last step. const eventId = event.getEventHash(); this.eventIds.set(nodeId, eventId); event.id = eventId; this.events.set(nodeId, event); return event; } // #endregion // #region Utility Functions /** * Generates an ID for the given block that is unique within the document, and adds a mapping of * the generated ID to the block's context, as determined by the Asciidoctor parser. */ private generateNodeId(block: AbstractBlock): string { let blockId: string | null = this.normalizeId(block.getId()); if (blockId != null && blockId.length > 0) { return blockId; } blockId = this.normalizeId(block.getTitle()); // Use the provided title, if possible. if (blockId != null && blockId.length > 0) { return blockId; } const documentId = this.rootNodeId; let blockNumber: number; const context = block.getContext(); switch (context) { case "admonition": blockNumber = this.contextCounters.get("admonition") ?? 0; blockId = `${documentId}-admonition-${blockNumber++}`; this.contextCounters.set("admonition", blockNumber); break; case "audio": blockNumber = this.contextCounters.get("audio") ?? 0; blockId = `${documentId}-audio-${blockNumber++}`; this.contextCounters.set("audio", blockNumber); break; case "colist": blockNumber = this.contextCounters.get("colist") ?? 0; blockId = `${documentId}-colist-${blockNumber++}`; this.contextCounters.set("colist", blockNumber); break; case "dlist": blockNumber = this.contextCounters.get("dlist") ?? 0; blockId = `${documentId}-dlist-${blockNumber++}`; this.contextCounters.set("dlist", blockNumber); break; case "document": blockNumber = this.contextCounters.get("document") ?? 0; blockId = `${documentId}-document-${blockNumber++}`; this.contextCounters.set("document", blockNumber); break; case "example": blockNumber = this.contextCounters.get("example") ?? 0; blockId = `${documentId}-example-${blockNumber++}`; this.contextCounters.set("example", blockNumber); break; case "floating_title": blockNumber = this.contextCounters.get("floating_title") ?? 0; blockId = `${documentId}-floating-title-${blockNumber++}`; this.contextCounters.set("floating_title", blockNumber); break; case "image": blockNumber = this.contextCounters.get("image") ?? 0; blockId = `${documentId}-image-${blockNumber++}`; this.contextCounters.set("image", blockNumber); break; case "list_item": blockNumber = this.contextCounters.get("list_item") ?? 0; blockId = `${documentId}-list-item-${blockNumber++}`; this.contextCounters.set("list_item", blockNumber); break; case "listing": blockNumber = this.contextCounters.get("listing") ?? 0; blockId = `${documentId}-listing-${blockNumber++}`; this.contextCounters.set("listing", blockNumber); break; case "literal": blockNumber = this.contextCounters.get("literal") ?? 0; blockId = `${documentId}-literal-${blockNumber++}`; this.contextCounters.set("literal", blockNumber); break; case "olist": blockNumber = this.contextCounters.get("olist") ?? 0; blockId = `${documentId}-olist-${blockNumber++}`; this.contextCounters.set("olist", blockNumber); break; case "open": blockNumber = this.contextCounters.get("open") ?? 0; blockId = `${documentId}-open-${blockNumber++}`; this.contextCounters.set("open", blockNumber); break; case "page_break": blockNumber = this.contextCounters.get("page_break") ?? 0; blockId = `${documentId}-page-break-${blockNumber++}`; this.contextCounters.set("page_break", blockNumber); break; case "paragraph": blockNumber = this.contextCounters.get("paragraph") ?? 0; blockId = `${documentId}-paragraph-${blockNumber++}`; this.contextCounters.set("paragraph", blockNumber); break; case "pass": blockNumber = this.contextCounters.get("pass") ?? 0; blockId = `${documentId}-pass-${blockNumber++}`; this.contextCounters.set("pass", blockNumber); break; case "preamble": blockNumber = this.contextCounters.get("preamble") ?? 0; blockId = `${documentId}-preamble-${blockNumber++}`; this.contextCounters.set("preamble", blockNumber); break; case "quote": blockNumber = this.contextCounters.get("quote") ?? 0; blockId = `${documentId}-quote-${blockNumber++}`; this.contextCounters.set("quote", blockNumber); break; case "section": blockNumber = this.contextCounters.get("section") ?? 0; blockId = `${documentId}-section-${blockNumber++}`; this.contextCounters.set("section", blockNumber); break; case "sidebar": blockNumber = this.contextCounters.get("sidebar") ?? 0; blockId = `${documentId}-sidebar-${blockNumber++}`; this.contextCounters.set("sidebar", blockNumber); break; case "table": blockNumber = this.contextCounters.get("table") ?? 0; blockId = `${documentId}-table-${blockNumber++}`; this.contextCounters.set("table", blockNumber); break; case "table_cell": blockNumber = this.contextCounters.get("table_cell") ?? 0; blockId = `${documentId}-table-cell-${blockNumber++}`; this.contextCounters.set("table_cell", blockNumber); break; case "thematic_break": blockNumber = this.contextCounters.get("thematic_break") ?? 0; blockId = `${documentId}-thematic-break-${blockNumber++}`; this.contextCounters.set("thematic_break", blockNumber); break; case "toc": blockNumber = this.contextCounters.get("toc") ?? 0; blockId = `${documentId}-toc-${blockNumber++}`; this.contextCounters.set("toc", blockNumber); break; case "ulist": blockNumber = this.contextCounters.get("ulist") ?? 0; blockId = `${documentId}-ulist-${blockNumber++}`; this.contextCounters.set("ulist", blockNumber); break; case "verse": blockNumber = this.contextCounters.get("verse") ?? 0; blockId = `${documentId}-verse-${blockNumber++}`; this.contextCounters.set("verse", blockNumber); break; case "video": blockNumber = this.contextCounters.get("video") ?? 0; blockId = `${documentId}-video-${blockNumber++}`; this.contextCounters.set("video", blockNumber); break; default: blockNumber = this.contextCounters.get("block") ?? 0; blockId = `${documentId}-block-${blockNumber++}`; this.contextCounters.set("block", blockNumber); break; } block.setId(blockId); this.eventToContextMap.set(blockId, context); return blockId; } private normalizeId(input?: string): string | null { if (input == null || input.length === 0) { return null; } return he .decode(input) .toLowerCase() .replace(/[_]/g, " ") // Replace underscores with spaces. .trim() .replace(/\s+/g, "-") // Replace spaces with dashes. .replace(/[^a-z0-9\-]/g, ""); // Remove non-alphanumeric characters except dashes. } private updateEventByContext(dTag: string, value: string, context: string) { switch (context) { case "document": case "section": this.updateEventTitle(dTag, value); break; default: this.updateEventBody(dTag, value); break; } } private updateEventTitle(dTag: string, value: string) { const event = this.events.get(dTag); this.events.delete(dTag); this.events.set(value, event!); this.rehashEvent(dTag, event!); } private updateEventBody(dTag: string, value: string) { const event = this.events.get(dTag); event!.content = value; this.rehashEvent(dTag, event!); } private rehashEvent(dTag: string, event: NDKEvent) { event.id = event.getEventHash(); this.eventIds.set(dTag, event.id); this.shouldUpdateEventTree = true; } private extractAndNormalizeWikilinks(content: string): string[][] { const wikilinkPattern = /\[\[([^\]]+)\]\]/g; const wikilinks: string[][] = []; let match: RegExpExecArray | null; // TODO: Match custom-named wikilinks as defined in NIP-54. while ((match = wikilinkPattern.exec(content)) !== null) { const linkName = match[1]; const normalizedText = this.normalizeId(linkName); wikilinks.push(["wikilink", normalizedText!]); } return wikilinks; } /** * Extracts the first image URL from AsciiDoc content. * @param content The AsciiDoc content to search for images. * @returns The first image URL found, or null if no images are present. */ private extractImageFromContent(content: string): string | null { // Look for AsciiDoc image syntax: image::url[alt text] const imageRegex = /image::([^\s\[]+)/g; let match = imageRegex.exec(content); if (match) { return match[1]; } // Look for AsciiDoc image syntax: image:url[alt text] const inlineImageRegex = /image:([^\s\[]+)/g; match = inlineImageRegex.exec(content); if (match) { return match[1]; } // Look for markdown-style image syntax: ![alt](url) const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; match = markdownImageRegex.exec(content); if (match) { return match[2]; } return null; } // TODO: Add search-based wikilink resolution. // #endregion } export const pharosInstance: Writable = writable(); export const tocUpdate = writable(0); // Whenever you update the publication tree, call: tocUpdate.update((n) => n + 1); function ensureAsciiDocHeader(content: string): string { const lines = content.split(/\r?\n/); let headerIndex = -1; let hasDoctype = false; // Find the first non-empty line as header for (let i = 0; i < lines.length; i++) { if (lines[i].trim() === "") continue; if (lines[i].trim().startsWith("=")) { headerIndex = i; break; } else { throw new Error("AsciiDoc document is missing a header at the top."); } } if (headerIndex === -1) { throw new Error("AsciiDoc document is missing a header."); } // Check for doctype in the next non-empty line after header let nextLine = headerIndex + 1; while (nextLine < lines.length && lines[nextLine].trim() === "") { nextLine++; } if ( nextLine < lines.length && lines[nextLine].trim().startsWith(":doctype:") ) { hasDoctype = true; } // Insert doctype immediately after header if not present if (!hasDoctype) { lines.splice(headerIndex + 1, 0, ":doctype: book"); } return lines.join("\n"); }