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.
 
 
 
 

510 lines
15 KiB

import type { NDKEvent } from "./nostrUtils.ts";
import NDK, { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { EVENT_KINDS } from "./search_constants";
import {
extractDocumentMetadata,
metadataToTags,
parseAsciiDocWithMetadata,
} from "./asciidoc_metadata.ts";
// =========================
// Validation
// =========================
/**
* Returns true if the event kind requires a d-tag (kinds 30000-39999).
*/
export function requiresDTag(kind: number): boolean {
return (
kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind <= EVENT_KINDS.ADDRESSABLE.MAX
);
}
/**
* Returns true if the tags array contains at least one d-tag with a non-empty value.
*/
export function hasDTag(tags: [string, string][]): boolean {
return tags.some(([k, v]) => k === "d" && v && v.trim() !== "");
}
/**
* Returns true if the content contains AsciiDoc headers (lines starting with '=' or '==').
*/
function containsAsciiDocHeaders(content: string): boolean {
return /^={1,}\s+/m.test(content);
}
/**
* Validates that content does NOT contain AsciiDoc headers (for kind 30023).
* Returns { valid, reason }.
*/
export function validateNotAsciidoc(content: string): {
valid: boolean;
reason?: string;
} {
if (containsAsciiDocHeaders(content)) {
return {
valid: false,
reason:
"Kind 30023 must not contain AsciiDoc headers (lines starting with = or ==).",
};
}
return { valid: true };
}
/**
* Validates AsciiDoc content. Must start with '=' and contain at least one '==' section header.
* Returns { valid, reason }.
*/
export function validateAsciiDoc(content: string): {
valid: boolean;
reason?: string;
} {
if (!content.trim().startsWith("=")) {
return {
valid: false,
reason: 'AsciiDoc must start with a document title ("=").',
};
}
if (!/^==\s+/m.test(content)) {
return {
valid: false,
reason: 'AsciiDoc must contain at least one section header ("==").',
};
}
return { valid: true };
}
/**
* Validates that a 30040 event set will be created correctly.
* Returns { valid, reason }.
*/
export function validate30040EventSet(content: string): {
valid: boolean;
reason?: string;
warning?: string;
} {
// Check for "index card" format first
const lines = content.split(/\r?\n/);
const { metadata } = extractDocumentMetadata(content);
const documentTitle = metadata.title;
const nonEmptyLines = lines.filter((line) => line.trim() !== "").map((line) =>
line.trim()
);
const isIndexCardFormat = documentTitle &&
nonEmptyLines.length === 2 &&
nonEmptyLines[0].startsWith("=") &&
nonEmptyLines[1].toLowerCase() === "index card";
if (isIndexCardFormat) {
return { valid: true };
}
// Check that we have a document title
if (!documentTitle) {
return {
valid: false,
reason:
'30040 events must have a document title (line starting with "=").',
};
}
// Check that the content will result in an empty 30040 event
// The 30040 event should have empty content, with all content split into 30041 events
if (!content.trim().startsWith("=")) {
return {
valid: false,
reason: '30040 events must start with a document title ("=").',
};
}
// Check for duplicate document headers (=)
const documentHeaderMatches = content.match(/^=\s+/gm);
if (documentHeaderMatches && documentHeaderMatches.length > 1) {
return {
valid: false,
reason:
'30040 events must have exactly one document title ("="). Found multiple document headers.',
};
}
// Parse the content to check sections
const parsed = parseAsciiDocWithMetadata(content);
const hasSections = parsed.sections.length > 0;
if (!hasSections) {
return {
valid: true,
warning:
"No section headers (==) found. This will create a 30040 index event and a single 30041 preamble section. Continue?",
};
}
// Only validate as AsciiDoc if we have sections
const asciiDocValidation = validateAsciiDoc(content);
if (!asciiDocValidation.valid) {
return asciiDocValidation;
}
// Check for empty sections
const emptySections = parsed.sections.filter((section: any) =>
section.content.trim() === ""
);
if (emptySections.length > 0) {
return {
valid: true,
warning: "You are creating sections that contain no content. Proceed?",
};
}
return { valid: true };
}
// =========================
// Extraction & Normalization
// =========================
/**
* Normalize a string for use as a d-tag: lowercase, hyphens, alphanumeric only.
*/
function normalizeDTagValue(header: string): string {
return header
.toLowerCase()
// Decode common HTML entities first
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&nbsp;/g, " ")
// Then normalize as before
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Converts a title string to a valid d-tag (lowercase, hyphens, no punctuation).
*/
export function titleToDTag(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, ""); // Trim leading/trailing hyphens
}
/**
* Extracts the topmost Markdown # header (line starting with '# ').
*/
function extractMarkdownTopHeader(content: string): string | null {
const match = content.match(/^#\s+(.+)$/m);
return match ? match[1].trim() : null;
}
// =========================
// Event Construction
// =========================
/**
* Builds a set of events for a 30040 publication: one 30040 index event and one 30041 event per section.
* Each 30041 gets a d-tag (normalized section header) and a title tag (raw section header).
* The 30040 index event references all 30041s by their d-tag.
*/
export function build30040EventSet(
content: string,
tags: [string, string][],
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number },
ndk: NDK,
): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } {
// Parse the AsciiDoc content with metadata extraction
const parsed = parseAsciiDocWithMetadata(content);
console.log("Parsed AsciiDoc:", parsed);
// Check if this is an "index card" format (no sections, just title + "index card")
const lines = content.split(/\r?\n/);
const documentTitle = parsed.metadata.title;
// For index card format, the content should be exactly: title + "index card"
const nonEmptyLines = lines.filter((line) => line.trim() !== "").map((line) =>
line.trim()
);
const isIndexCardFormat = documentTitle &&
nonEmptyLines.length === 2 &&
nonEmptyLines[0].startsWith("=") &&
nonEmptyLines[1].toLowerCase() === "index card";
if (isIndexCardFormat) {
console.log("Creating index card format (no sections)");
const indexDTag = normalizeDTagValue(documentTitle);
// Convert document metadata to tags
const metadataTags = metadataToTags(parsed.metadata);
const indexEvent: NDKEvent = new NDKEventClass(ndk, {
kind: 30040,
content: "",
tags: [
...tags,
...metadataTags,
["d", indexDTag],
["title", documentTitle],
],
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
return { indexEvent, sectionEvents: [] };
}
// Generate the index d-tag first
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : "index";
console.log("Index event:", { documentTitle, indexDTag });
// Create section events with their metadata
const sectionEvents: NDKEvent[] = parsed.sections.map((section: any, i: number) => {
const sectionDTag = `${indexDTag}-${normalizeDTagValue(section.title)}`;
console.log(`Creating section ${i}:`, {
title: section.title,
dTag: sectionDTag,
content: section.content,
metadata: section.metadata,
});
// Convert section metadata to tags
const sectionMetadataTags = metadataToTags(section.metadata);
return new NDKEventClass(ndk, {
kind: 30041,
content: section.content,
tags: [
...tags,
...sectionMetadataTags,
["d", sectionDTag],
["title", section.title],
],
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
});
// Create proper a tags with format: kind:pubkey:d-tag
const aTags = sectionEvents.map((event) => {
const dTag = event.tags.find(([k]) => k === "d")?.[1];
return ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string];
});
console.log("A tags:", aTags);
// Convert document metadata to tags
const metadataTags = metadataToTags(parsed.metadata);
const indexTags = [
...tags,
...metadataTags,
["d", indexDTag],
["title", documentTitle || "Untitled"],
...aTags,
];
const indexEvent: NDKEvent = new NDKEventClass(ndk, {
kind: 30040,
content: "",
tags: indexTags,
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
console.log("Final index event:", indexEvent);
console.log("=== build30040EventSet completed ===");
return { indexEvent, sectionEvents };
}
/**
* Returns the appropriate title tag for a given event kind and content.
* - 30041, 30818: AsciiDoc document header (first '= ' line)
* - 30023: Markdown topmost '# ' header
*/
export function getTitleTagForEvent(
kind: number,
content: string,
): string | null {
if (kind === 30041 || kind === 30818) {
const { metadata } = extractDocumentMetadata(content);
return metadata.title || null;
}
if (kind === 30023) {
return extractMarkdownTopHeader(content);
}
return null;
}
/**
* Returns the appropriate d-tag value for a given event kind and content.
* - 30023: Normalized markdown header
* - 30041, 30818: Normalized AsciiDoc document header
* - 30040: Uses existing d-tag or generates from content
*/
export function getDTagForEvent(
kind: number,
content: string,
existingDTag?: string,
): string | null {
if (existingDTag && existingDTag.trim() !== "") {
return existingDTag.trim();
}
if (kind === 30023) {
const title = extractMarkdownTopHeader(content);
return title ? normalizeDTagValue(title) : null;
}
if (kind === 30041 || kind === 30818) {
const { metadata } = extractDocumentMetadata(content);
return metadata.title ? normalizeDTagValue(metadata.title) : null;
}
return null;
}
/**
* Returns a description of what a 30040 event structure should be.
*/
export function get30040EventDescription(): string {
return `30040 events are publication indexes that organize AsciiDoc content into structured publications.
**Supported Structures:**
1. **Normal Document** (with sections):
= Document Title
:author: Author Name
:summary: Document description
:keywords: tag1, tag2, tag3
== Section 1
Section content here...
== Section 2
More content...
2. **Index Card** (empty publication):
= Publication Title
index card
3. **Skeleton Document** (empty sections):
= Document Title
== Empty Section 1
== Empty Section 2
4. **Preamble Document** (with preamble content):
= Document Title
:author: Author Name
:summary: Document description
:keywords: tag1, tag2, tag3
Preamble content here...
== Section 1
Section content here...
**Metadata Extraction:**
- Document title, authors, version, publication date, and publisher are extracted from header lines
- Additional metadata (summary/description, keywords/tags, image, ISBN, etc.) are extracted from attributes
- Multiple authors and summaries are preserved
- All metadata is converted to appropriate Nostr event tags
**Event Structure:**
- 30040 index event: Empty content with metadata tags and a-tags referencing sections
- 30041 section events: Individual section content with section-specific metadata
**Special Features:**
- Preamble content (between header and first section) is preserved
- Multiple authors and descriptions are supported
- Keywords and tags are automatically converted to Nostr t-tags
- Index card format creates empty publications without sections`;
}
/**
* Analyzes a 30040 event to determine if it was created correctly.
* Returns { valid, issues } where issues is an array of problems found.
*/
export function analyze30040Event(event: {
content: string;
tags: [string, string][];
kind: number;
}): { valid: boolean; issues: string[] } {
const issues: string[] = [];
// Check if it's actually a 30040 event
if (event.kind !== 30040) {
issues.push("Event is not kind 30040");
return { valid: false, issues };
}
// Check if content is empty (30040 should be metadata only)
if (event.content && event.content.trim() !== "") {
issues.push("30040 events should have empty content (metadata only)");
issues.push("Content should be split into separate 30041 events");
}
// Check for required tags
const hasTitle = event.tags.some(([k, v]) => k === "title" && v);
const hasDTag = event.tags.some(([k, v]) => k === "d" && v);
const hasATags = event.tags.some(([k, v]) => k === "a" && v);
if (!hasTitle) {
issues.push("Missing title tag");
}
if (!hasDTag) {
issues.push("Missing d tag");
}
if (!hasATags) {
issues.push("Missing a tags (should reference 30041 content events)");
}
// Check if a tags have the correct format (kind:pubkey:d-tag)
const aTags = event.tags.filter(([k, v]) => k === "a" && v);
for (const [, value] of aTags) {
if (!value.includes(":")) {
issues.push(
`Invalid a tag format: ${value} (should be "kind:pubkey:d-tag")`,
);
}
}
return { valid: issues.length === 0, issues };
}
/**
* Returns guidance on how to fix incorrect 30040 events.
*/
export function get30040FixGuidance(): string {
return `To fix a 30040 event:
1. **Content Structure**: Ensure your AsciiDoc starts with a document title (= Title)
- Add at least one section (== Section) for normal documents
- Use "index card" format for empty publications
- Include metadata in header lines or attributes,
or add them manually to the tag list
2. **Metadata**: Add relevant metadata to improve discoverability:
- Author: Use header line or :author: attribute
- Summary: Use :summary: or :description: attribute
- Keywords: Use :keywords: or :tags: attribute
- Version: Use revision line or :version: attribute
- Publication date: Use revision line or :published_on: attribute
3. **Event Structure**: The system will automatically create:
- 30040 index event: Empty content with metadata and a-tags
- 30041 section events: Individual section content with section metadata
4. **Common Issues**:
- Missing document title: Start with "= Your Title"
- No sections: Add "== Section Name" or use "index card" format
- Invalid metadata: Use proper AsciiDoc attribute syntax (:key: value)
5. **Best Practices**:
- Include descriptive titles and summaries
- Use keywords for better searchability
- Add author information when relevant
- Consider using preamble content for introductions`;
}