clone of repo on github
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

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');
}