21 changed files with 2664 additions and 783 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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