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.
1167 lines
38 KiB
1167 lines
38 KiB
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk'; |
|
import asciidoctor from 'asciidoctor'; |
|
import type { |
|
AbstractBlock, |
|
AbstractNode, |
|
Asciidoctor, |
|
Block, |
|
Document, |
|
Extensions, |
|
Section, |
|
ProcessorOptions, |
|
} from 'asciidoctor'; |
|
import he from 'he'; |
|
import { writable, type Writable } from 'svelte/store'; |
|
import { zettelKinds } from './consts.ts'; |
|
import { getMatchingTags } from '$lib/utils/nostrUtils'; |
|
|
|
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: 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 = asciidoctor(); |
|
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); |
|
}); |
|
}); |
|
} |
|
|
|
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 { |
|
this.html = this.asciidoctor.convert(content, { |
|
'extension_registry': this.pharosExtensions, |
|
...options, |
|
}) 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(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 != 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) { |
|
event.tags.push( |
|
[ |
|
'version', |
|
this.rootIndexMetadata.version!, |
|
this.rootIndexMetadata.edition! |
|
].filter(value => value != null) |
|
); |
|
} |
|
|
|
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!), |
|
]; |
|
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; |
|
} |
|
|
|
// 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; |
|
console.debug('[Pharos] AsciiDoc document header:', lines[i].trim()); |
|
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'); |
|
} |
|
|
|
// Log the state of the lines before returning |
|
console.debug('[Pharos] AsciiDoc lines after header/doctype normalization:', lines.slice(0, 5)); |
|
|
|
return lines.join('\n'); |
|
}
|
|
|