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.
 
 
 
 

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: ![alt](url)
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
match = markdownImageRegex.exec(content);
if (match) {
return match[2];
}
return null;
}
// TODO: Add search-based wikilink resolution.
// #endregion
}
export const pharosInstance: Writable<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");
}