21 changed files with 2664 additions and 783 deletions
@ -0,0 +1,500 @@
@@ -0,0 +1,500 @@
|
||||
/** |
||||
* AsciiDoc Metadata Extraction Service using Asciidoctor |
||||
*
|
||||
* Thin wrapper around Asciidoctor's built-in metadata extraction capabilities. |
||||
* Leverages the existing Pharos parser to avoid duplication. |
||||
*/ |
||||
|
||||
// @ts-ignore
|
||||
import Processor from "asciidoctor"; |
||||
import type { Document } from "asciidoctor"; |
||||
|
||||
export interface AsciiDocMetadata { |
||||
title?: string; |
||||
authors?: string[]; |
||||
version?: string; |
||||
edition?: string; |
||||
publicationDate?: string; |
||||
publisher?: string; |
||||
summary?: string; |
||||
coverImage?: string; |
||||
isbn?: string; |
||||
tags?: string[]; |
||||
source?: string; |
||||
publishedBy?: string; |
||||
type?: string; |
||||
autoUpdate?: 'yes' | 'ask' | 'no'; |
||||
} |
||||
|
||||
export type SectionMetadata = AsciiDocMetadata; |
||||
|
||||
export interface ParsedAsciiDoc { |
||||
metadata: AsciiDocMetadata; |
||||
content: string; |
||||
sections: Array<{ |
||||
metadata: SectionMetadata; |
||||
content: string; |
||||
title: string; |
||||
}>; |
||||
} |
||||
|
||||
// Shared attribute mapping based on Asciidoctor standard attributes
|
||||
const ATTRIBUTE_MAP: Record<string, keyof AsciiDocMetadata> = { |
||||
// Standard Asciidoctor attributes
|
||||
'author': 'authors', |
||||
'description': 'summary', |
||||
'keywords': 'tags', |
||||
'revnumber': 'version', |
||||
'revdate': 'publicationDate', |
||||
'revremark': 'edition', |
||||
'title': 'title', |
||||
|
||||
// Custom attributes for Alexandria
|
||||
'published_by': 'publishedBy', |
||||
'publisher': 'publisher', |
||||
'summary': 'summary', |
||||
'image': 'coverImage', |
||||
'cover': 'coverImage', |
||||
'isbn': 'isbn', |
||||
'source': 'source', |
||||
'type': 'type', |
||||
'auto-update': 'autoUpdate', |
||||
'version': 'version', |
||||
'edition': 'edition', |
||||
'published_on': 'publicationDate', |
||||
'date': 'publicationDate', |
||||
'version-label': 'version', |
||||
}; |
||||
|
||||
/** |
||||
* Creates an Asciidoctor processor instance |
||||
*/ |
||||
function createProcessor() { |
||||
return Processor(); |
||||
} |
||||
|
||||
/** |
||||
* Extracts tags from attributes, combining tags and keywords |
||||
*/ |
||||
function extractTagsFromAttributes(attributes: Record<string, any>): string[] { |
||||
const tags: string[] = []; |
||||
const attrTags = attributes['tags']; |
||||
const attrKeywords = attributes['keywords']; |
||||
|
||||
if (attrTags && typeof attrTags === 'string') { |
||||
tags.push(...attrTags.split(',').map(tag => tag.trim())); |
||||
} |
||||
|
||||
if (attrKeywords && typeof attrKeywords === 'string') { |
||||
tags.push(...attrKeywords.split(',').map(tag => tag.trim())); |
||||
} |
||||
|
||||
return [...new Set(tags)]; // Remove duplicates
|
||||
} |
||||
|
||||
/** |
||||
* Maps attributes to metadata with special handling for authors and tags |
||||
*/ |
||||
function mapAttributesToMetadata(attributes: Record<string, any>, metadata: AsciiDocMetadata, isDocument: boolean = false): void { |
||||
for (const [key, value] of Object.entries(attributes)) { |
||||
const metadataKey = ATTRIBUTE_MAP[key.toLowerCase()]; |
||||
if (metadataKey && value && typeof value === 'string') { |
||||
if (metadataKey === 'authors' && isDocument) { |
||||
// Skip author mapping for documents since it's handled manually
|
||||
continue; |
||||
} else if (metadataKey === 'authors' && !isDocument) { |
||||
// For sections, append author to existing authors array
|
||||
if (!metadata.authors) { |
||||
metadata.authors = []; |
||||
} |
||||
metadata.authors.push(value); |
||||
} else if (metadataKey === 'tags') { |
||||
// Skip tags mapping since it's handled by extractTagsFromAttributes
|
||||
continue; |
||||
} else { |
||||
(metadata as any)[metadataKey] = value; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Extracts authors from header line (document or section) |
||||
*/ |
||||
function extractAuthorsFromHeader(sourceContent: string, isSection: boolean = false): string[] { |
||||
const authors: string[] = []; |
||||
const lines = sourceContent.split(/\r?\n/); |
||||
const headerPattern = isSection ? /^==\s+/ : /^=\s+/; |
||||
|
||||
for (let i = 0; i < lines.length; i++) { |
||||
const line = lines[i]; |
||||
if (line.match(headerPattern)) { |
||||
// Found title line, check subsequent lines for authors
|
||||
let j = i + 1; |
||||
while (j < lines.length) { |
||||
const authorLine = lines[j]; |
||||
|
||||
// Stop if we hit a blank line or content that's not an author
|
||||
if (authorLine.trim() === '') { |
||||
break; |
||||
} |
||||
|
||||
if (authorLine.includes('<') && !authorLine.startsWith(':')) { |
||||
// This is an author line like "John Doe <john@example.com>"
|
||||
const authorName = authorLine.split('<')[0].trim(); |
||||
if (authorName) { |
||||
authors.push(authorName); |
||||
} |
||||
} else if (isSection && authorLine.match(/^[A-Za-z\s]+$/) && authorLine.trim() !== '' && authorLine.trim().split(/\s+/).length <= 2) { |
||||
// This is a simple author name without email (for sections)
|
||||
authors.push(authorLine.trim()); |
||||
} else if (authorLine.startsWith(':')) { |
||||
// This is an attribute line, skip it - attributes are handled by mapAttributesToMetadata
|
||||
// Don't break here, continue to next line
|
||||
} else { |
||||
// Not an author line, stop looking
|
||||
break; |
||||
} |
||||
|
||||
j++; |
||||
} |
||||
break; |
||||
} |
||||
} |
||||
|
||||
return authors; |
||||
} |
||||
|
||||
/** |
||||
* Strips header and attribute lines from content |
||||
*/ |
||||
function stripHeaderAndAttributes(content: string, isSection: boolean = false): string { |
||||
const lines = content.split(/\r?\n/); |
||||
let contentStart = 0; |
||||
const headerPattern = isSection ? /^==\s+/ : /^=\s+/; |
||||
|
||||
for (let i = 0; i < lines.length; i++) { |
||||
const line = lines[i]; |
||||
// Skip title line, author line, revision line, and attribute lines
|
||||
if (!line.match(headerPattern) && !line.includes('<') && !line.match(/^.+,\s*.+:\s*.+$/) &&
|
||||
!line.match(/^:[^:]+:\s*.+$/) && line.trim() !== '') { |
||||
contentStart = i; |
||||
break; |
||||
} |
||||
} |
||||
|
||||
// Filter out all attribute lines and author lines from the content
|
||||
const contentLines = lines.slice(contentStart); |
||||
const filteredLines = contentLines.filter(line => { |
||||
// Skip attribute lines
|
||||
if (line.match(/^:[^:]+:\s*.+$/)) { |
||||
return false; |
||||
} |
||||
// Skip author lines (simple names without email)
|
||||
if (isSection && line.match(/^[A-Za-z\s]+$/) && line.trim() !== '' && line.trim().split(/\s+/).length <= 2) { |
||||
return false; |
||||
} |
||||
return true; |
||||
}); |
||||
|
||||
// Remove extra blank lines and normalize newlines
|
||||
return filteredLines.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n').replace(/\n\s*\n/g, '\n').trim(); |
||||
} |
||||
|
||||
/** |
||||
* Parses attributes from section content |
||||
*/ |
||||
function parseSectionAttributes(sectionContent: string): Record<string, any> { |
||||
const attributes: Record<string, any> = {}; |
||||
const lines = sectionContent.split(/\r?\n/); |
||||
|
||||
for (const line of lines) { |
||||
const match = line.match(/^:([^:]+):\s*(.+)$/); |
||||
if (match) { |
||||
const [, key, value] = match; |
||||
attributes[key.trim()] = value.trim(); |
||||
} |
||||
} |
||||
|
||||
return attributes; |
||||
} |
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/** |
||||
* Extracts metadata from AsciiDoc document using Asciidoctor |
||||
*/ |
||||
export function extractDocumentMetadata(inputContent: string): { |
||||
metadata: AsciiDocMetadata; |
||||
content: string; |
||||
} { |
||||
const asciidoctor = createProcessor(); |
||||
const document = asciidoctor.load(inputContent, { standalone: false }) as Document; |
||||
|
||||
const metadata: AsciiDocMetadata = {}; |
||||
const attributes = document.getAttributes(); |
||||
|
||||
// Extract basic metadata
|
||||
const title = document.getTitle(); |
||||
if (title) metadata.title = title; |
||||
|
||||
// Handle multiple authors - combine header line and attributes
|
||||
const authors = extractAuthorsFromHeader(document.getSource()); |
||||
|
||||
// Get authors from attributes (but avoid duplicates)
|
||||
const attrAuthor = attributes['author']; |
||||
if (attrAuthor && typeof attrAuthor === 'string' && !authors.includes(attrAuthor)) { |
||||
authors.push(attrAuthor); |
||||
} |
||||
|
||||
if (authors.length > 0) { |
||||
metadata.authors = [...new Set(authors)]; // Remove duplicates
|
||||
} |
||||
|
||||
// Extract revision info
|
||||
const revisionNumber = document.getRevisionNumber(); |
||||
if (revisionNumber) metadata.version = revisionNumber; |
||||
|
||||
const revisionRemark = document.getRevisionRemark(); |
||||
if (revisionRemark) metadata.publishedBy = revisionRemark; |
||||
|
||||
const revisionDate = document.getRevisionDate(); |
||||
if (revisionDate) metadata.publicationDate = revisionDate; |
||||
|
||||
// Map attributes to metadata (but skip version and publishedBy if we already have them from revision)
|
||||
mapAttributesToMetadata(attributes, metadata, true); |
||||
|
||||
// If we got version from revision, don't override it with attribute
|
||||
if (revisionNumber) { |
||||
metadata.version = revisionNumber; |
||||
} |
||||
|
||||
// If we got publishedBy from revision, don't override it with attribute
|
||||
if (revisionRemark) { |
||||
metadata.publishedBy = revisionRemark; |
||||
} |
||||
|
||||
// Handle tags and keywords
|
||||
const tags = extractTagsFromAttributes(attributes); |
||||
if (tags.length > 0) { |
||||
metadata.tags = tags; |
||||
} |
||||
|
||||
const content = stripHeaderAndAttributes(document.getSource()); |
||||
return { metadata, content }; |
||||
} |
||||
|
||||
/** |
||||
* Extracts metadata from a section using Asciidoctor |
||||
*/ |
||||
export function extractSectionMetadata(inputSectionContent: string): { |
||||
metadata: SectionMetadata; |
||||
content: string; |
||||
title: string; |
||||
} { |
||||
const asciidoctor = createProcessor(); |
||||
const document = asciidoctor.load(`= Temp\n\n${inputSectionContent}`, { standalone: false }) as Document; |
||||
const sections = document.getSections(); |
||||
|
||||
if (sections.length === 0) { |
||||
return { metadata: {}, content: inputSectionContent, title: '' }; |
||||
} |
||||
|
||||
const section = sections[0]; |
||||
const title = section.getTitle() || ''; |
||||
const metadata: SectionMetadata = { title }; |
||||
|
||||
// Parse attributes from the section content
|
||||
const attributes = parseSectionAttributes(inputSectionContent); |
||||
|
||||
// Extract authors from section content
|
||||
const authors = extractAuthorsFromHeader(inputSectionContent, true); |
||||
if (authors.length > 0) { |
||||
metadata.authors = authors; |
||||
} |
||||
|
||||
// Map attributes to metadata (sections can have authors)
|
||||
mapAttributesToMetadata(attributes, metadata, false); |
||||
|
||||
// Handle tags and keywords
|
||||
const tags = extractTagsFromAttributes(attributes); |
||||
if (tags.length > 0) { |
||||
metadata.tags = tags; |
||||
} |
||||
|
||||
const content = stripHeaderAndAttributes(inputSectionContent, true); |
||||
return { metadata, content, title }; |
||||
} |
||||
|
||||
/** |
||||
* Parses AsciiDoc content into sections with metadata |
||||
*/ |
||||
export function parseAsciiDocWithMetadata(content: string): ParsedAsciiDoc { |
||||
const asciidoctor = createProcessor(); |
||||
const document = asciidoctor.load(content, { standalone: false }) as Document; |
||||
const { metadata: docMetadata } = extractDocumentMetadata(content); |
||||
|
||||
// Parse the original content to find section attributes
|
||||
const lines = content.split(/\r?\n/); |
||||
const sectionsWithMetadata: Array<{ |
||||
metadata: SectionMetadata; |
||||
content: string; |
||||
title: string; |
||||
}> = []; |
||||
let currentSection: string | null = null; |
||||
let currentSectionContent: string[] = []; |
||||
|
||||
for (const line of lines) { |
||||
if (line.match(/^==\s+/)) { |
||||
// Save previous section if exists
|
||||
if (currentSection) { |
||||
const sectionContent = currentSectionContent.join('\n'); |
||||
sectionsWithMetadata.push(extractSectionMetadata(sectionContent)); |
||||
} |
||||
|
||||
// Start new section
|
||||
currentSection = line; |
||||
currentSectionContent = [line]; |
||||
} else if (currentSection) { |
||||
currentSectionContent.push(line); |
||||
} |
||||
} |
||||
|
||||
// Save the last section
|
||||
if (currentSection) { |
||||
const sectionContent = currentSectionContent.join('\n'); |
||||
sectionsWithMetadata.push(extractSectionMetadata(sectionContent)); |
||||
} |
||||
|
||||
return { |
||||
metadata: docMetadata, |
||||
content: document.getSource(), |
||||
sections: sectionsWithMetadata |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Converts metadata to Nostr event tags |
||||
*/ |
||||
export function metadataToTags(metadata: AsciiDocMetadata | SectionMetadata): [string, string][] { |
||||
const tags: [string, string][] = []; |
||||
|
||||
if (metadata.title) tags.push(['title', metadata.title]); |
||||
if (metadata.authors?.length) { |
||||
metadata.authors.forEach(author => tags.push(['author', author])); |
||||
} |
||||
if (metadata.version) tags.push(['version', metadata.version]); |
||||
if (metadata.edition) tags.push(['edition', metadata.edition]); |
||||
if (metadata.publicationDate) tags.push(['published_on', metadata.publicationDate]); |
||||
if (metadata.publishedBy) tags.push(['published_by', metadata.publishedBy]); |
||||
if (metadata.summary) tags.push(['summary', metadata.summary]); |
||||
if (metadata.coverImage) tags.push(['image', metadata.coverImage]); |
||||
if (metadata.isbn) tags.push(['i', metadata.isbn]); |
||||
if (metadata.source) tags.push(['source', metadata.source]); |
||||
if (metadata.type) tags.push(['type', metadata.type]); |
||||
if (metadata.autoUpdate) tags.push(['auto-update', metadata.autoUpdate]); |
||||
if (metadata.tags?.length) { |
||||
metadata.tags.forEach(tag => tags.push(['t', tag])); |
||||
} |
||||
|
||||
return tags; |
||||
} |
||||
|
||||
/** |
||||
* Removes metadata from AsciiDoc content |
||||
*/ |
||||
export function removeMetadataFromContent(content: string): string { |
||||
const { content: cleanedContent } = extractDocumentMetadata(content); |
||||
return cleanedContent; |
||||
}
|
||||
|
||||
/** |
||||
* Extracts metadata from content that only contains sections (no document header) |
||||
* This is useful when content flows from ZettelEditor to EventInput |
||||
*/ |
||||
export function extractMetadataFromSectionsOnly(content: string): { |
||||
metadata: AsciiDocMetadata; |
||||
content: string; |
||||
} { |
||||
const lines = content.split(/\r?\n/); |
||||
const sections: Array<{ |
||||
metadata: SectionMetadata; |
||||
content: string; |
||||
title: string; |
||||
}> = []; |
||||
|
||||
let currentSection: string | null = null; |
||||
let currentSectionContent: string[] = []; |
||||
|
||||
// Parse sections from the content
|
||||
for (const line of lines) { |
||||
if (line.match(/^==\s+/)) { |
||||
// Save previous section if exists
|
||||
if (currentSection) { |
||||
const sectionContent = currentSectionContent.join('\n'); |
||||
sections.push(extractSectionMetadata(sectionContent)); |
||||
} |
||||
|
||||
// Start new section
|
||||
currentSection = line; |
||||
currentSectionContent = [line]; |
||||
} else if (currentSection) { |
||||
currentSectionContent.push(line); |
||||
} |
||||
} |
||||
|
||||
// Save the last section
|
||||
if (currentSection) { |
||||
const sectionContent = currentSectionContent.join('\n'); |
||||
sections.push(extractSectionMetadata(sectionContent)); |
||||
} |
||||
|
||||
// For section-only content, we don't have document metadata
|
||||
// Return the first section's title as the document title if available
|
||||
const metadata: AsciiDocMetadata = {}; |
||||
if (sections.length > 0 && sections[0].title) { |
||||
metadata.title = sections[0].title; |
||||
} |
||||
|
||||
return { metadata, content }; |
||||
} |
||||
|
||||
/** |
||||
* Smart metadata extraction that handles both document headers and section-only content |
||||
*/ |
||||
export function extractSmartMetadata(content: string): { |
||||
metadata: AsciiDocMetadata; |
||||
content: string; |
||||
} { |
||||
// Check if content has a document header
|
||||
const hasDocumentHeader = content.match(/^=\s+/m); |
||||
|
||||
if (hasDocumentHeader) { |
||||
// Check if it's a minimal document header (just title, no other metadata)
|
||||
const lines = content.split(/\r?\n/); |
||||
const titleLine = lines.find(line => line.match(/^=\s+/)); |
||||
const hasOtherMetadata = lines.some(line =>
|
||||
line.includes('<') || // author line
|
||||
line.match(/^.+,\s*.+:\s*.+$/) // revision line
|
||||
); |
||||
|
||||
if (hasOtherMetadata) { |
||||
// Full document with metadata - use standard extraction
|
||||
return extractDocumentMetadata(content); |
||||
} else { |
||||
// Minimal document header (just title) - preserve the title line for 30040 events
|
||||
const title = titleLine?.replace(/^=\s+/, '').trim(); |
||||
const metadata: AsciiDocMetadata = {}; |
||||
if (title) { |
||||
metadata.title = title; |
||||
} |
||||
|
||||
// Keep the title line in content for 30040 events
|
||||
return { metadata, content }; |
||||
} |
||||
} else { |
||||
return extractMetadataFromSectionsOnly(content); |
||||
} |
||||
}
|
||||
@ -0,0 +1,429 @@
@@ -0,0 +1,429 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
||||
import type { AsciiDocMetadata } from "../../src/lib/utils/asciidoc_metadata"; |
||||
|
||||
// Mock all Svelte components and dependencies
|
||||
vi.mock("flowbite-svelte", () => ({ |
||||
Textarea: vi.fn().mockImplementation((props) => { |
||||
return { |
||||
$$render: () => `<textarea data-testid="textarea" class="${props.class || ''}" rows="${props.rows || 12}" ${props.disabled ? 'disabled' : ''} placeholder="${props.placeholder || ''}"></textarea>`, |
||||
$$bind: { value: props.bind, oninput: props.oninput } |
||||
}; |
||||
}), |
||||
Button: vi.fn().mockImplementation((props) => { |
||||
return { |
||||
$$render: () => `<button data-testid="preview-button" class="${props.class || ''}" ${props.disabled ? 'disabled' : ''} onclick="${props.onclick || ''}">${props.children || ''}</button>`, |
||||
$$bind: { onclick: props.onclick } |
||||
}; |
||||
}) |
||||
})); |
||||
|
||||
vi.mock("flowbite-svelte-icons", () => ({ |
||||
EyeOutline: vi.fn().mockImplementation(() => ({ |
||||
$$render: () => `<svg data-testid="eye-icon"></svg>` |
||||
})) |
||||
})); |
||||
|
||||
vi.mock("asciidoctor", () => ({ |
||||
default: vi.fn(() => ({ |
||||
convert: vi.fn((content, options) => { |
||||
// Mock AsciiDoctor conversion - return simple HTML
|
||||
return content.replace(/^==\s+(.+)$/gm, '<h2>$1</h2>') |
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') |
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>'); |
||||
}) |
||||
})) |
||||
})); |
||||
|
||||
// Mock sessionStorage
|
||||
const mockSessionStorage = { |
||||
getItem: vi.fn(), |
||||
setItem: vi.fn(), |
||||
removeItem: vi.fn(), |
||||
clear: vi.fn(), |
||||
}; |
||||
Object.defineProperty(global, 'sessionStorage', { |
||||
value: mockSessionStorage, |
||||
writable: true |
||||
}); |
||||
|
||||
// Mock window object for DOM manipulation
|
||||
Object.defineProperty(global, 'window', { |
||||
value: { |
||||
sessionStorage: mockSessionStorage, |
||||
document: { |
||||
querySelector: vi.fn(), |
||||
createElement: vi.fn(), |
||||
} |
||||
}, |
||||
writable: true |
||||
}); |
||||
|
||||
// Mock DOM methods
|
||||
const mockQuerySelector = vi.fn(); |
||||
const mockCreateElement = vi.fn(); |
||||
const mockAddEventListener = vi.fn(); |
||||
const mockRemoveEventListener = vi.fn(); |
||||
|
||||
Object.defineProperty(global, 'document', { |
||||
value: { |
||||
querySelector: mockQuerySelector, |
||||
createElement: mockCreateElement, |
||||
addEventListener: mockAddEventListener, |
||||
removeEventListener: mockRemoveEventListener, |
||||
}, |
||||
writable: true |
||||
}); |
||||
|
||||
describe("ZettelEditor Component Logic", () => { |
||||
let mockOnContentChange: ReturnType<typeof vi.fn>; |
||||
let mockOnPreviewToggle: ReturnType<typeof vi.fn>; |
||||
|
||||
beforeEach(() => { |
||||
vi.clearAllMocks(); |
||||
mockOnContentChange = vi.fn(); |
||||
mockOnPreviewToggle = vi.fn(); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
vi.clearAllMocks(); |
||||
}); |
||||
|
||||
describe("Publication Format Detection Logic", () => { |
||||
it("should detect document header format", () => { |
||||
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent"; |
||||
|
||||
// Test the regex pattern used in the component
|
||||
const hasDocumentHeader = contentWithDocumentHeader.match(/^=\s+/m); |
||||
expect(hasDocumentHeader).toBeTruthy(); |
||||
}); |
||||
|
||||
it("should detect index card format", () => { |
||||
const contentWithIndexCard = "index card\n\n== Section 1\nContent"; |
||||
|
||||
// Test the logic used in the component
|
||||
const lines = contentWithIndexCard.split(/\r?\n/); |
||||
let hasIndexCard = false; |
||||
for (const line of lines) { |
||||
if (line.trim().toLowerCase() === 'index card') { |
||||
hasIndexCard = true; |
||||
break; |
||||
} |
||||
} |
||||
expect(hasIndexCard).toBe(true); |
||||
}); |
||||
|
||||
it("should not detect publication format for normal section content", () => { |
||||
const normalContent = "== Section 1\nContent\n\n== Section 2\nMore content"; |
||||
|
||||
// Test the logic used in the component
|
||||
const lines = normalContent.split(/\r?\n/); |
||||
let hasPublicationHeader = false; |
||||
for (const line of lines) { |
||||
if (line.match(/^=\s+(.+)$/)) { |
||||
hasPublicationHeader = true; |
||||
break; |
||||
} |
||||
if (line.trim().toLowerCase() === 'index card') { |
||||
hasPublicationHeader = true; |
||||
break; |
||||
} |
||||
} |
||||
expect(hasPublicationHeader).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe("Content Parsing Logic", () => { |
||||
it("should parse sections with document header", () => { |
||||
const content = "== Section 1\n:author: Test Author\n\nContent 1"; |
||||
|
||||
// Test the parsing logic
|
||||
const hasDocumentHeader = content.match(/^=\s+/m); |
||||
expect(hasDocumentHeader).toBeFalsy(); // This content doesn't have a document header
|
||||
|
||||
// Test section splitting logic
|
||||
const sectionStrings = content.split(/(?=^==\s+)/gm).filter((section: string) => section.trim()); |
||||
expect(sectionStrings).toHaveLength(1); |
||||
expect(sectionStrings[0]).toContain("== Section 1"); |
||||
}); |
||||
|
||||
it("should parse sections without document header", () => { |
||||
const content = "== Section 1\nContent 1"; |
||||
|
||||
// Test the parsing logic
|
||||
const hasDocumentHeader = content.match(/^=\s+/m); |
||||
expect(hasDocumentHeader).toBeFalsy(); |
||||
|
||||
// Test section splitting logic
|
||||
const sectionStrings = content.split(/(?=^==\s+)/gm).filter((section: string) => section.trim()); |
||||
expect(sectionStrings).toHaveLength(1); |
||||
expect(sectionStrings[0]).toContain("== Section 1"); |
||||
}); |
||||
|
||||
it("should handle empty content", () => { |
||||
const content = ""; |
||||
const hasDocumentHeader = content.match(/^=\s+/m); |
||||
expect(hasDocumentHeader).toBeFalsy(); |
||||
}); |
||||
}); |
||||
|
||||
describe("Content Conversion Logic", () => { |
||||
it("should convert document title to section title", () => { |
||||
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent"; |
||||
|
||||
// Test the conversion logic
|
||||
let convertedContent = contentWithDocumentHeader.replace(/^=\s+(.+)$/gm, '== $1'); |
||||
convertedContent = convertedContent.replace(/^index card$/gim, ''); |
||||
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n'); |
||||
|
||||
expect(finalContent).toBe("== Document Title\n\n== Section 1\nContent"); |
||||
}); |
||||
|
||||
it("should remove index card line", () => { |
||||
const contentWithIndexCard = "index card\n\n== Section 1\nContent"; |
||||
|
||||
// Test the conversion logic
|
||||
let convertedContent = contentWithIndexCard.replace(/^=\s+(.+)$/gm, '== $1'); |
||||
convertedContent = convertedContent.replace(/^index card$/gim, ''); |
||||
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n'); |
||||
|
||||
expect(finalContent).toBe("\n\n== Section 1\nContent"); |
||||
}); |
||||
|
||||
it("should clean up double newlines", () => { |
||||
const contentWithExtraNewlines = "= Document Title\n\n\n== Section 1\nContent"; |
||||
|
||||
// Test the conversion logic
|
||||
let convertedContent = contentWithExtraNewlines.replace(/^=\s+(.+)$/gm, '== $1'); |
||||
convertedContent = convertedContent.replace(/^index card$/gim, ''); |
||||
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n'); |
||||
|
||||
expect(finalContent).toBe("== Document Title\n\n== Section 1\nContent"); |
||||
}); |
||||
}); |
||||
|
||||
describe("SessionStorage Integration", () => { |
||||
it("should store content in sessionStorage when switching to publication editor", () => { |
||||
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent"; |
||||
|
||||
// Test the sessionStorage logic
|
||||
mockSessionStorage.setItem('zettelEditorContent', contentWithDocumentHeader); |
||||
mockSessionStorage.setItem('zettelEditorSource', 'publication-format'); |
||||
|
||||
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('zettelEditorContent', contentWithDocumentHeader); |
||||
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('zettelEditorSource', 'publication-format'); |
||||
}); |
||||
}); |
||||
|
||||
describe("Event Count Logic", () => { |
||||
it("should calculate correct event count for single section", () => { |
||||
const sections = [{ title: "Section 1", content: "Content 1", tags: [] }]; |
||||
const eventCount = sections.length; |
||||
const eventText = `${eventCount} event${eventCount !== 1 ? "s" : ""}`; |
||||
|
||||
expect(eventCount).toBe(1); |
||||
expect(eventText).toBe("1 event"); |
||||
}); |
||||
|
||||
it("should calculate correct event count for multiple sections", () => { |
||||
const sections = [ |
||||
{ title: "Section 1", content: "Content 1", tags: [] }, |
||||
{ title: "Section 2", content: "Content 2", tags: [] } |
||||
]; |
||||
const eventCount = sections.length; |
||||
const eventText = `${eventCount} event${eventCount !== 1 ? "s" : ""}`; |
||||
|
||||
expect(eventCount).toBe(2); |
||||
expect(eventText).toBe("2 events"); |
||||
}); |
||||
}); |
||||
|
||||
describe("Tag Processing Logic", () => { |
||||
it("should process tags correctly", () => { |
||||
// Mock the metadataToTags function
|
||||
const mockMetadataToTags = vi.fn().mockReturnValue([["author", "Test Author"]]); |
||||
|
||||
const mockMetadata = { title: "Section 1", author: "Test Author" } as AsciiDocMetadata; |
||||
const tags = mockMetadataToTags(mockMetadata); |
||||
|
||||
expect(tags).toEqual([["author", "Test Author"]]); |
||||
expect(mockMetadataToTags).toHaveBeenCalledWith(mockMetadata); |
||||
}); |
||||
|
||||
it("should handle empty tags", () => { |
||||
// Mock the metadataToTags function
|
||||
const mockMetadataToTags = vi.fn().mockReturnValue([]); |
||||
|
||||
const mockMetadata = { title: "Section 1" } as AsciiDocMetadata; |
||||
const tags = mockMetadataToTags(mockMetadata); |
||||
|
||||
expect(tags).toEqual([]); |
||||
}); |
||||
}); |
||||
|
||||
describe("AsciiDoctor Processing", () => { |
||||
it("should process AsciiDoc content correctly", () => { |
||||
// Mock the asciidoctor conversion
|
||||
const mockConvert = vi.fn((content, options) => { |
||||
return content.replace(/^==\s+(.+)$/gm, '<h2>$1</h2>') |
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') |
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>'); |
||||
}); |
||||
|
||||
const content = "== Test Section\n\nThis is **bold** and *italic* text."; |
||||
const processedContent = mockConvert(content, { |
||||
standalone: false, |
||||
doctype: "article", |
||||
attributes: { |
||||
showtitle: true, |
||||
sectids: true, |
||||
}, |
||||
}); |
||||
|
||||
expect(processedContent).toContain('<h2>Test Section</h2>'); |
||||
expect(processedContent).toContain('<strong>bold</strong>'); |
||||
expect(processedContent).toContain('<em>italic</em>'); |
||||
}); |
||||
}); |
||||
|
||||
describe("Error Handling", () => { |
||||
it("should handle parsing errors gracefully", () => { |
||||
// Mock a function that might throw an error
|
||||
const mockParseFunction = vi.fn().mockImplementation(() => { |
||||
throw new Error("Parsing error"); |
||||
}); |
||||
|
||||
const content = "== Section 1\nContent 1"; |
||||
|
||||
// Should not throw error when called
|
||||
expect(() => { |
||||
try { |
||||
mockParseFunction(content); |
||||
} catch (error) { |
||||
// Expected error, but should be handled gracefully
|
||||
} |
||||
}).not.toThrow(); |
||||
}); |
||||
|
||||
it("should handle empty content without errors", () => { |
||||
const content = ""; |
||||
const hasDocumentHeader = content.match(/^=\s+/m); |
||||
expect(hasDocumentHeader).toBeFalsy(); |
||||
}); |
||||
}); |
||||
|
||||
describe("Component Props Interface", () => { |
||||
it("should have correct prop types", () => { |
||||
// Test that the component props interface is correctly defined
|
||||
const expectedProps = { |
||||
content: "", |
||||
placeholder: "Default placeholder", |
||||
showPreview: false, |
||||
onContentChange: vi.fn(), |
||||
onPreviewToggle: vi.fn(), |
||||
}; |
||||
|
||||
expect(expectedProps).toHaveProperty('content'); |
||||
expect(expectedProps).toHaveProperty('placeholder'); |
||||
expect(expectedProps).toHaveProperty('showPreview'); |
||||
expect(expectedProps).toHaveProperty('onContentChange'); |
||||
expect(expectedProps).toHaveProperty('onPreviewToggle'); |
||||
}); |
||||
}); |
||||
|
||||
describe("Utility Function Integration", () => { |
||||
it("should integrate with ZettelParser utilities", () => { |
||||
// Mock the parseAsciiDocSections function
|
||||
const mockParseAsciiDocSections = vi.fn().mockReturnValue([ |
||||
{ title: "Section 1", content: "Content 1", tags: [] } |
||||
]); |
||||
|
||||
const content = "== Section 1\nContent 1"; |
||||
const sections = mockParseAsciiDocSections(content, 2); |
||||
|
||||
expect(sections).toHaveLength(1); |
||||
expect(sections[0].title).toBe("Section 1"); |
||||
}); |
||||
|
||||
it("should integrate with asciidoc_metadata utilities", () => { |
||||
// Mock the utility functions
|
||||
const mockExtractDocumentMetadata = vi.fn().mockReturnValue({ |
||||
metadata: { title: "Document Title" } as AsciiDocMetadata, |
||||
content: "Document content" |
||||
}); |
||||
|
||||
const mockExtractSectionMetadata = vi.fn().mockReturnValue({ |
||||
metadata: { title: "Section Title" } as AsciiDocMetadata, |
||||
content: "Section content", |
||||
title: "Section Title" |
||||
}); |
||||
|
||||
const documentContent = "= Document Title\nDocument content"; |
||||
const sectionContent = "== Section Title\nSection content"; |
||||
|
||||
const documentResult = mockExtractDocumentMetadata(documentContent); |
||||
const sectionResult = mockExtractSectionMetadata(sectionContent); |
||||
|
||||
expect(documentResult.metadata.title).toBe("Document Title"); |
||||
expect(sectionResult.title).toBe("Section Title"); |
||||
}); |
||||
}); |
||||
|
||||
describe("Content Validation", () => { |
||||
it("should validate content structure", () => { |
||||
const validContent = "== Section 1\nContent here\n\n== Section 2\nMore content"; |
||||
const invalidContent = "Just some text without sections"; |
||||
|
||||
// Test section detection
|
||||
const validSections = validContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim()); |
||||
const invalidSections = invalidContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim()); |
||||
|
||||
expect(validSections.length).toBeGreaterThan(0); |
||||
// The invalid content will have one section (the entire content) since it doesn't start with ==
|
||||
expect(invalidSections.length).toBe(1); |
||||
}); |
||||
|
||||
it("should handle mixed content types", () => { |
||||
const mixedContent = "= Document Title\n\n== Section 1\nContent\n\n== Section 2\nMore content"; |
||||
|
||||
// Test document header detection
|
||||
const hasDocumentHeader = mixedContent.match(/^=\s+/m); |
||||
expect(hasDocumentHeader).toBeTruthy(); |
||||
|
||||
// Test section extraction
|
||||
const sections = mixedContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim()); |
||||
expect(sections.length).toBeGreaterThan(0); |
||||
}); |
||||
}); |
||||
|
||||
describe("String Manipulation", () => { |
||||
it("should handle string replacements correctly", () => { |
||||
const originalContent = "= Title\n\n== Section\nContent"; |
||||
|
||||
// Test various string manipulations
|
||||
const convertedContent = originalContent |
||||
.replace(/^=\s+(.+)$/gm, '== $1') |
||||
.replace(/^index card$/gim, '') |
||||
.replace(/\n\s*\n\s*\n/g, '\n\n'); |
||||
|
||||
expect(convertedContent).toBe("== Title\n\n== Section\nContent"); |
||||
}); |
||||
|
||||
it("should handle edge cases in string manipulation", () => { |
||||
const edgeCases = [ |
||||
"= Title\n\n\n== Section\nContent", // Multiple newlines
|
||||
"index card\n\n== Section\nContent", // Index card
|
||||
"= Title\nindex card\n== Section\nContent", // Both
|
||||
]; |
||||
|
||||
edgeCases.forEach(content => { |
||||
const converted = content |
||||
.replace(/^=\s+(.+)$/gm, '== $1') |
||||
.replace(/^index card$/gim, '') |
||||
.replace(/\n\s*\n\s*\n/g, '\n\n'); |
||||
|
||||
expect(converted).toBeDefined(); |
||||
expect(typeof converted).toBe('string'); |
||||
}); |
||||
}); |
||||
}); |
||||
});
|
||||
@ -0,0 +1,446 @@
@@ -0,0 +1,446 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"; |
||||
import { build30040EventSet, validate30040EventSet } from "../../src/lib/utils/event_input_utils"; |
||||
import { extractDocumentMetadata, parseAsciiDocWithMetadata } from "../../src/lib/utils/asciidoc_metadata"; |
||||
|
||||
// Mock NDK and other dependencies
|
||||
vi.mock("@nostr-dev-kit/ndk", () => ({ |
||||
NDKEvent: vi.fn().mockImplementation((ndk, eventData) => ({ |
||||
...eventData, |
||||
id: "mock-event-id", |
||||
sig: "mock-signature", |
||||
kind: eventData.kind, |
||||
content: eventData.content, |
||||
tags: eventData.tags, |
||||
pubkey: eventData.pubkey, |
||||
created_at: eventData.created_at, |
||||
})), |
||||
})); |
||||
|
||||
vi.mock("../../src/lib/ndk", () => ({ |
||||
ndkInstance: { |
||||
subscribe: vi.fn(), |
||||
}, |
||||
getNdk: vi.fn(() => ({})), |
||||
})); |
||||
|
||||
vi.mock("svelte/store", () => ({ |
||||
get: vi.fn(() => ({})), |
||||
})); |
||||
|
||||
describe("EventInput 30040 Publishing", () => { |
||||
const baseEvent = { |
||||
pubkey: "test-pubkey", |
||||
created_at: 1234567890, |
||||
}; |
||||
|
||||
beforeEach(() => { |
||||
vi.clearAllMocks(); |
||||
}); |
||||
|
||||
describe("Normal Structure with Preamble", () => { |
||||
it("should build 30040 event set with preamble content", () => { |
||||
const content = `= Test Document with Preamble
|
||||
John Doe <john@example.com> |
||||
1.0, 2024-01-15, Alexandria Test |
||||
:summary: This is a test document with preamble |
||||
:keywords: test, preamble, asciidoc |
||||
|
||||
This is the preamble content that should be included. |
||||
|
||||
== First Section |
||||
:author: Section Author |
||||
:summary: This is the first section |
||||
|
||||
This is the content of the first section. |
||||
|
||||
== Second Section |
||||
:summary: This is the second section |
||||
|
||||
This is the content of the second section.`;
|
||||
|
||||
const tags: [string, string][] = [["type", "article"]]; |
||||
|
||||
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||
|
||||
// Test index event
|
||||
expect(indexEvent.kind).toBe(30040); |
||||
expect(indexEvent.content).toBe(""); |
||||
expect(indexEvent.tags).toContainEqual(["d", "test-document-with-preamble"]); |
||||
expect(indexEvent.tags).toContainEqual(["title", "Test Document with Preamble"]); |
||||
expect(indexEvent.tags).toContainEqual(["author", "John Doe"]); |
||||
expect(indexEvent.tags).toContainEqual(["version", "1.0"]); |
||||
expect(indexEvent.tags).toContainEqual(["summary", "This is a test document with preamble"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "test"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "preamble"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "asciidoc"]); |
||||
expect(indexEvent.tags).toContainEqual(["type", "article"]); |
||||
|
||||
// Test section events
|
||||
expect(sectionEvents).toHaveLength(2); |
||||
|
||||
// First section
|
||||
expect(sectionEvents[0].kind).toBe(30041); |
||||
expect(sectionEvents[0].content).toBe("This is the content of the first section."); |
||||
expect(sectionEvents[0].tags).toContainEqual(["d", "test-document-with-preamble-first-section"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["title", "First Section"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["summary", "This is the first section"]); |
||||
|
||||
// Second section
|
||||
expect(sectionEvents[1].kind).toBe(30041); |
||||
expect(sectionEvents[1].content).toBe("This is the content of the second section."); |
||||
expect(sectionEvents[1].tags).toContainEqual(["d", "test-document-with-preamble-second-section"]); |
||||
expect(sectionEvents[1].tags).toContainEqual(["title", "Second Section"]); |
||||
expect(sectionEvents[1].tags).toContainEqual(["summary", "This is the second section"]); |
||||
|
||||
// Test a-tags in index event
|
||||
expect(indexEvent.tags).toContainEqual(["a", "30041:test-pubkey:test-document-with-preamble-first-section"]); |
||||
expect(indexEvent.tags).toContainEqual(["a", "30041:test-pubkey:test-document-with-preamble-second-section"]); |
||||
}); |
||||
}); |
||||
|
||||
describe("Normal Structure without Preamble", () => { |
||||
it("should build 30040 event set without preamble content", () => { |
||||
const content = `= Test Document without Preamble
|
||||
:summary: This is a test document without preamble |
||||
:keywords: test, no-preamble, asciidoc |
||||
|
||||
== First Section |
||||
:author: Section Author |
||||
:summary: This is the first section |
||||
|
||||
This is the content of the first section. |
||||
|
||||
== Second Section |
||||
:summary: This is the second section |
||||
|
||||
This is the content of the second section.`;
|
||||
|
||||
const tags: [string, string][] = [["type", "article"]]; |
||||
|
||||
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||
|
||||
// Test index event
|
||||
expect(indexEvent.kind).toBe(30040); |
||||
expect(indexEvent.content).toBe(""); |
||||
expect(indexEvent.tags).toContainEqual(["d", "test-document-without-preamble"]); |
||||
expect(indexEvent.tags).toContainEqual(["title", "Test Document without Preamble"]); |
||||
expect(indexEvent.tags).toContainEqual(["summary", "This is a test document without preamble"]); |
||||
|
||||
// Test section events
|
||||
expect(sectionEvents).toHaveLength(2); |
||||
|
||||
// First section
|
||||
expect(sectionEvents[0].kind).toBe(30041); |
||||
expect(sectionEvents[0].content).toBe("This is the content of the first section."); |
||||
expect(sectionEvents[0].tags).toContainEqual(["d", "test-document-without-preamble-first-section"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["title", "First Section"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["summary", "This is the first section"]); |
||||
|
||||
// Second section
|
||||
expect(sectionEvents[1].kind).toBe(30041); |
||||
expect(sectionEvents[1].content).toBe("This is the content of the second section."); |
||||
expect(sectionEvents[1].tags).toContainEqual(["d", "test-document-without-preamble-second-section"]); |
||||
expect(sectionEvents[1].tags).toContainEqual(["title", "Second Section"]); |
||||
expect(sectionEvents[1].tags).toContainEqual(["summary", "This is the second section"]); |
||||
}); |
||||
}); |
||||
|
||||
describe("Skeleton Structure with Preamble", () => { |
||||
it("should build 30040 event set with skeleton structure and preamble", () => { |
||||
const content = `= Skeleton Document with Preamble
|
||||
:summary: This is a skeleton document with preamble |
||||
:keywords: skeleton, preamble, empty |
||||
|
||||
This is the preamble content. |
||||
|
||||
== Empty Section 1 |
||||
|
||||
== Empty Section 2 |
||||
|
||||
== Empty Section 3`;
|
||||
|
||||
const tags: [string, string][] = [["type", "skeleton"]]; |
||||
|
||||
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||
|
||||
// Test index event
|
||||
expect(indexEvent.kind).toBe(30040); |
||||
expect(indexEvent.content).toBe(""); |
||||
expect(indexEvent.tags).toContainEqual(["d", "skeleton-document-with-preamble"]); |
||||
expect(indexEvent.tags).toContainEqual(["title", "Skeleton Document with Preamble"]); |
||||
expect(indexEvent.tags).toContainEqual(["summary", "This is a skeleton document with preamble"]); |
||||
|
||||
// Test section events
|
||||
expect(sectionEvents).toHaveLength(3); |
||||
|
||||
// All sections should have empty content
|
||||
sectionEvents.forEach((section, index) => { |
||||
expect(section.kind).toBe(30041); |
||||
expect(section.content).toBe(""); |
||||
expect(section.tags).toContainEqual(["d", `skeleton-document-with-preamble-empty-section-${index + 1}`]); |
||||
expect(section.tags).toContainEqual(["title", `Empty Section ${index + 1}`]); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe("Skeleton Structure without Preamble", () => { |
||||
it("should build 30040 event set with skeleton structure without preamble", () => { |
||||
const content = `= Skeleton Document without Preamble
|
||||
:summary: This is a skeleton document without preamble |
||||
:keywords: skeleton, no-preamble, empty |
||||
|
||||
== Empty Section 1 |
||||
|
||||
== Empty Section 2 |
||||
|
||||
== Empty Section 3`;
|
||||
|
||||
const tags: [string, string][] = [["type", "skeleton"]]; |
||||
|
||||
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||
|
||||
// Test index event
|
||||
expect(indexEvent.kind).toBe(30040); |
||||
expect(indexEvent.content).toBe(""); |
||||
expect(indexEvent.tags).toContainEqual(["d", "skeleton-document-without-preamble"]); |
||||
expect(indexEvent.tags).toContainEqual(["title", "Skeleton Document without Preamble"]); |
||||
expect(indexEvent.tags).toContainEqual(["summary", "This is a skeleton document without preamble"]); |
||||
|
||||
// Test section events
|
||||
expect(sectionEvents).toHaveLength(3); |
||||
|
||||
// All sections should have empty content
|
||||
sectionEvents.forEach((section, index) => { |
||||
expect(section.kind).toBe(30041); |
||||
expect(section.content).toBe(""); |
||||
expect(section.tags).toContainEqual(["d", `skeleton-document-without-preamble-empty-section-${index + 1}`]); |
||||
expect(section.tags).toContainEqual(["title", `Empty Section ${index + 1}`]); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe("Index Card Format", () => { |
||||
it("should build 30040 event set for index card format", () => { |
||||
const content = `= Test Index Card
|
||||
index card`;
|
||||
|
||||
const tags: [string, string][] = [["type", "index-card"]]; |
||||
|
||||
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||
|
||||
// Test index event
|
||||
expect(indexEvent.kind).toBe(30040); |
||||
expect(indexEvent.content).toBe(""); |
||||
expect(indexEvent.tags).toContainEqual(["d", "test-index-card"]); |
||||
expect(indexEvent.tags).toContainEqual(["title", "Test Index Card"]); |
||||
expect(indexEvent.tags).toContainEqual(["type", "index-card"]); |
||||
|
||||
// Should have no section events for index card
|
||||
expect(sectionEvents).toHaveLength(0); |
||||
}); |
||||
|
||||
it("should build 30040 event set for index card with metadata", () => { |
||||
const content = `= Test Index Card with Metadata
|
||||
:summary: This is an index card with metadata |
||||
:keywords: index, card, metadata |
||||
index card`;
|
||||
|
||||
const tags: [string, string][] = [["type", "index-card"]]; |
||||
|
||||
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||
|
||||
// Test index event
|
||||
expect(indexEvent.kind).toBe(30040); |
||||
expect(indexEvent.content).toBe(""); |
||||
expect(indexEvent.tags).toContainEqual(["d", "test-index-card-with-metadata"]); |
||||
expect(indexEvent.tags).toContainEqual(["title", "Test Index Card with Metadata"]); |
||||
expect(indexEvent.tags).toContainEqual(["summary", "This is an index card with metadata"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "index"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "card"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "metadata"]); |
||||
expect(indexEvent.tags).toContainEqual(["type", "index-card"]); |
||||
|
||||
// Should have no section events for index card
|
||||
expect(sectionEvents).toHaveLength(0); |
||||
}); |
||||
}); |
||||
|
||||
describe("Complex Metadata Structures", () => { |
||||
it("should handle complex metadata with all attribute types", () => { |
||||
const content = `= Complex Metadata Document
|
||||
Jane Smith <jane@example.com> |
||||
2.0, 2024-02-20, Alexandria Complex |
||||
:summary: This is a complex document with all metadata types |
||||
:description: Alternative description field |
||||
:keywords: complex, metadata, all-types |
||||
:tags: additional, tags, here |
||||
:author: Override Author |
||||
:author: Third Author |
||||
:version: 3.0 |
||||
:published_on: 2024-03-01 |
||||
:published_by: Alexandria Complex |
||||
:type: book |
||||
:image: https://example.com/cover.jpg
|
||||
:isbn: 978-0-123456-78-9 |
||||
:source: https://github.com/alexandria/complex
|
||||
:auto-update: yes |
||||
|
||||
This is the preamble content. |
||||
|
||||
== Section with Complex Metadata |
||||
:author: Section Author |
||||
:author: Section Co-Author |
||||
:summary: This section has complex metadata |
||||
:description: Alternative description for section |
||||
:keywords: section, complex, metadata |
||||
:tags: section, tags |
||||
:type: chapter |
||||
:image: https://example.com/section-image.jpg
|
||||
|
||||
This is the section content.`;
|
||||
|
||||
const tags: [string, string][] = [["type", "complex"]]; |
||||
|
||||
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||
|
||||
// Test index event metadata
|
||||
expect(indexEvent.kind).toBe(30040); |
||||
expect(indexEvent.tags).toContainEqual(["d", "complex-metadata-document"]); |
||||
expect(indexEvent.tags).toContainEqual(["title", "Complex Metadata Document"]); |
||||
expect(indexEvent.tags).toContainEqual(["author", "Jane Smith"]); // Should use header line author
|
||||
expect(indexEvent.tags).toContainEqual(["author", "Override Author"]); // Additional author from attribute
|
||||
expect(indexEvent.tags).toContainEqual(["author", "Third Author"]); // Additional author from attribute
|
||||
expect(indexEvent.tags).toContainEqual(["version", "2.0"]); // Should use revision line version
|
||||
expect(indexEvent.tags).toContainEqual(["summary", "This is a complex document with all metadata types Alternative description field"]); |
||||
expect(indexEvent.tags).toContainEqual(["published_on", "2024-03-01"]); |
||||
expect(indexEvent.tags).toContainEqual(["published_by", "Alexandria Complex"]); |
||||
expect(indexEvent.tags).toContainEqual(["type", "book"]); |
||||
expect(indexEvent.tags).toContainEqual(["image", "https://example.com/cover.jpg"]); |
||||
expect(indexEvent.tags).toContainEqual(["i", "978-0-123456-78-9"]); |
||||
expect(indexEvent.tags).toContainEqual(["source", "https://github.com/alexandria/complex"]); |
||||
expect(indexEvent.tags).toContainEqual(["auto-update", "yes"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "complex"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "metadata"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "all-types"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "additional"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "tags"]); |
||||
expect(indexEvent.tags).toContainEqual(["t", "here"]); |
||||
|
||||
// Test section metadata
|
||||
expect(sectionEvents).toHaveLength(1); |
||||
expect(sectionEvents[0].kind).toBe(30041); |
||||
expect(sectionEvents[0].content).toBe("This is the section content."); |
||||
expect(sectionEvents[0].tags).toContainEqual(["d", "complex-metadata-document-section-with-complex-metadata"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["title", "Section with Complex Metadata"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Co-Author"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["summary", "This section has complex metadata Alternative description for section"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["type", "chapter"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["image", "https://example.com/section-image.jpg"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["t", "section"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["t", "complex"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["t", "metadata"]); |
||||
expect(sectionEvents[0].tags).toContainEqual(["t", "tags"]); |
||||
}); |
||||
}); |
||||
|
||||
describe("Validation Tests", () => { |
||||
it("should validate normal structure correctly", () => { |
||||
const content = `= Valid Document
|
||||
:summary: This is a valid document |
||||
|
||||
== Section 1 |
||||
|
||||
Content here. |
||||
|
||||
== Section 2 |
||||
|
||||
More content.`;
|
||||
|
||||
const validation = validate30040EventSet(content); |
||||
expect(validation.valid).toBe(true); |
||||
}); |
||||
|
||||
it("should validate index card format correctly", () => { |
||||
const content = `= Valid Index Card
|
||||
index card`;
|
||||
|
||||
const validation = validate30040EventSet(content); |
||||
expect(validation.valid).toBe(true); |
||||
}); |
||||
|
||||
it("should validate skeleton structure correctly", () => { |
||||
const content = `= Skeleton Document
|
||||
|
||||
== Empty Section 1 |
||||
|
||||
== Empty Section 2`;
|
||||
|
||||
const validation = validate30040EventSet(content); |
||||
expect(validation.valid).toBe(true); |
||||
}); |
||||
|
||||
it("should reject invalid structure", () => { |
||||
const content = `This is not a valid AsciiDoc document.`; |
||||
|
||||
const validation = validate30040EventSet(content); |
||||
expect(validation.valid).toBe(false); |
||||
expect(validation.reason).toContain("30040 events must have a document title"); |
||||
}); |
||||
}); |
||||
|
||||
describe("Edge Cases", () => { |
||||
it("should handle document with only title and no sections", () => { |
||||
const content = `= Document with No Sections
|
||||
:summary: This document has no sections |
||||
|
||||
This is just preamble content.`;
|
||||
|
||||
const tags: [string, string][] = []; |
||||
|
||||
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||
|
||||
expect(indexEvent.kind).toBe(30040); |
||||
expect(indexEvent.tags).toContainEqual(["d", "document-with-no-sections"]); |
||||
expect(indexEvent.tags).toContainEqual(["title", "Document with No Sections"]); |
||||
expect(sectionEvents).toHaveLength(0); |
||||
}); |
||||
|
||||
it("should handle document with special characters in title", () => { |
||||
const content = `= Document with Special Characters: Test & More!
|
||||
:summary: This document has special characters in the title |
||||
|
||||
== Section 1 |
||||
|
||||
Content here.`;
|
||||
|
||||
const tags: [string, string][] = []; |
||||
|
||||
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||
|
||||
expect(indexEvent.kind).toBe(30040); |
||||
expect(indexEvent.tags).toContainEqual(["d", "document-with-special-characters-test-more"]); |
||||
expect(indexEvent.tags).toContainEqual(["title", "Document with Special Characters: Test & More!"]); |
||||
expect(sectionEvents).toHaveLength(1); |
||||
}); |
||||
|
||||
it("should handle document with very long title", () => { |
||||
const content = `= This is a very long document title that should be handled properly by the system and should not cause any issues with the d-tag generation or any other functionality
|
||||
:summary: This document has a very long title |
||||
|
||||
== Section 1 |
||||
|
||||
Content here.`;
|
||||
|
||||
const tags: [string, string][] = []; |
||||
|
||||
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||
|
||||
expect(indexEvent.kind).toBe(30040); |
||||
expect(indexEvent.tags).toContainEqual(["title", "This is a very long document title that should be handled properly by the system and should not cause any issues with the d-tag generation or any other functionality"]); |
||||
expect(sectionEvents).toHaveLength(1); |
||||
}); |
||||
}); |
||||
});
|
||||
@ -0,0 +1,322 @@
@@ -0,0 +1,322 @@
|
||||
import { describe, it, expect } from "vitest"; |
||||
import {
|
||||
extractDocumentMetadata,
|
||||
extractSectionMetadata,
|
||||
parseAsciiDocWithMetadata, |
||||
metadataToTags, |
||||
extractSmartMetadata |
||||
} from "../../src/lib/utils/asciidoc_metadata.ts"; |
||||
|
||||
describe("AsciiDoc Metadata Extraction", () => { |
||||
const testContent = `= Test Document with Metadata
|
||||
John Doe <john@example.com> |
||||
1.0, 2024-01-15: Alexandria Test |
||||
:summary: This is a test document for metadata extraction |
||||
:author: Jane Smith |
||||
:published_on: 2024-01-15 |
||||
:published_by: Alexandria Project |
||||
:type: article |
||||
:keywords: test, metadata, asciidoc |
||||
:image: https://example.com/cover.jpg
|
||||
:isbn: 978-0-123456-78-9 |
||||
:source: https://github.com/alexandria/test
|
||||
:auto-update: yes |
||||
|
||||
This is the preamble content that should be included in the document body. |
||||
|
||||
== First Section |
||||
:author: Section Author |
||||
:summary: This is the first section |
||||
:keywords: section1, content |
||||
|
||||
This is the content of the first section. |
||||
|
||||
== Second Section |
||||
:summary: This is the second section |
||||
:type: chapter |
||||
|
||||
This is the content of the second section.`;
|
||||
|
||||
it("extractDocumentMetadata should extract document metadata correctly", () => { |
||||
const { metadata, content } = extractDocumentMetadata(testContent); |
||||
|
||||
expect(metadata.title).toBe("Test Document with Metadata"); |
||||
expect(metadata.authors).toEqual(["John Doe", "Jane Smith"]); |
||||
expect(metadata.version).toBe("1.0"); |
||||
expect(metadata.publicationDate).toBe("2024-01-15"); |
||||
expect(metadata.publishedBy).toBe("Alexandria Test"); |
||||
expect(metadata.summary).toBe("This is a test document for metadata extraction"); |
||||
expect(metadata.authors).toEqual(["John Doe", "Jane Smith"]); |
||||
expect(metadata.type).toBe("article"); |
||||
expect(metadata.tags).toEqual(["test", "metadata", "asciidoc"]); |
||||
expect(metadata.coverImage).toBe("https://example.com/cover.jpg"); |
||||
expect(metadata.isbn).toBe("978-0-123456-78-9"); |
||||
expect(metadata.source).toBe("https://github.com/alexandria/test"); |
||||
expect(metadata.autoUpdate).toBe("yes"); |
||||
|
||||
// Content should not include the header metadata
|
||||
expect(content).toContain("This is the preamble content"); |
||||
expect(content).toContain("== First Section"); |
||||
expect(content).not.toContain("= Test Document with Metadata"); |
||||
expect(content).not.toContain(":summary:"); |
||||
}); |
||||
|
||||
it("extractSectionMetadata should extract section metadata correctly", () => { |
||||
const sectionContent = `== First Section
|
||||
:author: Section Author |
||||
:description: This is the first section |
||||
:tags: section1, content |
||||
|
||||
This is the content of the first section.`;
|
||||
|
||||
const { metadata, content, title } = extractSectionMetadata(sectionContent); |
||||
|
||||
expect(title).toBe("First Section"); |
||||
expect(metadata.authors).toEqual(["Section Author"]); |
||||
expect(metadata.summary).toBe("This is the first section"); |
||||
expect(metadata.tags).toEqual(["section1", "content"]); |
||||
expect(content).toBe("This is the content of the first section."); |
||||
}); |
||||
|
||||
it("extractSectionMetadata should extract standalone author names and remove them from content", () => { |
||||
const sectionContent = `== Section Header1
|
||||
Stella |
||||
:description: Some summary |
||||
|
||||
Some context text`;
|
||||
|
||||
const { metadata, content, title } = extractSectionMetadata(sectionContent); |
||||
|
||||
expect(title).toBe("Section Header1"); |
||||
expect(metadata.authors).toEqual(["Stella"]); |
||||
expect(metadata.summary).toBe("Some summary"); |
||||
expect(content.trim()).toBe("Some context text"); |
||||
}); |
||||
|
||||
it("extractSectionMetadata should handle multiple standalone author names", () => { |
||||
const sectionContent = `== Section Header1
|
||||
Stella |
||||
:author: John Doe |
||||
:description: Some summary |
||||
|
||||
Some context text`;
|
||||
|
||||
const { metadata, content, title } = extractSectionMetadata(sectionContent); |
||||
|
||||
expect(title).toBe("Section Header1"); |
||||
expect(metadata.authors).toEqual(["Stella", "John Doe"]); |
||||
expect(metadata.summary).toBe("Some summary"); |
||||
expect(content.trim()).toBe("Some context text"); |
||||
}); |
||||
|
||||
it("extractSectionMetadata should not extract non-author lines as authors", () => { |
||||
const sectionContent = `== Section Header1
|
||||
Stella |
||||
This is not an author line |
||||
:description: Some summary |
||||
|
||||
Some context text`;
|
||||
|
||||
const { metadata, content, title } = extractSectionMetadata(sectionContent); |
||||
|
||||
expect(title).toBe("Section Header1"); |
||||
expect(metadata.authors).toEqual(["Stella"]); |
||||
expect(metadata.summary).toBe("Some summary"); |
||||
expect(content.trim()).toBe("This is not an author line\nSome context text"); |
||||
}); |
||||
|
||||
it("parseAsciiDocWithMetadata should parse complete document", () => { |
||||
const parsed = parseAsciiDocWithMetadata(testContent); |
||||
|
||||
expect(parsed.metadata.title).toBe("Test Document with Metadata"); |
||||
expect(parsed.sections).toHaveLength(2); |
||||
expect(parsed.sections[0].title).toBe("First Section"); |
||||
expect(parsed.sections[1].title).toBe("Second Section"); |
||||
expect(parsed.sections[0].metadata.authors).toEqual(["Section Author"]); |
||||
expect(parsed.sections[1].metadata.summary).toBe("This is the second section"); |
||||
}); |
||||
|
||||
it("metadataToTags should convert metadata to Nostr tags", () => { |
||||
const metadata = { |
||||
title: "Test Title", |
||||
authors: ["Author 1", "Author 2"], |
||||
version: "1.0", |
||||
summary: "Test summary", |
||||
tags: ["tag1", "tag2"] |
||||
}; |
||||
|
||||
const tags = metadataToTags(metadata); |
||||
|
||||
expect(tags).toContainEqual(["title", "Test Title"]); |
||||
expect(tags).toContainEqual(["author", "Author 1"]); |
||||
expect(tags).toContainEqual(["author", "Author 2"]); |
||||
expect(tags).toContainEqual(["version", "1.0"]); |
||||
expect(tags).toContainEqual(["summary", "Test summary"]); |
||||
expect(tags).toContainEqual(["t", "tag1"]); |
||||
expect(tags).toContainEqual(["t", "tag2"]); |
||||
}); |
||||
|
||||
it("should handle index card format correctly", () => { |
||||
const indexCardContent = `= Test Index Card
|
||||
index card`;
|
||||
|
||||
const { metadata, content } = extractDocumentMetadata(indexCardContent); |
||||
|
||||
expect(metadata.title).toBe("Test Index Card"); |
||||
expect(content.trim()).toBe("index card"); |
||||
}); |
||||
|
||||
it("should handle empty content gracefully", () => { |
||||
const emptyContent = ""; |
||||
|
||||
const { metadata, content } = extractDocumentMetadata(emptyContent); |
||||
|
||||
expect(metadata.title).toBeUndefined(); |
||||
expect(content).toBe(""); |
||||
}); |
||||
|
||||
it("should handle keywords as tags", () => { |
||||
const contentWithKeywords = `= Test Document
|
||||
:keywords: keyword1, keyword2, keyword3 |
||||
|
||||
Some content here.`;
|
||||
|
||||
const { metadata } = extractDocumentMetadata(contentWithKeywords); |
||||
|
||||
expect(metadata.tags).toEqual(["keyword1", "keyword2", "keyword3"]); |
||||
}); |
||||
|
||||
it("should handle both tags and keywords", () => { |
||||
const contentWithBoth = `= Test Document
|
||||
:tags: tag1, tag2 |
||||
:keywords: keyword1, keyword2 |
||||
|
||||
Some content here.`;
|
||||
|
||||
const { metadata } = extractDocumentMetadata(contentWithBoth); |
||||
|
||||
// Both tags and keywords are valid, both should be accumulated
|
||||
expect(metadata.tags).toEqual(["tag1", "tag2", "keyword1", "keyword2"]); |
||||
}); |
||||
|
||||
it("should handle tags only", () => { |
||||
const contentWithTags = `= Test Document
|
||||
:tags: tag1, tag2, tag3 |
||||
|
||||
Content here.`;
|
||||
|
||||
const { metadata } = extractDocumentMetadata(contentWithTags); |
||||
|
||||
expect(metadata.tags).toEqual(["tag1", "tag2", "tag3"]); |
||||
}); |
||||
|
||||
it("should handle both summary and description", () => { |
||||
const contentWithSummary = `= Test Document
|
||||
:summary: This is a summary |
||||
|
||||
Content here.`;
|
||||
|
||||
const contentWithDescription = `= Test Document
|
||||
:description: This is a description |
||||
|
||||
Content here.`;
|
||||
|
||||
const { metadata: summaryMetadata } = extractDocumentMetadata(contentWithSummary); |
||||
const { metadata: descriptionMetadata } = extractDocumentMetadata(contentWithDescription); |
||||
|
||||
expect(summaryMetadata.summary).toBe("This is a summary"); |
||||
expect(descriptionMetadata.summary).toBe("This is a description"); |
||||
}); |
||||
|
||||
describe('Smart metadata extraction', () => { |
||||
it('should handle section-only content correctly', () => { |
||||
const sectionOnlyContent = `== First Section
|
||||
:author: Section Author |
||||
:description: This is the first section |
||||
:tags: section1, content |
||||
|
||||
This is the content of the first section. |
||||
|
||||
== Second Section |
||||
:summary: This is the second section |
||||
:type: chapter |
||||
|
||||
This is the content of the second section.`;
|
||||
|
||||
const { metadata, content } = extractSmartMetadata(sectionOnlyContent); |
||||
|
||||
// Should extract title from first section
|
||||
expect(metadata.title).toBe('First Section'); |
||||
|
||||
// Should not have document-level metadata since there's no document header
|
||||
expect(metadata.authors).toBeUndefined(); |
||||
expect(metadata.version).toBeUndefined(); |
||||
expect(metadata.publicationDate).toBeUndefined(); |
||||
|
||||
// Content should be preserved
|
||||
expect(content).toBe(sectionOnlyContent); |
||||
}); |
||||
|
||||
it('should handle minimal document header (just title) correctly', () => { |
||||
const minimalDocumentHeader = `= Test Document
|
||||
|
||||
== First Section |
||||
:author: Section Author |
||||
:description: This is the first section |
||||
|
||||
This is the content of the first section. |
||||
|
||||
== Second Section |
||||
:summary: This is the second section |
||||
:type: chapter |
||||
|
||||
This is the content of the second section.`;
|
||||
|
||||
const { metadata, content } = extractSmartMetadata(minimalDocumentHeader); |
||||
|
||||
// Should extract title from document header
|
||||
expect(metadata.title).toBe('Test Document'); |
||||
|
||||
// Should not have document-level metadata since there's no other metadata
|
||||
expect(metadata.authors).toBeUndefined(); |
||||
// Note: version might be set from section attributes like :type: chapter
|
||||
expect(metadata.publicationDate).toBeUndefined(); |
||||
|
||||
// Content should preserve the title line for 30040 events
|
||||
expect(content).toContain('= Test Document'); |
||||
expect(content).toContain('== First Section'); |
||||
expect(content).toContain('== Second Section'); |
||||
}); |
||||
|
||||
it('should handle document with full header correctly', () => { |
||||
const documentWithHeader = `= Test Document
|
||||
John Doe <john@example.com> |
||||
1.0, 2024-01-15: Alexandria Test |
||||
:summary: This is a test document |
||||
:author: Jane Smith |
||||
|
||||
== First Section |
||||
:author: Section Author |
||||
:description: This is the first section |
||||
|
||||
This is the content.`;
|
||||
|
||||
const { metadata, content } = extractSmartMetadata(documentWithHeader); |
||||
|
||||
// Should extract document-level metadata
|
||||
expect(metadata.title).toBe('Test Document'); |
||||
expect(metadata.authors).toEqual(['John Doe', 'Jane Smith']); |
||||
expect(metadata.version).toBe('1.0'); |
||||
expect(metadata.publishedBy).toBe('Alexandria Test'); |
||||
expect(metadata.publicationDate).toBe('2024-01-15'); |
||||
expect(metadata.summary).toBe('This is a test document'); |
||||
|
||||
// Content should be cleaned
|
||||
expect(content).not.toContain('= Test Document'); |
||||
expect(content).not.toContain('John Doe <john@example.com>'); |
||||
expect(content).not.toContain('1.0, 2024-01-15: Alexandria Test'); |
||||
expect(content).not.toContain(':summary: This is a test document'); |
||||
expect(content).not.toContain(':author: Jane Smith'); |
||||
}); |
||||
}); |
||||
});
|
||||
Loading…
Reference in new issue