diff --git a/src/lib/components/EventInput.svelte b/src/lib/components/EventInput.svelte index 17b3489..2768205 100644 --- a/src/lib/components/EventInput.svelte +++ b/src/lib/components/EventInput.svelte @@ -48,6 +48,38 @@ let warningMessage = $state(""); let pendingPublish = $state(false); let extractedMetadata = $state<[string, string][]>([]); + let hasLoadedFromStorage = $state(false); + + // Load content from sessionStorage if available (from ZettelEditor) + $effect(() => { + if (hasLoadedFromStorage) return; // Prevent multiple loads + + const storedContent = sessionStorage.getItem('zettelEditorContent'); + const storedSource = sessionStorage.getItem('zettelEditorSource'); + + if (storedContent && storedSource === 'publication-format') { + content = storedContent; + hasLoadedFromStorage = true; + + // Clear the stored content after loading + sessionStorage.removeItem('zettelEditorContent'); + sessionStorage.removeItem('zettelEditorSource'); + + // Extract title from content + const extracted = extractTitleFromContent(content); + if (extracted) { + title = extracted; + titleManuallyEdited = false; + dTagManuallyEdited = false; + } + + // For content from ZettelEditor, don't extract any metadata + // since ZettelEditor content never has document metadata + if (kind === 30040 || kind === 30041) { + extractedMetadata = []; + } + } + }); /** * Extracts the first Markdown/AsciiDoc header as the title. @@ -57,10 +89,14 @@ // Look for document title (=) first, then fall back to section headers (==) const documentMatch = content.match(/^=\s*(.+)$/m); if (documentMatch) { - return documentMatch[1].trim(); + const title = documentMatch[1].trim(); + // Only return the title if it's not empty (malformed titles like "=|" will be empty) + if (title) { + return title; + } } - // If no document title, look for the first section header + // If no valid document title, look for the first section header const sectionMatch = content.match(/^==\s*(.+)$/m); if (sectionMatch) { return sectionMatch[1].trim(); @@ -81,16 +117,8 @@ // Extract metadata from AsciiDoc content for 30040 and 30041 events if (kind === 30040 || kind === 30041) { - try { - const { metadata } = extractDocumentMetadata(content); - const metadataTags = metadataToTags(metadata); - extractedMetadata = metadataTags; - console.log("Extracted metadata:", metadata); - console.log("Metadata tags:", metadataTags); - } catch (error) { - console.error("Error extracting metadata:", error); - extractedMetadata = []; - } + // Don't extract metadata - let users add tags manually + extractedMetadata = []; } else { extractedMetadata = []; } diff --git a/src/lib/components/ZettelEditor.svelte b/src/lib/components/ZettelEditor.svelte index b8c9d4d..9e33343 100644 --- a/src/lib/components/ZettelEditor.svelte +++ b/src/lib/components/ZettelEditor.svelte @@ -5,19 +5,33 @@ parseAsciiDocSections, type ZettelSection, } from "$lib/utils/ZettelParser"; + import { + extractDocumentMetadata, + extractSectionMetadata, + parseAsciiDocWithMetadata, + type AsciiDocMetadata, + metadataToTags, + } from "$lib/utils/asciidoc_metadata"; import asciidoctor from "asciidoctor"; // Component props let { content = "", placeholder = `== Note Title -:author: {author} // author is optional -:tags: tag1, tag2, tag3 // tags are optional +:author: Your Name +:version: 1.0 +:published_on: 2024-01-01 +:published_by: Alexandria +:summary: A brief description of this note +:tags: note, example, metadata +:image: https://example.com/image.jpg note content here... == Note Title 2 -:tags: tag1, tag2, tag3 +Some Other Author (this weeks even if there is no :author: attribute) +:keywords: second, note, example (keywords are converted to tags) +:description: This is a description of the note (description is converted to a summary tag) Note content here... `, showPreview = false, @@ -34,8 +48,63 @@ Note content here... // Initialize AsciiDoctor processor const asciidoctorProcessor = asciidoctor(); - // Parse sections for preview - let parsedSections = $derived(parseAsciiDocSections(content, 2)); + // Parse sections for preview using the new metadata service + let parsedSections = $derived.by(() => { + if (!content.trim()) return []; + + // Check if content starts with a document header (level 0 header) + const hasDocumentHeader = content.match(/^=\s+/m); + + let sections; + if (hasDocumentHeader) { + // Use the proper metadata service for documents with headers + const parsed = parseAsciiDocWithMetadata(content); + sections = parsed.sections; + } else { + // For content that starts directly with sections, split manually + const sectionStrings = content.split(/(?=^==\s+)/gm).filter((section: string) => section.trim()); + sections = sectionStrings.map((sectionString: string) => { + const { metadata, content, title } = extractSectionMetadata(sectionString); + return { metadata, content, title }; + }); + } + + // Debug logging + console.log("Parsed sections:", sections); + + return sections.map((section: { metadata: AsciiDocMetadata; content: string; title: string }) => { + // Use only section metadata for each section + // Don't combine with document metadata to avoid overriding section-specific metadata + const tags = metadataToTags(section.metadata); + + // Debug logging + console.log(`Section "${section.title}":`, { metadata: section.metadata, tags }); + + return { + title: section.title || "Untitled", + content: section.content.trim(), + tags, + }; + }); + }); + + // Check for 30040-style document headers (publication format) + let hasPublicationHeader = $derived.by(() => { + if (!content.trim()) return false; + + const lines = content.split(/\r?\n/); + for (const line of lines) { + // Check for document title (level 0 header) + if (line.match(/^=\s+(.+)$/)) { + return true; + } + // Check for "index card" format (case insensitive) + if (line.trim().toLowerCase() === 'index card') { + return true; + } + } + return false; + }); // Toggle preview panel function togglePreview() { @@ -51,34 +120,87 @@ Note content here...
- -
-
-
- - - + + {#if hasPublicationHeader} +
+
+
+ + + +
+
+

+ Publication Format Detected +

+

+ You're using a publication format (document title with = or "index card"). + This editor is for individual notes only. Use the + Events form + to create structured publications. +

+
+ { + // Store the content in sessionStorage so it can be loaded in the Events form + sessionStorage.setItem('zettelEditorContent', content); + sessionStorage.setItem('zettelEditorSource', 'publication-format'); + }} + class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-800 border border-red-200 dark:border-red-700 rounded-md hover:bg-red-200 dark:hover:bg-red-700 transition-colors" + > + Switch to Publication Editor + + +
+
-
-

- Note-Taking Tool -

-

- This editor is for creating individual notes (30041 events) only. Each section becomes a separate note event. - To create structured publications with a 30040 index event that ties multiple notes together, - use the Events form. -

- + {:else} + +
+
+
+ + + +
+
+

+ Note-Taking Tool +

+

+ This editor is for creating individual notes (30041 events) only. Each section becomes a separate note event. + You can add metadata like author, version, publication date, summary, and tags using AsciiDoc attributes. + To create structured publications with a 30040 index event that ties multiple notes together, + use the Events form. +

+
-
+ {/if}
- {#if showPreview} + {#if showPreview && !hasPublicationHeader}

- {#if index < parsedSections.length - 1} - -
- -
-
- {#if section.tags && section.tags.length > 0} - {#each section.tags as tag} -
- {tag[0]}: - {tag[1]} -
- {/each} - {:else} - No tags +
+ +
+
+ {#if section.tags && section.tags.length > 0} + {#each section.tags as tag} +
- {/if} -
+ {tag[0]}: + {tag[1]} +
+ {/each} + {:else} + No tags + {/if}
+
- + {#if index < parsedSections.length - 1} +
@@ -184,8 +308,8 @@ Note content here... Event Boundary
-
- {/if} + {/if} +

{/each}
@@ -198,7 +322,6 @@ Note content here... ? "s" : ""}
- Note: Currently only the first event will be published.
{/if}
diff --git a/src/lib/utils/ZettelParser.ts b/src/lib/utils/ZettelParser.ts index fea2e08..2796d47 100644 --- a/src/lib/utils/ZettelParser.ts +++ b/src/lib/utils/ZettelParser.ts @@ -32,21 +32,40 @@ export function parseZettelSection(section: string): ZettelSection { const lines = section.split("\n"); let title = "Untitled"; const contentLines: string[] = []; - let inHeader = true; let tags: string[][] = []; tags = extractTags(section); - for (const line of lines) { + // Find the section title first + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; const trimmed = line.trim(); - if (inHeader && trimmed.startsWith("==")) { + if (trimmed.startsWith("==")) { title = trimmed.replace(/^==+/, "").trim(); - continue; - } else if (inHeader && trimmed.startsWith(":")) { - continue; + + // Process header metadata (everything after title until blank line) + let j = i + 1; + while (j < lines.length && lines[j].trim() !== "") { + const headerLine = lines[j].trim(); + if (headerLine.startsWith(":")) { + // This is metadata, already handled by extractTags + j++; + } else { + // This is header content (like author name), skip from content + j++; + } + } + + // Skip the blank line + if (j < lines.length && lines[j].trim() === "") { + j++; + } + + // Everything after the blank line is content + for (let k = j; k < lines.length; k++) { + contentLines.push(lines[k]); + } + break; } - - inHeader = false; - contentLines.push(line); } return { @@ -69,6 +88,7 @@ export function parseAsciiDocSections( /** * Extracts tag names and values from the content. * :tagname: tagvalue // tags are optional + * Also handles AsciiDoc author line convention * @param content The AsciiDoc string. * @returns Array of tags. */ @@ -76,30 +96,46 @@ export function extractTags(content: string): string[][] { const tags: string[][] = []; const lines = content.split("\n"); - for (const line of lines) { + // Find the section title and process header metadata + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; const trimmed = line.trim(); - if (trimmed.startsWith(":")) { - // Parse AsciiDoc attribute format: :tagname: value - const match = trimmed.match(/^:([^:]+):\s*(.*)$/); - if (match) { - const tagName = match[1].trim(); - const tagValue = match[2].trim(); + + if (trimmed.startsWith("==")) { + // Process header metadata (everything after title until blank line) + let j = i + 1; + while (j < lines.length && lines[j].trim() !== "") { + const headerLine = lines[j].trim(); + + if (headerLine.startsWith(":")) { + // Parse AsciiDoc attribute format: :tagname: value + const match = headerLine.match(/^:([^:]+):\s*(.*)$/); + if (match) { + const tagName = match[1].trim(); + const tagValue = match[2].trim(); - // Special handling for tags attribute - if (tagName === "tags") { - // Split comma-separated values and create individual "t" tags - const tagValues = tagValue - .split(",") - .map((v) => v.trim()) - .filter((v) => v.length > 0); - for (const value of tagValues) { - tags.push(["t", value]); + // Special handling for tags attribute + if (tagName === "tags") { + // Split comma-separated values and create individual "t" tags + const tagValues = tagValue + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + for (const value of tagValues) { + tags.push(["t", value]); + } + } else { + // Regular attribute becomes a tag + tags.push([tagName, tagValue]); + } } } else { - // Regular attribute becomes a tag - tags.push([tagName, tagValue]); + // This is header content (like author name) + tags.push(["author", headerLine]); } + j++; } + break; } } diff --git a/src/routes/new/compose/+page.svelte b/src/routes/new/compose/+page.svelte index 73eb646..33704a7 100644 --- a/src/routes/new/compose/+page.svelte +++ b/src/routes/new/compose/+page.svelte @@ -5,6 +5,7 @@ import { goto } from "$app/navigation"; import { nip19 } from "nostr-tools"; import { publishMultipleZettels } from "$lib/services/publisher"; + import { parseAsciiDocSections } from "$lib/utils/ZettelParser"; let content = $state(""); let showPreview = $state(false); @@ -13,6 +14,8 @@ successCount: number; total: number; errors: string[]; + successfulEvents: Array<{ eventId: string; title: string }>; + failedEvents: Array<{ title: string; error: string; sectionIndex: number }>; } | null>(null); // Handle content changes from ZettelEditor @@ -33,19 +36,91 @@ content, onError: (error) => { // Only used for catastrophic errors - publishResults = { successCount: 0, total: 0, errors: [error] }; + publishResults = { successCount: 0, total: 0, errors: [error], successfulEvents: [], failedEvents: [] }; }, }); const successCount = results.filter(r => r.success).length; const errors = results.filter(r => !r.success && r.error).map(r => r.error!); + + // Extract successful events with their titles + const sections = parseAsciiDocSections(content, 2); + const successfulEvents = results + .filter(r => r.success && r.eventId) + .map((r, index) => ({ + eventId: r.eventId!, + title: sections[index]?.title || `Note ${index + 1}` + })); + + // Extract failed events with their titles and errors + const failedEvents = results + .map((r, index) => ({ result: r, index })) + .filter(({ result }) => !result.success) + .map(({ result, index }) => ({ + title: sections[index]?.title || `Note ${index + 1}`, + error: result.error || 'Unknown error', + sectionIndex: index + })); + publishResults = { successCount, total: results.length, errors, + successfulEvents, + failedEvents, }; isPublishing = false; } + + async function retryFailedEvent(sectionIndex: number) { + if (!publishResults) return; + + isPublishing = true; + + // Get the specific section content + const sections = parseAsciiDocSections(content, 2); + const section = sections[sectionIndex]; + if (!section) return; + + // Reconstruct the section content for publishing + const sectionContent = `== ${section.title}\n\n${section.content}`; + + try { + const result = await publishMultipleZettels({ + content: sectionContent, + onError: (error) => { + console.error('Retry failed:', error); + }, + }); + + if (result[0]?.success && result[0]?.eventId) { + // Update the successful events list + const newSuccessfulEvent = { + eventId: result[0].eventId, + title: section.title + }; + + // Remove from failed events + const updatedFailedEvents = publishResults.failedEvents.filter( + (_, index) => index !== sectionIndex + ); + + // Add to successful events + const updatedSuccessfulEvents = [...publishResults.successfulEvents, newSuccessfulEvent]; + + publishResults = { + ...publishResults, + successCount: publishResults.successCount + 1, + successfulEvents: updatedSuccessfulEvents, + failedEvents: updatedFailedEvents, + }; + } + } catch (error) { + console.error('Retry failed:', error); + } + + isPublishing = false; + } @@ -89,14 +164,71 @@ Success! {publishResults.successCount} events published. + {#if publishResults.successfulEvents.length > 0} +
+ Published events: +
+ {#each publishResults.successfulEvents as event} + {@const nevent = nip19.neventEncode({ id: event.eventId })} + + {/each} +
+
+ {/if}
{:else} Some events failed to publish. - {publishResults.successCount} of {publishResults.total} events published.
- {#each publishResults.errors as error} -
{error}
- {/each} + {publishResults.successCount} of {publishResults.total} events published. + + {#if publishResults.successfulEvents.length > 0} +
+ Successfully published: +
+ {#each publishResults.successfulEvents as event} + {@const nevent = nip19.neventEncode({ id: event.eventId })} + + {/each} +
+
+ {/if} + + {#if publishResults.failedEvents.length > 0} +
+ Failed to publish: +
+ {#each publishResults.failedEvents as failedEvent, index} +
+
{failedEvent.title}
+
{failedEvent.error}
+ +
+ {/each} +
+
+ {/if}
{/if} {/if} diff --git a/tests/unit/ZettelEditor.test.ts b/tests/unit/ZettelEditor.test.ts new file mode 100644 index 0000000..3490286 --- /dev/null +++ b/tests/unit/ZettelEditor.test.ts @@ -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: () => ``, + $$bind: { value: props.bind, oninput: props.oninput } + }; + }), + Button: vi.fn().mockImplementation((props) => { + return { + $$render: () => ``, + $$bind: { onclick: props.onclick } + }; + }) +})); + +vi.mock("flowbite-svelte-icons", () => ({ + EyeOutline: vi.fn().mockImplementation(() => ({ + $$render: () => `` + })) +})); + +vi.mock("asciidoctor", () => ({ + default: vi.fn(() => ({ + convert: vi.fn((content, options) => { + // Mock AsciiDoctor conversion - return simple HTML + return content.replace(/^==\s+(.+)$/gm, '

$1

') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1'); + }) + })) +})); + +// 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; + let mockOnPreviewToggle: ReturnType; + + 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, '

$1

') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1'); + }); + + 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('

Test Section

'); + expect(processedContent).toContain('bold'); + expect(processedContent).toContain('italic'); + }); + }); + + 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'); + }); + }); + }); +}); \ No newline at end of file