You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1273 lines
40 KiB
1273 lines
40 KiB
// 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<string, number> = new Map<string, number>(); |
|
|
|
/** |
|
* 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<string, AbstractNode> = new Map<string, AbstractNode>(); |
|
|
|
/** |
|
* A map of event d tags to the events themselves. |
|
*/ |
|
private events: Map<string, NDKEvent> = new Map<string, NDKEvent>(); |
|
|
|
/** |
|
* A map of event d tags to the context name assigned to each event's originating node by the |
|
* Asciidoctor parser. |
|
*/ |
|
private eventToContextMap: Map<string, string> = new Map<string, string>(); |
|
|
|
/** |
|
* A map of node IDs to the integer event kind that will be used to represent the node. |
|
*/ |
|
private eventToKindMap: Map<string, number> = new Map<string, number>(); |
|
|
|
/** |
|
* A map of index IDs to the IDs of the nodes they reference. |
|
*/ |
|
private indexToChildEventsMap: Map<string, Set<string>> = new Map< |
|
string, |
|
Set<string> |
|
>(); |
|
|
|
/** |
|
* A map of node IDs to the Nostr event IDs of the events they generate. |
|
*/ |
|
private eventIds: Map<string, string> = new Map<string, string>(); |
|
|
|
/** |
|
* A map of the levels of the event tree to a list of event IDs at each level. |
|
*/ |
|
private eventsByLevelMap: Map<number, string[]> = new Map<number, string[]>(); |
|
|
|
/** |
|
* A map of blog entries |
|
*/ |
|
private blogEntries: Map<string, NDKEvent> = new Map<string, NDKEvent>(); |
|
|
|
/** |
|
* 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<void> { |
|
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<void> { |
|
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<string>()); |
|
|
|
/** 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<string>()); |
|
|
|
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<string> { |
|
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<string>[] = []; |
|
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:  |
|
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<Pharos> = 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"); |
|
}
|
|
|