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.
 
 
 
 

420 lines
11 KiB

/**
* Factory for creating PublicationTree instances from AsciiDoc content
*
* This integrates the AST parser with Michael's PublicationTree architecture,
* providing a clean bridge between AsciiDoc parsing and Nostr event publishing.
*/
import { PublicationTree } from "$lib/data_structures/publication_tree";
import { SveltePublicationTree } from "$lib/components/publications/svelte_publication_tree.svelte";
import { parseAsciiDocAST } from "$lib/utils/asciidoc_ast_parser";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { getMimeTags } from "$lib/utils/mime";
export interface PublicationTreeFactoryResult {
tree: PublicationTree;
svelteTree: SveltePublicationTree;
indexEvent: NDKEvent | null;
contentEvents: NDKEvent[];
metadata: {
title: string;
totalSections: number;
contentType: "article" | "scattered-notes" | "none";
attributes: Record<string, string>;
};
}
/**
* Create a PublicationTree from AsciiDoc content using AST parsing
* This is the main integration point between AST parsing and PublicationTree
*/
export async function createPublicationTreeFromContent(
content: string,
ndk: NDK,
parseLevel: number = 2,
): Promise<PublicationTreeFactoryResult> {
// For preview purposes, we can work without authentication
// Authentication is only required for actual publishing
const hasActiveUser = !!ndk.activeUser;
// Parse content using AST
const parsed = parseAsciiDocAST(content, parseLevel);
// Determine content type
const contentType = detectContentType(parsed);
let tree: PublicationTree;
let indexEvent: NDKEvent | null = null;
const contentEvents: NDKEvent[] = [];
if (contentType === "article" && parsed.title) {
// Create hierarchical structure: 30040 index + 30041 content events
indexEvent = createIndexEvent(parsed, ndk);
tree = new PublicationTree(indexEvent, ndk);
// Add content events to tree
for (const section of parsed.sections) {
const contentEvent = createContentEvent(section, parsed, ndk);
await tree.addEvent(contentEvent, indexEvent);
contentEvents.push(contentEvent);
}
} else if (contentType === "scattered-notes") {
// Create flat structure: only 30041 events
if (parsed.sections.length === 0) {
throw new Error("No sections found for scattered notes");
}
// Use first section as root for tree structure
const firstSection = parsed.sections[0];
const rootEvent = createContentEvent(firstSection, parsed, ndk);
tree = new PublicationTree(rootEvent, ndk);
contentEvents.push(rootEvent);
// Add remaining sections
for (let i = 1; i < parsed.sections.length; i++) {
const contentEvent = createContentEvent(parsed.sections[i], parsed, ndk);
await tree.addEvent(contentEvent, rootEvent);
contentEvents.push(contentEvent);
}
} else {
throw new Error("No valid content found to create publication tree");
}
// Create reactive Svelte wrapper
const svelteTree = new SveltePublicationTree(
indexEvent || contentEvents[0],
ndk,
);
return {
tree,
svelteTree,
indexEvent,
contentEvents,
metadata: {
title: parsed.title,
totalSections: parsed.sections.length,
contentType,
attributes: parsed.attributes,
},
};
}
/**
* Create a 30040 index event from parsed document
*/
function createIndexEvent(parsed: any, ndk: NDK): NDKEvent {
const event = new NDKEvent(ndk);
event.kind = 30040;
event.created_at = Math.floor(Date.now() / 1000);
// Use placeholder pubkey for preview if no active user
event.pubkey = ndk.activeUser?.pubkey || "preview-placeholder-pubkey";
// Generate d-tag from title
const dTag = generateDTag(parsed.title);
const [mTag, MTag] = getMimeTags(30040);
const tags: string[][] = [["d", dTag], mTag, MTag, ["title", parsed.title]];
// Add document attributes as tags
addDocumentAttributesToTags(tags, parsed.attributes, event.pubkey);
// Generate publication abbreviation for namespacing sections
const pubAbbrev = generateTitleAbbreviation(parsed.title);
// Add a-tags for each section (30041 references)
// Using new format: kind:pubkey:{abbv}-{section-d-tag}
parsed.sections.forEach((section: any) => {
const sectionDTag = generateDTag(section.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
tags.push(["a", `30041:${event.pubkey}:${namespacedDTag}`]);
});
event.tags = tags;
event.content = parsed.content || generateIndexContent(parsed);
return event;
}
/**
* Create a 30041 content event from parsed section
*/
function createContentEvent(
section: any,
documentParsed: any,
ndk: NDK,
): NDKEvent {
const event = new NDKEvent(ndk);
event.kind = 30041;
event.created_at = Math.floor(Date.now() / 1000);
// Use placeholder pubkey for preview if no active user
event.pubkey = ndk.activeUser?.pubkey || "preview-placeholder-pubkey";
// Generate namespaced d-tag using publication abbreviation
const sectionDTag = generateDTag(section.title);
const pubAbbrev = generateTitleAbbreviation(documentParsed.title);
const namespacedDTag = `${pubAbbrev}-${sectionDTag}`;
const [mTag, MTag] = getMimeTags(30041);
const tags: string[][] = [
["d", namespacedDTag],
mTag,
MTag,
["title", section.title],
];
// Add section-specific attributes
addSectionAttributesToTags(tags, section.attributes);
// Add document-level attributes that should be inherited
inheritDocumentAttributes(tags, documentParsed.attributes);
event.tags = tags;
event.content = section.content || "";
return event;
}
/**
* Detect content type based on parsed structure
*/
function detectContentType(
parsed: any,
): "article" | "scattered-notes" | "none" {
const hasDocTitle = !!parsed.title;
const hasSections = parsed.sections.length > 0;
// Check if the "title" is actually just the first section title
// This happens when AsciiDoc starts with == instead of =
const titleMatchesFirstSection = parsed.sections.length > 0 &&
parsed.title === parsed.sections[0].title;
if (hasDocTitle && hasSections && !titleMatchesFirstSection) {
return "article";
} else if (hasSections) {
return "scattered-notes";
}
return "none";
}
/**
* Generate deterministic d-tag from title
*/
function generateDTag(title: string): string {
return (
title
.toLowerCase()
.replace(/[^\p{L}\p{N}]/gu, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || "untitled"
);
}
/**
* Generate title abbreviation from first letters of each word
* Used for namespacing section a-tags
* @param title - The publication title
* @returns Abbreviation string (e.g., "My Test Article" → "mta")
*/
function generateTitleAbbreviation(title: string): string {
if (!title || !title.trim()) {
return "u"; // "untitled"
}
// Split on non-alphanumeric characters and filter out empty strings
const words = title
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length > 0);
if (words.length === 0) {
return "u";
}
// Take first letter of each word and join
return words
.map((word) => word.charAt(0).toLowerCase())
.join("");
}
/**
* Add document attributes as Nostr tags
*/
function addDocumentAttributesToTags(
tags: string[][],
attributes: Record<string, string>,
pubkey: string,
) {
// Standard metadata
if (attributes.author) tags.push(["author", attributes.author]);
if (attributes.version) tags.push(["version", attributes.version]);
if (attributes.published) tags.push(["published", attributes.published]);
if (attributes.language) tags.push(["language", attributes.language]);
if (attributes.image) tags.push(["image", attributes.image]);
if (attributes.description) tags.push(["summary", attributes.description]);
// Tags
if (attributes.tags) {
attributes.tags.split(",").forEach((tag) => tags.push(["t", tag.trim()]));
}
// Add pubkey reference
tags.push(["p", pubkey]);
// Custom attributes (filtered)
addCustomAttributes(tags, attributes);
}
/**
* Add section-specific attributes as tags
*/
function addSectionAttributesToTags(
tags: string[][],
attributes: Record<string, string>,
) {
addCustomAttributes(tags, attributes);
}
/**
* Inherit relevant document attributes for content events
*/
function inheritDocumentAttributes(
tags: string[][],
documentAttributes: Record<string, string>,
) {
// Inherit selected document attributes
if (documentAttributes.language) {
tags.push(["language", documentAttributes.language]);
}
if (documentAttributes.type) tags.push(["type", documentAttributes.type]);
}
/**
* Add custom attributes, filtering out system ones
*/
function addCustomAttributes(
tags: string[][],
attributes: Record<string, string>,
) {
const systemAttributes = [
"attribute-undefined",
"attribute-missing",
"appendix-caption",
"appendix-refsig",
"caution-caption",
"chapter-refsig",
"example-caption",
"figure-caption",
"important-caption",
"last-update-label",
"manname-title",
"note-caption",
"part-refsig",
"preface-title",
"section-refsig",
"table-caption",
"tip-caption",
"toc-title",
"untitled-label",
"version-label",
"warning-caption",
"asciidoctor",
"asciidoctor-version",
"safe-mode-name",
"backend",
"doctype",
"basebackend",
"filetype",
"outfilesuffix",
"stylesdir",
"iconsdir",
"localdate",
"localyear",
"localtime",
"localdatetime",
"docdate",
"docyear",
"doctime",
"docdatetime",
"doctitle",
"embedded",
"notitle",
// Already handled above
"author",
"version",
"published",
"language",
"image",
"description",
"tags",
"title",
"type",
];
Object.entries(attributes).forEach(([key, value]) => {
if (!systemAttributes.includes(key) && value && typeof value === "string") {
tags.push([key, value]);
}
});
}
/**
* Generate default index content if none provided
*/
function generateIndexContent(parsed: any): string {
return `# ${parsed.title}
${parsed.sections.length} sections available:
${
parsed.sections
.map((section: any, i: number) => `${i + 1}. ${section.title}`)
.join("\n")
}`;
}
/**
* Export events from PublicationTree for publishing
* This provides compatibility with the current publishing workflow
*/
export async function exportEventsFromTree(
result: PublicationTreeFactoryResult,
) {
const events: any[] = [];
// Add index event if it exists
if (result.indexEvent) {
events.push(eventToPublishableObject(result.indexEvent));
}
// Add content events
result.contentEvents.forEach((event) => {
events.push(eventToPublishableObject(event));
});
return {
indexEvent: result.indexEvent
? eventToPublishableObject(result.indexEvent)
: undefined,
contentEvents: result.contentEvents.map(eventToPublishableObject),
tree: result.tree,
};
}
/**
* Convert NDKEvent to publishable object format
*/
function eventToPublishableObject(event: NDKEvent) {
return {
kind: event.kind,
content: event.content,
tags: event.tags,
created_at: event.created_at,
pubkey: event.pubkey,
id: event.id,
title: event.tags.find((t) => t[0] === "title")?.[1] || "Untitled",
};
}