From 04c755da5d4fd9cb66992655567b362bafaa302b Mon Sep 17 00:00:00 2001 From: James Ainslie <42301770+jamesainslie@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:05:21 -0400 Subject: [PATCH 01/26] fix(core): improve mermaid cache race handling and renderer robustness --- src/core/render-pipeline.ts | 41 ++- src/renderers/mermaid-renderer.ts | 20 +- tests/unit/core/mermaid-cache-race.test.ts | 281 +++++++++++++++ tests/unit/renderers/mermaid-renderer.test.ts | 322 ++++++++++++++++++ 4 files changed, 645 insertions(+), 19 deletions(-) create mode 100644 tests/unit/core/mermaid-cache-race.test.ts create mode 100644 tests/unit/renderers/mermaid-renderer.test.ts diff --git a/src/core/render-pipeline.ts b/src/core/render-pipeline.ts index 5925b5f..3635a26 100644 --- a/src/core/render-pipeline.ts +++ b/src/core/render-pipeline.ts @@ -347,13 +347,22 @@ export class RenderPipeline { // Re-setup image lazy loading this.setupImageLazyLoading(container); - // Re-initialize Mermaid diagrams (they may need panzoom reinitialization) - const mermaidContainers = container.querySelectorAll('.mermaid-container.mermaid-ready'); - if (mermaidContainers.length > 0) { + // Re-initialize Mermaid diagrams + const pendingMermaid = container.querySelectorAll('.mermaid-container.mermaid-pending'); + const readyMermaid = container.querySelectorAll('.mermaid-container.mermaid-ready'); + + if (pendingMermaid.length > 0 || readyMermaid.length > 0) { try { - // Mermaid diagrams should already have SVG from cache - // Controls will be re-added automatically when mermaid module loads - await import('../renderers/mermaid-renderer'); + const { mermaidRenderer } = await import('../renderers/mermaid-renderer'); + + // Re-render any pending diagrams that were cached before rendering completed + if (pendingMermaid.length > 0) { + debug.info( + 'RenderPipeline', + `Re-rendering ${pendingMermaid.length} pending mermaid diagram(s) from stale cache` + ); + await mermaidRenderer.renderAll(container); + } } catch (error) { debug.error('RenderPipeline', 'Failed to reinitialize mermaid:', error); } @@ -378,11 +387,11 @@ export class RenderPipeline { await this.applySyntaxHighlighting(container); } - // CRITICAL: Mark Mermaid blocks (actual rendering happens lazily on intersection) - debug.debug('RenderPipeline', 'Marking Mermaid blocks...'); - this.markMermaidBlocks(container); + // CRITICAL: Mark and render Mermaid blocks (awaited so cache captures rendered state) + debug.debug('RenderPipeline', 'Rendering Mermaid blocks...'); + await this.markAndRenderMermaidBlocks(container); - debug.debug('RenderPipeline', 'Syntax highlighting complete'); + debug.debug('RenderPipeline', 'Enhancement complete'); // NON-CRITICAL: Schedule remaining enhancements during idle time // This improves perceived performance by not blocking the main render @@ -914,18 +923,18 @@ export class RenderPipeline { } /** - * Mark Mermaid blocks for rendering + * Mark Mermaid blocks for rendering and await completion */ - private markMermaidBlocks(container: HTMLElement): void { + private async markAndRenderMermaidBlocks(container: HTMLElement): Promise { const mermaidBlocks = container.querySelectorAll('.mermaid-container'); mermaidBlocks.forEach((block) => { block.classList.add('mermaid-pending'); }); - // Initialize Mermaid renderer - this.renderMermaidDiagrams(container).catch((error) => { - debug.error('RenderPipeline', 'Mermaid rendering catch error:', error); - }); + // Await mermaid rendering so cache captures the rendered state + if (mermaidBlocks.length > 0) { + await this.renderMermaidDiagrams(container); + } } /** diff --git a/src/renderers/mermaid-renderer.ts b/src/renderers/mermaid-renderer.ts index 013a944..25f811a 100644 --- a/src/renderers/mermaid-renderer.ts +++ b/src/renderers/mermaid-renderer.ts @@ -278,13 +278,27 @@ export class MermaidRenderer { // Generate unique ID for SVG const svgId = `mermaid-svg-${containerId}`; - // Render with timeout + // Render with timeout and proper cleanup + let timeoutId: ReturnType; const renderPromise = mermaid.render(svgId, code); const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Render timeout')), 5000); + timeoutId = setTimeout(() => reject(new Error('Render timeout')), 5000); }); - const result = await Promise.race([renderPromise, timeoutPromise]); + let result: { svg: string }; + try { + result = await Promise.race([renderPromise, timeoutPromise]); + clearTimeout(timeoutId!); + } catch (error) { + clearTimeout(timeoutId!); + // Clean up any orphaned DOM elements that mermaid.render() may have created + // mermaid creates temporary SVG elements with the svgId during rendering + const orphanedSvg = document.getElementById(svgId); + if (orphanedSvg) { + orphanedSvg.remove(); + } + throw error; + } // Clear loading state container.innerHTML = ''; diff --git a/tests/unit/core/mermaid-cache-race.test.ts b/tests/unit/core/mermaid-cache-race.test.ts new file mode 100644 index 0000000..d9602c3 --- /dev/null +++ b/tests/unit/core/mermaid-cache-race.test.ts @@ -0,0 +1,281 @@ +/** + * Tests for mermaid rendering / cache race condition fix + * + * Verifies that: + * 1. Mermaid rendering completes BEFORE cache is written + * 2. Cache restore re-renders any pending mermaid diagrams + * 3. Timeout properly cleans up orphaned mermaid DOM elements + */ + +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { RenderPipeline } from '../../../src/core/render-pipeline'; +import { createTestContainer, cleanupTestContainer, mockConsole } from '../../helpers/test-utils'; +import { mockChromeRuntime, waitFor } from '../../helpers/mocks'; + +// The mock renderAll should simulate what the real one does: change classes on containers +const mockRenderAll = vi.fn().mockImplementation(async (container: HTMLElement) => { + // Simulate successful rendering: remove pending, add ready + const pending = container.querySelectorAll('.mermaid-container.mermaid-pending'); + pending.forEach((el) => { + el.classList.remove('mermaid-pending'); + el.classList.add('mermaid-ready'); + el.innerHTML = '
rendered
'; + }); +}); + +vi.mock('../../../src/renderers/mermaid-renderer', () => ({ + mermaidRenderer: { + renderAll: mockRenderAll, + renderAllImmediate: vi.fn().mockResolvedValue(undefined), + updateTheme: vi.fn(), + cleanup: vi.fn(), + }, +})); + +vi.mock('../../../src/renderers/syntax-highlighter', () => ({ + syntaxHighlighter: { + highlightVisible: vi.fn(), + highlightAll: vi.fn(), + setTheme: vi.fn(), + }, +})); + +vi.mock('../../../src/workers/worker-pool', () => ({ + workerPool: { + initialize: vi.fn().mockRejectedValue(new Error('file:// not supported')), + execute: vi.fn(), + terminate: vi.fn(), + }, +})); + +describe('Mermaid Cache Race Condition', () => { + let pipeline: RenderPipeline; + let container: HTMLElement; + let consoleMock: { restore: () => void }; + let sendMessageMock: ReturnType; + + // Markdown that produces mermaid containers + const mermaidMarkdown = '# Test\n\n```mermaid\ngraph TD\n A-->B\n```\n\nSome text.'; + + beforeEach(() => { + consoleMock = mockConsole(); + container = createTestContainer(); + vi.clearAllMocks(); + + // Re-apply the mock implementation after clearAllMocks + mockRenderAll.mockImplementation(async (cont: HTMLElement) => { + const pending = cont.querySelectorAll('.mermaid-container.mermaid-pending'); + pending.forEach((el) => { + el.classList.remove('mermaid-pending'); + el.classList.add('mermaid-ready'); + el.innerHTML = '
rendered
'; + }); + }); + + const mocks = mockChromeRuntime(); + sendMessageMock = mocks.sendMessage; + + pipeline = new RenderPipeline(); + }); + + afterEach(() => { + cleanupTestContainer(container); + consoleMock.restore(); + }); + + describe('Cache write timing', () => { + test('should await mermaid rendering before writing cache', async () => { + // Track the order of operations + const operationOrder: string[] = []; + + mockRenderAll.mockImplementation(async (cont: HTMLElement) => { + operationOrder.push('mermaid-render-start'); + // Simulate mermaid rendering taking some time + await new Promise((resolve) => setTimeout(resolve, 10)); + // Simulate DOM update + const pending = cont.querySelectorAll('.mermaid-container.mermaid-pending'); + pending.forEach((el) => { + el.classList.remove('mermaid-pending'); + el.classList.add('mermaid-ready'); + el.innerHTML = '
rendered
'; + }); + operationOrder.push('mermaid-render-complete'); + }); + + sendMessageMock.mockImplementation((message: { type: string }) => { + if (message.type === 'CACHE_SET') { + operationOrder.push('cache-set'); + } + if (message.type === 'CACHE_GENERATE_KEY') { + return Promise.resolve({ key: 'test-key' }); + } + if (message.type === 'CACHE_GET') { + return Promise.resolve({ result: null }); + } + return Promise.resolve({ success: true }); + }); + + await pipeline.render({ + container, + markdown: mermaidMarkdown, + useCache: true, + useWorkers: false, + filePath: '/test/file.md', + theme: 'github-light', + }); + + // Wait for any async operations + await waitFor(100); + + // Mermaid render should complete BEFORE cache write + const mermaidCompleteIdx = operationOrder.indexOf('mermaid-render-complete'); + const cacheSetIdx = operationOrder.indexOf('cache-set'); + + expect(operationOrder).toContain('mermaid-render-start'); + expect(operationOrder).toContain('mermaid-render-complete'); + + // If caching happened, mermaid should have completed first + if (cacheSetIdx >= 0) { + expect(mermaidCompleteIdx).toBeLessThan(cacheSetIdx); + } + }); + + test('should not cache HTML with mermaid-pending containers', async () => { + let cachedHtml = ''; + + sendMessageMock.mockImplementation((message: { type: string; payload?: any }) => { + if (message.type === 'CACHE_SET') { + cachedHtml = message.payload?.result?.html || ''; + return Promise.resolve({ success: true }); + } + if (message.type === 'CACHE_GENERATE_KEY') { + return Promise.resolve({ key: 'test-key' }); + } + if (message.type === 'CACHE_GET') { + return Promise.resolve({ result: null }); + } + return Promise.resolve({ success: true }); + }); + + await pipeline.render({ + container, + markdown: mermaidMarkdown, + useCache: true, + useWorkers: false, + filePath: '/test/file.md', + theme: 'github-light', + }); + + await waitFor(100); + + // Cache should have been written + expect(cachedHtml).not.toBe(''); + // Cache should NOT contain mermaid-pending (rendering was awaited) + expect(cachedHtml).not.toContain('mermaid-pending'); + }); + }); + + describe('Cache restore with mermaid', () => { + test('should re-render pending mermaid diagrams from stale cache', async () => { + // Simulate a stale cache hit that has mermaid-pending containers + const staleHtml = ` +

Test

+
+
Rendering diagram...
+
+

Some text.

+ `; + + sendMessageMock.mockImplementation((message: { type: string }) => { + if (message.type === 'CACHE_GENERATE_KEY') { + return Promise.resolve({ key: 'cached-key' }); + } + if (message.type === 'CACHE_GET') { + return Promise.resolve({ + result: { + html: staleHtml, + metadata: { + wordCount: 5, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }, + highlightedBlocks: new Map(), + mermaidSVGs: new Map(), + timestamp: Date.now(), + cacheKey: 'cached-key', + }, + }); + } + return Promise.resolve({ success: true }); + }); + + await pipeline.render({ + container, + markdown: mermaidMarkdown, + useCache: true, + useWorkers: false, + filePath: '/test/file.md', + theme: 'github-light', + }); + + await waitFor(100); + + // Should have triggered mermaid renderAll for the pending diagrams + expect(mockRenderAll).toHaveBeenCalled(); + }); + + test('should not re-render when cache only has mermaid-ready containers', async () => { + const goodCacheHtml = ` +

Test

+
+
cached svg
+
+ `; + + sendMessageMock.mockImplementation((message: { type: string }) => { + if (message.type === 'CACHE_GENERATE_KEY') { + return Promise.resolve({ key: 'good-cached-key' }); + } + if (message.type === 'CACHE_GET') { + return Promise.resolve({ + result: { + html: goodCacheHtml, + metadata: { + wordCount: 2, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }, + highlightedBlocks: new Map(), + mermaidSVGs: new Map(), + timestamp: Date.now(), + cacheKey: 'good-cached-key', + }, + }); + } + return Promise.resolve({ success: true }); + }); + + await pipeline.render({ + container, + markdown: '# Test\n\n```mermaid\ngraph TD\n A-->B\n```', + useCache: true, + useWorkers: false, + filePath: '/test/ready.md', + theme: 'github-light', + }); + + await waitFor(100); + + // renderAll should NOT be called - no pending diagrams + expect(mockRenderAll).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/renderers/mermaid-renderer.test.ts b/tests/unit/renderers/mermaid-renderer.test.ts new file mode 100644 index 0000000..8db61c0 --- /dev/null +++ b/tests/unit/renderers/mermaid-renderer.test.ts @@ -0,0 +1,322 @@ +/** + * Unit tests for Mermaid Renderer + * Covers: render queue, timeout cleanup, and state management + */ + +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestContainer, cleanupTestContainer, mockConsole } from '../../helpers/test-utils'; +import { waitFor } from '../../helpers/mocks'; + +// vi.mock factories are hoisted - cannot reference outer variables +vi.mock('mermaid', () => ({ + default: { + initialize: vi.fn(), + parse: vi.fn().mockResolvedValue({ diagramType: 'flowchart-v2', config: {} }), + render: vi.fn().mockResolvedValue({ svg: 'mock', diagramType: 'flowchart-v2' }), + }, +})); + +vi.mock('panzoom', () => ({ + default: vi.fn().mockReturnValue({ + dispose: vi.fn(), + getTransform: vi.fn().mockReturnValue({ scale: 1, x: 0, y: 0 }), + moveTo: vi.fn(), + zoomAbs: vi.fn(), + zoomTo: vi.fn(), + smoothZoomAbs: vi.fn(), + }), +})); + +// Import after mocks are set up +import mermaid from 'mermaid'; +import Panzoom from 'panzoom'; +import { MermaidRenderer } from '../../../src/renderers/mermaid-renderer'; + +describe('MermaidRenderer', () => { + let renderer: MermaidRenderer; + let container: HTMLElement; + let consoleMock: { restore: () => void }; + + beforeEach(() => { + consoleMock = mockConsole(); + container = createTestContainer(); + vi.clearAllMocks(); + + // Reset mermaid mocks to success state + vi.mocked(mermaid.parse).mockResolvedValue({ diagramType: 'flowchart-v2', config: {} }); + vi.mocked(mermaid.render).mockResolvedValue({ svg: 'mock diagram', diagramType: 'flowchart-v2' }); + + renderer = new MermaidRenderer(); + }); + + afterEach(() => { + renderer.cleanup(); + cleanupTestContainer(container); + consoleMock.restore(); + }); + + /** + * Helper: create a mermaid container element in the DOM with code registered + */ + function createMermaidContainer(id: string, code: string): HTMLElement { + const el = document.createElement('div'); + el.id = id; + el.className = 'mermaid-container mermaid-pending'; + el.innerHTML = '
Rendering diagram...
'; + el.setAttribute('data-mermaid-code', code); + container.appendChild(el); + + // Register in global registry + const win = window as { __MDVIEW_MERMAID_CODE__?: Map }; + if (!win.__MDVIEW_MERMAID_CODE__) { + win.__MDVIEW_MERMAID_CODE__ = new Map(); + } + win.__MDVIEW_MERMAID_CODE__.set(id, code); + + return el; + } + + describe('renderDiagram', () => { + test('should render a valid mermaid diagram', async () => { + const el = createMermaidContainer('mermaid-test1', 'graph TD\n A-->B'); + + await renderer.renderDiagram('mermaid-test1'); + + expect(mermaid.parse).toHaveBeenCalledWith('graph TD\n A-->B'); + expect(mermaid.render).toHaveBeenCalled(); + expect(el.classList.contains('mermaid-ready')).toBe(true); + expect(el.classList.contains('mermaid-pending')).toBe(false); + expect(el.querySelector('.mermaid-rendered')).not.toBeNull(); + }); + + test('should show error for invalid syntax', async () => { + vi.mocked(mermaid.parse).mockRejectedValue(new Error('Parse error: invalid syntax')); + const el = createMermaidContainer('mermaid-bad', 'not valid mermaid'); + + await renderer.renderDiagram('mermaid-bad'); + + expect(el.classList.contains('mermaid-error')).toBe(true); + expect(el.innerHTML).toContain('Mermaid Diagram Error'); + }); + + test('should skip already-rendered containers', async () => { + const el = createMermaidContainer('mermaid-done', 'graph TD\n A-->B'); + // Simulate already rendered by adding SVG + const wrapper = document.createElement('div'); + wrapper.className = 'mermaid-rendered'; + wrapper.innerHTML = 'already rendered'; + el.innerHTML = ''; + el.appendChild(wrapper); + + await renderer.renderDiagram('mermaid-done'); + + // Should not call mermaid.render again + expect(mermaid.render).not.toHaveBeenCalled(); + }); + + test('should handle missing container gracefully', async () => { + await renderer.renderDiagram('nonexistent-id'); + expect(mermaid.render).not.toHaveBeenCalled(); + }); + + test('should handle missing code gracefully', async () => { + const el = document.createElement('div'); + el.id = 'mermaid-nocode'; + el.className = 'mermaid-container mermaid-pending'; + container.appendChild(el); + + await renderer.renderDiagram('mermaid-nocode'); + + expect(mermaid.render).not.toHaveBeenCalled(); + }); + }); + + describe('render queue serialization', () => { + test('should serialize concurrent renders through the queue', async () => { + createMermaidContainer('mermaid-q1', 'graph TD\n A-->B'); + createMermaidContainer('mermaid-q2', 'graph TD\n C-->D'); + + // Make the first render slow - resolves only when we call resolveFirst() + let resolveFirst!: (value: { svg: string; diagramType: string }) => void; + vi.mocked(mermaid.render) + .mockImplementationOnce( + () => new Promise((resolve) => { resolveFirst = resolve; }) + ) + .mockResolvedValueOnce({ svg: 'second', diagramType: 'flowchart-v2' }); + + // Start first render - it will proceed through validateSyntax, then block on mermaid.render + const p1 = renderer.renderDiagram('mermaid-q1'); + + // Wait for validateSyntax (mermaid.parse) to resolve so mermaid.render gets called + await waitFor(10); + + // Now start second render - isRendering should be true, so it gets queued + void renderer.renderDiagram('mermaid-q2'); + + // First render's mermaid.render should have been called once + expect(mermaid.render).toHaveBeenCalledTimes(1); + + // Complete first render + resolveFirst({ svg: 'first', diagramType: 'flowchart-v2' }); + await p1; + + // Wait for queue processing + await waitFor(50); + + // Second render should have been processed from queue + expect(mermaid.render).toHaveBeenCalledTimes(2); + }); + + test('should reset isRendering flag even on error', async () => { + createMermaidContainer('mermaid-err', 'graph TD\n A-->B'); + createMermaidContainer('mermaid-after', 'graph TD\n C-->D'); + + // First render fails at mermaid.render + vi.mocked(mermaid.render) + .mockRejectedValueOnce(new Error('Render failed')) + .mockResolvedValueOnce({ svg: 'recovered', diagramType: 'flowchart-v2' }); + + // First render - will fail + await renderer.renderDiagram('mermaid-err'); + + const errEl = document.getElementById('mermaid-err'); + expect(errEl?.classList.contains('mermaid-error')).toBe(true); + + // Second render should work since isRendering was reset in finally + await renderer.renderDiagram('mermaid-after'); + + const afterEl = document.getElementById('mermaid-after'); + expect(afterEl?.classList.contains('mermaid-ready')).toBe(true); + }); + }); + + describe('render timeout', () => { + test('should timeout after 5 seconds and show error', async () => { + vi.useFakeTimers(); + + createMermaidContainer('mermaid-slow', 'graph TD\n A-->B'); + + // parse resolves immediately, but render never resolves + vi.mocked(mermaid.render).mockImplementation(() => new Promise(() => {})); + + const renderPromise = renderer.renderDiagram('mermaid-slow'); + + // Allow microtasks (validateSyntax) to run + await vi.advanceTimersByTimeAsync(0); + + // Advance past the 5-second timeout + await vi.advanceTimersByTimeAsync(5100); + + await renderPromise; + + const el = document.getElementById('mermaid-slow'); + expect(el?.classList.contains('mermaid-error')).toBe(true); + expect(el?.innerHTML).toContain('Render timeout'); + + vi.useRealTimers(); + }); + + test('should reset isRendering after timeout so queue continues', async () => { + vi.useFakeTimers(); + + createMermaidContainer('mermaid-timeout1', 'graph TD\n A-->B'); + createMermaidContainer('mermaid-timeout2', 'graph TD\n C-->D'); + + // First render hangs, second succeeds + vi.mocked(mermaid.render) + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce({ svg: 'recovered', diagramType: 'flowchart-v2' }); + + const p1 = renderer.renderDiagram('mermaid-timeout1'); + + // Let validateSyntax complete and mermaid.render start + await vi.advanceTimersByTimeAsync(0); + + // Queue second render + renderer.renderDiagram('mermaid-timeout2'); + + // Trigger timeout for first render + await vi.advanceTimersByTimeAsync(5100); + + await p1; + + // Wait for queue to process second render + await vi.advanceTimersByTimeAsync(100); + + const el1 = document.getElementById('mermaid-timeout1'); + expect(el1?.classList.contains('mermaid-error')).toBe(true); + + const el2 = document.getElementById('mermaid-timeout2'); + expect(el2?.classList.contains('mermaid-ready')).toBe(true); + + vi.useRealTimers(); + }); + + test('should clean up orphaned mermaid DOM elements after timeout', async () => { + vi.useFakeTimers(); + + createMermaidContainer('mermaid-orphan', 'graph TD\n A-->B'); + + // Simulate mermaid leaving orphaned SVG elements during rendering + vi.mocked(mermaid.render).mockImplementation(async (svgId: string) => { + const orphan = document.createElement('svg'); + orphan.id = svgId; + document.body.appendChild(orphan); + // Hang forever + return new Promise(() => {}); + }); + + const renderPromise = renderer.renderDiagram('mermaid-orphan'); + + // Let validateSyntax + mermaid.render start + await vi.advanceTimersByTimeAsync(0); + + // Trigger timeout + await vi.advanceTimersByTimeAsync(5100); + + await renderPromise; + + // Orphaned SVG should have been cleaned up + const orphanedSvg = document.getElementById('mermaid-svg-mermaid-orphan'); + expect(orphanedSvg).toBeNull(); + + vi.useRealTimers(); + }); + }); + + describe('renderAll', () => { + test('should render all pending diagrams in a container', async () => { + createMermaidContainer('mermaid-all1', 'graph TD\n A-->B'); + createMermaidContainer('mermaid-all2', 'graph TD\n C-->D'); + + // Force no observer for immediate rendering + renderer.cleanup(); + renderer = new MermaidRenderer(); + (renderer as any).observer = null; + + await renderer.renderAll(container); + + // Both should be rendered (queue processes sequentially) + await waitFor(100); + + expect(mermaid.render).toHaveBeenCalledTimes(2); + }); + }); + + describe('cleanup', () => { + test('should dispose all panzoom instances', async () => { + createMermaidContainer('mermaid-cleanup1', 'graph TD\n A-->B'); + await renderer.renderDiagram('mermaid-cleanup1'); + + const panzoomInstance = vi.mocked(Panzoom).mock.results[0]?.value; + renderer.cleanup(); + + expect(panzoomInstance.dispose).toHaveBeenCalled(); + }); + + test('should not throw on double cleanup', () => { + renderer.cleanup(); + expect(() => renderer.cleanup()).not.toThrow(); + }); + }); +}); From 5fd8135b7c4b4bd8eb4c43de7e68785a174b04f9 Mon Sep 17 00:00:00 2001 From: James Ainslie <42301770+jamesainslie@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:09:06 -0400 Subject: [PATCH 02/26] chore: add workspace structure and @mdview/core package skeleton --- package-lock.json | 127 ++++++++++++++++------ package.json | 3 + packages/core/package.json | 15 +++ packages/core/src/__tests__/smoke.test.ts | 7 ++ packages/core/src/index.ts | 3 + packages/core/tsconfig.json | 12 ++ packages/core/vitest.config.ts | 9 ++ 7 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 packages/core/package.json create mode 100644 packages/core/src/__tests__/smoke.test.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 84b35be..74f53af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "mdview", "version": "0.3.4", "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@jamesainslie/docx": "^9.5.1-svg.1", "dompurify": "^3.0.6", @@ -139,6 +142,7 @@ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", @@ -154,6 +158,7 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -322,7 +327,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -366,7 +370,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1679,6 +1682,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mdview/core": { + "resolved": "packages/core", + "link": true + }, "node_modules/@mermaid-js/mermaid-cli": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/@mermaid-js/mermaid-cli/-/mermaid-cli-11.12.0.tgz", @@ -1792,6 +1799,7 @@ "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "debug": "^4.4.0", "extract-zip": "^2.0.1", @@ -2286,7 +2294,8 @@ "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -2679,7 +2688,6 @@ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz", "integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==", "license": "MIT", - "peer": true, "dependencies": { "@types/linkify-it": "^3", "@types/mdurl": "^1" @@ -2729,6 +2737,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/node": "*" } @@ -2775,7 +2784,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3150,7 +3158,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3329,6 +3336,7 @@ "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.0.1" }, @@ -3352,6 +3360,7 @@ "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "peerDependencies": { "react-native-b4a": "*" }, @@ -3374,6 +3383,7 @@ "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -3390,6 +3400,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -3416,6 +3427,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "engines": { "bare": ">=1.14.0" } @@ -3427,6 +3439,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "bare-os": "^3.0.1" } @@ -3438,6 +3451,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "streamx": "^2.21.0" }, @@ -3461,6 +3475,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "bare-path": "^3.0.0" } @@ -3492,6 +3507,7 @@ "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" } @@ -3575,6 +3591,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -3586,6 +3603,7 @@ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "*" } @@ -3687,7 +3705,6 @@ "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -3754,6 +3771,7 @@ "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "mitt": "3.0.1", "zod": "3.23.8" @@ -3860,6 +3878,7 @@ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -3982,6 +4001,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4096,7 +4116,6 @@ "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -4531,7 +4550,6 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -4639,6 +4657,7 @@ "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 14" } @@ -4702,6 +4721,7 @@ "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", @@ -4852,7 +4872,8 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/encoding-sniffer": { "version": "0.2.1", @@ -4874,6 +4895,7 @@ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "once": "^1.4.0" } @@ -4896,6 +4918,7 @@ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4919,6 +4942,7 @@ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -5029,6 +5053,7 @@ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -5052,6 +5077,7 @@ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", @@ -5075,7 +5101,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5204,6 +5229,7 @@ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -5298,6 +5324,7 @@ "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "bare-events": "^2.7.0" } @@ -5318,6 +5345,7 @@ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", @@ -5345,7 +5373,8 @@ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-glob": { "version": "3.3.3", @@ -5417,6 +5446,7 @@ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pend": "~1.2.0" } @@ -5539,6 +5569,7 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -5562,6 +5593,7 @@ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pump": "^3.0.0" }, @@ -5591,6 +5623,7 @@ "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", @@ -5989,6 +6022,7 @@ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 12" } @@ -5998,7 +6032,8 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/is-binary-path": { "version": "2.1.0", @@ -6045,6 +6080,7 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -6108,7 +6144,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6148,7 +6183,8 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/js-yaml": { "version": "4.1.1", @@ -6254,7 +6290,8 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -6700,6 +6737,7 @@ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -6719,7 +6757,6 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -6814,7 +6851,6 @@ "integrity": "sha512-UlIZrRariB11TY1RtTgUWp65tphtBv4CSq7vyS2ZZ2TgoMjs2nloq+wFqxiwcxlhHUvs7DPGgMjs2aeQxz5h9g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", @@ -6892,7 +6928,8 @@ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/mlly": { "version": "1.8.0", @@ -6971,6 +7008,7 @@ "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -7111,6 +7149,7 @@ "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", @@ -7131,6 +7170,7 @@ "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" @@ -7182,6 +7222,7 @@ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -7314,7 +7355,8 @@ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/picocolors": { "version": "1.1.1", @@ -7565,7 +7607,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7764,6 +7805,7 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.4.0" } @@ -7774,6 +7816,7 @@ "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -7793,7 +7836,8 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pump": { "version": "3.0.3", @@ -7801,6 +7845,7 @@ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -7855,6 +7900,7 @@ "integrity": "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@puppeteer/browsers": "2.6.1", "chromium-bidi": "0.11.0", @@ -7922,7 +7968,6 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7933,7 +7978,6 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8005,6 +8049,7 @@ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8423,6 +8468,7 @@ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -8434,6 +8480,7 @@ "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" @@ -8449,6 +8496,7 @@ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -8475,6 +8523,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8519,6 +8568,7 @@ "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", @@ -8550,6 +8600,7 @@ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -8682,7 +8733,6 @@ "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8721,6 +8771,7 @@ "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" @@ -8736,6 +8787,7 @@ "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", @@ -8748,6 +8800,7 @@ "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "b4a": "^1.6.4" } @@ -8797,7 +8850,8 @@ "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinybench": { "version": "2.9.0", @@ -8857,7 +8911,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8977,7 +9030,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -9471,7 +9523,8 @@ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/typescript": { "version": "5.9.3", @@ -9479,7 +9532,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9507,6 +9559,7 @@ "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" @@ -9585,7 +9638,6 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -9694,7 +9746,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10012,6 +10063,7 @@ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -10094,6 +10146,7 @@ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=10" } @@ -10104,7 +10157,6 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -10118,6 +10170,7 @@ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -10137,6 +10190,7 @@ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -10147,6 +10201,7 @@ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" @@ -10171,9 +10226,15 @@ "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "packages/core": { + "name": "@mdview/core", + "version": "0.0.1", + "license": "MIT" } } } diff --git a/package.json b/package.json index 8371654..7305cda 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "0.3.4", "description": "Beautiful Chrome extension for viewing Markdown files with themes, syntax highlighting, and interactive Mermaid diagrams", "type": "module", + "workspaces": [ + "packages/*" + ], "scripts": { "dev": "vite build --watch", "build": "node scripts/update-manifest-version.js && tsc && vite build && node scripts/fix-manifest.js", diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..8f21f63 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@mdview/core", + "version": "0.0.1", + "description": "Core rendering engine for mdview", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest", + "test:ci": "vitest --run" + }, + "author": "James Ainslie", + "license": "MIT" +} diff --git a/packages/core/src/__tests__/smoke.test.ts b/packages/core/src/__tests__/smoke.test.ts new file mode 100644 index 0000000..41f6435 --- /dev/null +++ b/packages/core/src/__tests__/smoke.test.ts @@ -0,0 +1,7 @@ +import { VERSION } from '../index'; + +describe('@mdview/core', () => { + it('should export VERSION', () => { + expect(VERSION).toBe('0.0.1'); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..1da5182 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,3 @@ +// @mdview/core - shared rendering engine +// Modules will be exported here as they are extracted from the Chrome extension. +export const VERSION = '0.0.1'; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..06014a2 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "baseUrl": ".", + "paths": {}, + "types": ["vitest/globals"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..c30a1fe --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + testTimeout: 10000, + }, +}); From e299cdee14fc9ee147acf6719311aac95a4c0c80 Mon Sep 17 00:00:00 2001 From: James Ainslie <42301770+jamesainslie@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:10:36 -0400 Subject: [PATCH 03/26] chore: configure vitest workspace for monorepo --- vitest.workspace.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 vitest.workspace.ts diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 0000000..ee916e3 --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,19 @@ +import { defineWorkspace } from 'vitest/config'; + +export default defineWorkspace([ + { + extends: './vitest.config.ts', + test: { + name: 'chrome-ext', + include: ['tests/**/*.test.ts'], + }, + }, + { + extends: './packages/core/vitest.config.ts', + test: { + name: 'core', + root: './packages/core', + include: ['src/**/*.test.ts'], + }, + }, +]); From 31fe07e3c9645018e71262c5ae0623b9bcd693b3 Mon Sep 17 00:00:00 2001 From: James Ainslie <42301770+jamesainslie@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:14:36 -0400 Subject: [PATCH 04/26] refactor(core): move type definitions to @mdview/core --- packages/core/src/__tests__/types.test.ts | 140 +++++ packages/core/src/types/index.d.ts | 584 +++++++++++++++++ .../core/src/types/markdown-it-plugins.d.ts | 29 + src/types/index.d.ts | 587 +----------------- 4 files changed, 756 insertions(+), 584 deletions(-) create mode 100644 packages/core/src/__tests__/types.test.ts create mode 100644 packages/core/src/types/index.d.ts create mode 100644 packages/core/src/types/markdown-it-plugins.d.ts diff --git a/packages/core/src/__tests__/types.test.ts b/packages/core/src/__tests__/types.test.ts new file mode 100644 index 0000000..907d131 --- /dev/null +++ b/packages/core/src/__tests__/types.test.ts @@ -0,0 +1,140 @@ +import type { + Theme, + ThemeName, + AppState, + ConversionResult, + Comment, + CommentParseResult, + ExportFormat, + PaperSize, + ContentNode, + CachedResult, + WorkerTask, +} from '../types/index'; + +describe('@mdview/core types', () => { + it('should allow creating a Theme object', () => { + const theme: Theme = { + name: 'github-light', + displayName: 'GitHub Light', + variant: 'light', + author: 'test', + version: '1.0.0', + colors: { + background: '#fff', + backgroundSecondary: '#f6f8fa', + backgroundTertiary: '#eee', + foreground: '#24292e', + foregroundSecondary: '#586069', + foregroundMuted: '#6a737d', + primary: '#0366d6', + secondary: '#6f42c1', + accent: '#e36209', + heading: '#24292e', + link: '#0366d6', + linkHover: '#0366d6', + linkVisited: '#6f42c1', + codeBackground: '#f6f8fa', + codeText: '#24292e', + codeKeyword: '#d73a49', + codeString: '#032f62', + codeComment: '#6a737d', + codeFunction: '#6f42c1', + border: '#e1e4e8', + borderLight: '#eaecef', + borderHeavy: '#d1d5da', + selection: '#0366d633', + highlight: '#fff3cd', + shadow: 'rgba(27,31,35,0.15)', + success: '#28a745', + warning: '#ffd33d', + error: '#d73a49', + info: '#0366d6', + commentHighlight: '#fff3cd', + commentHighlightResolved: '#dcffe4', + commentCardBg: '#ffffff', + }, + typography: { + fontFamily: 'system-ui', + codeFontFamily: 'monospace', + baseFontSize: '16px', + baseLineHeight: 1.6, + h1Size: '2em', + h2Size: '1.5em', + h3Size: '1.25em', + h4Size: '1em', + h5Size: '0.875em', + h6Size: '0.85em', + fontWeightNormal: 400, + fontWeightBold: 600, + headingFontWeight: 600, + }, + spacing: { + blockMargin: '16px', + paragraphMargin: '16px', + listItemMargin: '4px', + headingMargin: '24px', + codeBlockPadding: '16px', + tableCellPadding: '6px 13px', + }, + syntaxTheme: 'github', + mermaidTheme: { + theme: 'default', + themeVariables: { + primaryColor: '#0366d6', + primaryTextColor: '#24292e', + primaryBorderColor: '#e1e4e8', + lineColor: '#586069', + secondaryColor: '#6f42c1', + tertiaryColor: '#e36209', + background: '#fff', + mainBkg: '#fff', + }, + }, + }; + expect(theme.name).toBe('github-light'); + expect(theme.variant).toBe('light'); + }); + + it('should allow ThemeName literals', () => { + const names: ThemeName[] = [ + 'github-light', + 'github-dark', + 'catppuccin-latte', + 'catppuccin-frappe', + 'catppuccin-macchiato', + 'catppuccin-mocha', + 'monokai', + 'monokai-pro', + ]; + expect(names).toHaveLength(8); + }); + + it('should allow ExportFormat and PaperSize types', () => { + const format: ExportFormat = 'docx'; + const size: PaperSize = 'A4'; + expect(format).toBe('docx'); + expect(size).toBe('A4'); + }); + + it('should allow Comment type with all fields', () => { + const comment: Comment = { + id: 'comment-1', + selectedText: 'test', + body: 'a comment', + author: 'tester', + date: new Date().toISOString(), + resolved: false, + context: { + line: 1, + section: 'Introduction', + sectionLevel: 1, + breadcrumb: ['Introduction'], + }, + tags: ['nit', 'suggestion'], + replies: [{ id: 'reply-1', author: 'reviewer', body: 'agreed', date: new Date().toISOString() }], + reactions: { '👍': ['tester'] }, + }; + expect(comment.id).toBe('comment-1'); + }); +}); diff --git a/packages/core/src/types/index.d.ts b/packages/core/src/types/index.d.ts new file mode 100644 index 0000000..1dce0fd --- /dev/null +++ b/packages/core/src/types/index.d.ts @@ -0,0 +1,584 @@ +// Type definitions for MDView + +/** + * Global window extensions + */ +declare global { + const __APP_VERSION__: string; + + interface Window { + __MDVIEW_MERMAID_CODE__?: Map; + } +} + +export type ThemeName = + | 'github-light' + | 'github-dark' + | 'catppuccin-latte' + | 'catppuccin-frappe' + | 'catppuccin-macchiato' + | 'catppuccin-mocha' + | 'monokai' + | 'monokai-pro' + | 'test-theme'; // For testing + +export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug'; + +export interface AppState { + preferences: { + theme: ThemeName; + autoTheme: boolean; + lightTheme: ThemeName; + darkTheme: ThemeName; + syntaxTheme: string; + autoReload: boolean; + lineNumbers: boolean; + enableHtml: boolean; // Enable HTML rendering in markdown + syncTabs: boolean; + logLevel: LogLevel; + debug?: boolean; // Deprecated + // Editor / Appearance Overrides + fontFamily?: string; + codeFontFamily?: string; + lineHeight?: number; + maxWidth?: number; + useMaxWidth?: boolean; // Toggle for full width + // Table of Contents + showToc?: boolean; // Enable/disable TOC + tocMaxDepth?: number; // Max heading depth (1-6) + tocAutoCollapse?: boolean; // Auto-collapse nested sections + tocPosition?: 'left' | 'right'; // Position of TOC + tocStyle?: 'floating' | 'fixed'; // Style of TOC (floating card or fixed sidebar) + // Comments + commentsEnabled?: boolean; // Enable/disable comments feature + commentAuthor?: string; // Author name for new comments + // Export settings + exportDefaultFormat?: 'docx' | 'pdf'; + exportDefaultPageSize?: PaperSize; + exportIncludeToc?: boolean; + exportFilenameTemplate?: string; // e.g., "{title}-{date}" + // Site blocklist - URLs/patterns where MDView should not render + blockedSites?: string[]; // e.g., ["github.com", "*.gitlab.com/*/blob/*"] + }; + document: { + path: string; + content: string; + scrollPosition: number; + renderState: 'pending' | 'rendering' | 'complete' | 'error'; + }; + ui: { + theme: Theme | null; + maximizedDiagram: string | null; + visibleDiagrams: Set; + tocVisible?: boolean; // Current TOC visibility state + }; +} + +export interface Theme { + name: ThemeName; + displayName: string; + variant: 'light' | 'dark'; + author: string; + version: string; + colors: ThemeColors; + typography: ThemeTypography; + spacing: ThemeSpacing; + syntaxTheme: string; + mermaidTheme: MermaidThemeConfig; +} + +export interface ThemeColors { + background: string; + backgroundSecondary: string; + backgroundTertiary: string; + foreground: string; + foregroundSecondary: string; + foregroundMuted: string; + primary: string; + secondary: string; + accent: string; + heading: string; + link: string; + linkHover: string; + linkVisited: string; + codeBackground: string; + codeText: string; + codeKeyword: string; + codeString: string; + codeComment: string; + codeFunction: string; + border: string; + borderLight: string; + borderHeavy: string; + selection: string; + highlight: string; + shadow: string; + success: string; + warning: string; + error: string; + info: string; + // Comment highlighting + commentHighlight: string; + commentHighlightResolved: string; + commentCardBg: string; +} + +export interface ThemeTypography { + fontFamily: string; + headingFontFamily?: string; + codeFontFamily: string; + baseFontSize: string; + baseLineHeight: number; + h1Size: string; + h2Size: string; + h3Size: string; + h4Size: string; + h5Size: string; + h6Size: string; + fontWeightNormal: number; + fontWeightBold: number; + headingFontWeight: number; +} + +export interface ThemeSpacing { + blockMargin: string; + paragraphMargin: string; + listItemMargin: string; + headingMargin: string; + codeBlockPadding: string; + tableCellPadding: string; +} + +export interface MermaidThemeConfig { + theme: 'base' | 'dark' | 'default' | 'forest' | 'neutral'; + themeVariables: { + primaryColor: string; + primaryTextColor: string; + primaryBorderColor: string; + lineColor: string; + secondaryColor: string; + tertiaryColor: string; + background: string; + mainBkg: string; + [key: string]: string; + }; +} + +export interface ConversionResult { + html: string; + metadata: { + wordCount: number; + headings: HeadingInfo[]; + codeBlocks: CodeBlockInfo[]; + mermaidBlocks: MermaidBlockInfo[]; + images: ImageInfo[]; + links: LinkInfo[]; + frontmatter: Record | null; + }; + errors: ParseError[]; +} + +export interface HeadingInfo { + level: number; + text: string; + id: string; + line: number; +} + +export interface CodeBlockInfo { + language: string; + code: string; + line: number; + lines: number; +} + +export interface MermaidBlockInfo { + code: string; + line: number; +} + +export interface ImageInfo { + src: string; + alt: string; + title?: string; + line: number; +} + +export interface LinkInfo { + href: string; + text: string; + line: number; +} + +export interface ParseError { + message: string; + line: number; + column: number; + severity: 'error' | 'warning'; +} + +export interface ValidationResult { + valid: boolean; + errors: ParseError[]; + warnings: ParseError[]; +} + +// Cache types +export interface CachedResult { + html: string; + metadata: ConversionResult['metadata']; + highlightedBlocks: Map; + mermaidSVGs: Map; + timestamp: number; + cacheKey: string; +} + +export interface CacheEntry { + result: CachedResult; + filePath: string; + contentHash: string; + theme: ThemeName; + lastAccessed: number; +} + +// Worker types +export type WorkerTaskType = 'parse' | 'highlight' | 'mermaid'; + +export interface WorkerTask { + type: WorkerTaskType; + id: string; + payload: unknown; + priority?: number; +} + +// Message types for communication between content script and service worker +export type MessageType = + | 'GET_STATE' + | 'UPDATE_PREFERENCES' + | 'APPLY_THEME' + | 'CACHE_GENERATE_KEY' + | 'CACHE_GET' + | 'CACHE_SET' + | 'CACHE_INVALIDATE' + | 'CACHE_INVALIDATE_BY_PATH' + | 'CACHE_STATS' + | 'REPORT_ERROR' + | 'CHECK_FILE_CHANGED' // New message type + | 'PREFERENCES_UPDATED' + | 'RELOAD_CONTENT'; + +export interface WorkerResponse { + id: string; + result?: unknown; + error?: string; +} + +export interface ParseTaskPayload { + markdown: string; + options?: { + breaks?: boolean; + linkify?: boolean; + typographer?: boolean; + enableHtml?: boolean; + }; +} + +export interface ParseTaskResult { + html: string; + metadata: ConversionResult['metadata']; +} + +export interface HighlightTaskPayload { + code: string; + language: string; +} + +export interface HighlightTaskResult { + html: string; + language: string; +} + +export interface MermaidTaskPayload { + code: string; + theme?: MermaidThemeConfig; + id: string; +} + +export interface MermaidTaskResult { + svg: string; + id: string; +} + +// Export feature types + +/** + * Supported export formats + */ +export type ExportFormat = 'docx' | 'pdf'; + +/** + * Standard paper sizes for PDF export + * ISO A-series: A0, A1, A3, A4, A5, A6 + * North American: Letter, Legal, Tabloid, Executive + */ +export type PaperSize = + | 'A0' + | 'A1' + | 'A3' + | 'A4' + | 'A5' + | 'A6' + | 'Letter' + | 'Legal' + | 'Tabloid' + | 'Executive'; + +/** + * Content node types for export + */ +export type ContentNodeType = + | 'heading' + | 'paragraph' + | 'list' + | 'code' + | 'table' + | 'image' + | 'mermaid' + | 'blockquote' + | 'hr'; + +/** + * Structured content node for export + */ +export interface ContentNode { + type: ContentNodeType; + content: string | ContentNode[]; + attributes: { + level?: number; // For headings (1-6) + language?: string; // For code blocks + ordered?: boolean; // For lists + id?: string; // For headings, images, mermaid + src?: string; // For images + alt?: string; // For images + [key: string]: unknown; + }; + children?: ContentNode[]; +} + +/** + * Collected content from rendered markdown + */ +export interface CollectedContent { + title: string; + nodes: ContentNode[]; + metadata: { + wordCount: number; + imageCount: number; + mermaidCount: number; + exportedAt: Date; + }; +} + +/** + * SVG conversion options + */ +export interface SVGConversionOptions { + scale?: number; // Default: 2 (retina) - only used for raster formats + format?: 'png' | 'jpeg' | 'svg'; // 'svg' preserves vector format + quality?: number; // JPEG quality 0-1 + backgroundColor?: string; +} + +/** + * Converted image data + */ +export interface ConvertedImage { + id: string; + data: string; // base64 encoded + width: number; + height: number; + format: string; +} + +/** + * Export options + */ +export interface ExportOptions { + format: ExportFormat; + filename?: string; + includeImages?: boolean; + includeMermaid?: boolean; +} + +/** + * Export progress information + */ +export interface ExportProgress { + stage: 'collecting' | 'converting' | 'generating' | 'downloading'; + progress: number; // 0-100 + message: string; +} + +/** + * Progress callback function + */ +export type ProgressCallback = (progress: ExportProgress) => void; + +/** + * DOCX Generator options + */ +export interface DOCXGeneratorOptions { + title?: string; + author?: string; + includeTableOfContents?: boolean; + pageSize?: PaperSize; + margins?: { + top: number; + bottom: number; + left: number; + right: number; + }; +} + +/** + * Export UI Options + */ +export interface ExportUIOptions { + position?: 'left' | 'right'; + formats?: ExportFormat[]; + defaultPageSize?: PaperSize; + defaultFormat?: ExportFormat; + includeTableOfContents?: boolean; + filenameTemplate?: string; +} + +/** + * Export UI State + */ +export interface ExportUIState { + isMenuOpen: boolean; + isExporting: boolean; + currentProgress: ExportProgress | null; + lastError: Error | null; +} + +/** + * PDF Generator options + */ +export interface PDFGeneratorOptions { + paperSize?: PaperSize; + orientation?: 'portrait' | 'landscape'; + margins?: string; // CSS margin value + convertSvgsToImages?: boolean; + pageBreakBeforeHeadings?: boolean; +} + +/** + * Filename template variables + */ +export type FilenameTemplateVar = + | '{title}' // Document title + | '{date}' // YYYY-MM-DD + | '{datetime}' // YYYY-MM-DD_HH-mm + | '{timestamp}' // Unix timestamp + | '{year}' // YYYY + | '{month}' // MM + | '{day}'; // DD + +// Comment feature types + +/** + * A reply within a comment thread + */ +export interface CommentReply { + id: string; // "reply-1", "reply-2", scoped within parent + author: string; + body: string; + date: string; // ISO 8601 +} + +/** + * Emoji reactions on a comment: emoji char → author names + */ +export type CommentReactions = Record; + +/** + * Tag that can be applied to a comment to signal severity/intent. + */ +export type CommentTag = + | 'blocking' + | 'nit' + | 'suggestion' + | 'question' + | 'praise' + | 'todo' + | 'fyi'; + +/** + * Positional context for a comment within the document structure. + * Computed at comment-creation time from the source offset and document headings. + * Stored in the footnote metadata so AI agents can read it directly from the file. + */ +export interface CommentContext { + /** 1-based line number where the commented text appears */ + line: number; + /** Nearest heading text above the comment (undefined if before any heading) */ + section?: string; + /** Heading level of the nearest heading (1-6, undefined if before any heading) */ + sectionLevel?: number; + /** Heading hierarchy from root to the containing section */ + breadcrumb: string[]; +} + +/** + * A single comment attached to text in the markdown + */ +export interface Comment { + id: string; // e.g. "comment-1" + selectedText: string; // The text the comment is anchored to + body: string; // The comment content + author: string; // From extension settings + date: string; // ISO 8601 timestamp + resolved: boolean; // Whether the comment has been resolved + /** Positional context within the document; undefined for legacy comments */ + context?: CommentContext; + /** Tags for categorizing comment severity/intent */ + tags?: CommentTag[]; + /** Threaded replies */ + replies?: CommentReply[]; + /** Emoji reactions */ + reactions?: CommentReactions; + /** Prefix context for TextQuoteSelector anchoring */ + anchorPrefix?: string; + /** Suffix context for TextQuoteSelector anchoring */ + anchorSuffix?: string; +} + +/** + * Result of parsing comments from raw markdown + */ +export interface CommentParseResult { + cleanedMarkdown: string; // Markdown with comment footnotes stripped + comments: Comment[]; // Extracted comments +} + +/** + * Comment metadata stored in the footnote HTML comment + */ +export interface CommentMetadata { + author: string; + date: string; + resolved?: boolean; + selectedText?: string; + // Positional context fields (optional for backward compatibility) + line?: number; + section?: string; + sectionLevel?: number; + breadcrumb?: string[]; + /** Tags for categorizing comment severity/intent */ + tags?: CommentTag[]; + /** Threaded replies */ + replies?: CommentReply[]; + /** Emoji reactions */ + reactions?: CommentReactions; +} diff --git a/packages/core/src/types/markdown-it-plugins.d.ts b/packages/core/src/types/markdown-it-plugins.d.ts new file mode 100644 index 0000000..ea3eddb --- /dev/null +++ b/packages/core/src/types/markdown-it-plugins.d.ts @@ -0,0 +1,29 @@ +/** + * Type declarations for markdown-it plugins without official types + */ + +declare module 'markdown-it-attrs' { + import type MarkdownIt from 'markdown-it'; + const markdownItAttrs: MarkdownIt.PluginWithOptions; + export default markdownItAttrs; +} + +declare module 'markdown-it-task-lists' { + import type MarkdownIt from 'markdown-it'; + const markdownItTaskLists: MarkdownIt.PluginWithOptions; + export default markdownItTaskLists; +} + +declare module 'markdown-it-emoji' { + import type MarkdownIt from 'markdown-it'; + const markdownItEmoji: MarkdownIt.PluginSimple; + export default markdownItEmoji; +} + +declare module 'markdown-it-footnote' { + import type MarkdownIt from 'markdown-it'; + const markdownItFootnote: MarkdownIt.PluginSimple; + export default markdownItFootnote; +} + + diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 1dce0fd..81ebc2c 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,584 +1,3 @@ -// Type definitions for MDView - -/** - * Global window extensions - */ -declare global { - const __APP_VERSION__: string; - - interface Window { - __MDVIEW_MERMAID_CODE__?: Map; - } -} - -export type ThemeName = - | 'github-light' - | 'github-dark' - | 'catppuccin-latte' - | 'catppuccin-frappe' - | 'catppuccin-macchiato' - | 'catppuccin-mocha' - | 'monokai' - | 'monokai-pro' - | 'test-theme'; // For testing - -export type LogLevel = 'none' | 'error' | 'warn' | 'info' | 'debug'; - -export interface AppState { - preferences: { - theme: ThemeName; - autoTheme: boolean; - lightTheme: ThemeName; - darkTheme: ThemeName; - syntaxTheme: string; - autoReload: boolean; - lineNumbers: boolean; - enableHtml: boolean; // Enable HTML rendering in markdown - syncTabs: boolean; - logLevel: LogLevel; - debug?: boolean; // Deprecated - // Editor / Appearance Overrides - fontFamily?: string; - codeFontFamily?: string; - lineHeight?: number; - maxWidth?: number; - useMaxWidth?: boolean; // Toggle for full width - // Table of Contents - showToc?: boolean; // Enable/disable TOC - tocMaxDepth?: number; // Max heading depth (1-6) - tocAutoCollapse?: boolean; // Auto-collapse nested sections - tocPosition?: 'left' | 'right'; // Position of TOC - tocStyle?: 'floating' | 'fixed'; // Style of TOC (floating card or fixed sidebar) - // Comments - commentsEnabled?: boolean; // Enable/disable comments feature - commentAuthor?: string; // Author name for new comments - // Export settings - exportDefaultFormat?: 'docx' | 'pdf'; - exportDefaultPageSize?: PaperSize; - exportIncludeToc?: boolean; - exportFilenameTemplate?: string; // e.g., "{title}-{date}" - // Site blocklist - URLs/patterns where MDView should not render - blockedSites?: string[]; // e.g., ["github.com", "*.gitlab.com/*/blob/*"] - }; - document: { - path: string; - content: string; - scrollPosition: number; - renderState: 'pending' | 'rendering' | 'complete' | 'error'; - }; - ui: { - theme: Theme | null; - maximizedDiagram: string | null; - visibleDiagrams: Set; - tocVisible?: boolean; // Current TOC visibility state - }; -} - -export interface Theme { - name: ThemeName; - displayName: string; - variant: 'light' | 'dark'; - author: string; - version: string; - colors: ThemeColors; - typography: ThemeTypography; - spacing: ThemeSpacing; - syntaxTheme: string; - mermaidTheme: MermaidThemeConfig; -} - -export interface ThemeColors { - background: string; - backgroundSecondary: string; - backgroundTertiary: string; - foreground: string; - foregroundSecondary: string; - foregroundMuted: string; - primary: string; - secondary: string; - accent: string; - heading: string; - link: string; - linkHover: string; - linkVisited: string; - codeBackground: string; - codeText: string; - codeKeyword: string; - codeString: string; - codeComment: string; - codeFunction: string; - border: string; - borderLight: string; - borderHeavy: string; - selection: string; - highlight: string; - shadow: string; - success: string; - warning: string; - error: string; - info: string; - // Comment highlighting - commentHighlight: string; - commentHighlightResolved: string; - commentCardBg: string; -} - -export interface ThemeTypography { - fontFamily: string; - headingFontFamily?: string; - codeFontFamily: string; - baseFontSize: string; - baseLineHeight: number; - h1Size: string; - h2Size: string; - h3Size: string; - h4Size: string; - h5Size: string; - h6Size: string; - fontWeightNormal: number; - fontWeightBold: number; - headingFontWeight: number; -} - -export interface ThemeSpacing { - blockMargin: string; - paragraphMargin: string; - listItemMargin: string; - headingMargin: string; - codeBlockPadding: string; - tableCellPadding: string; -} - -export interface MermaidThemeConfig { - theme: 'base' | 'dark' | 'default' | 'forest' | 'neutral'; - themeVariables: { - primaryColor: string; - primaryTextColor: string; - primaryBorderColor: string; - lineColor: string; - secondaryColor: string; - tertiaryColor: string; - background: string; - mainBkg: string; - [key: string]: string; - }; -} - -export interface ConversionResult { - html: string; - metadata: { - wordCount: number; - headings: HeadingInfo[]; - codeBlocks: CodeBlockInfo[]; - mermaidBlocks: MermaidBlockInfo[]; - images: ImageInfo[]; - links: LinkInfo[]; - frontmatter: Record | null; - }; - errors: ParseError[]; -} - -export interface HeadingInfo { - level: number; - text: string; - id: string; - line: number; -} - -export interface CodeBlockInfo { - language: string; - code: string; - line: number; - lines: number; -} - -export interface MermaidBlockInfo { - code: string; - line: number; -} - -export interface ImageInfo { - src: string; - alt: string; - title?: string; - line: number; -} - -export interface LinkInfo { - href: string; - text: string; - line: number; -} - -export interface ParseError { - message: string; - line: number; - column: number; - severity: 'error' | 'warning'; -} - -export interface ValidationResult { - valid: boolean; - errors: ParseError[]; - warnings: ParseError[]; -} - -// Cache types -export interface CachedResult { - html: string; - metadata: ConversionResult['metadata']; - highlightedBlocks: Map; - mermaidSVGs: Map; - timestamp: number; - cacheKey: string; -} - -export interface CacheEntry { - result: CachedResult; - filePath: string; - contentHash: string; - theme: ThemeName; - lastAccessed: number; -} - -// Worker types -export type WorkerTaskType = 'parse' | 'highlight' | 'mermaid'; - -export interface WorkerTask { - type: WorkerTaskType; - id: string; - payload: unknown; - priority?: number; -} - -// Message types for communication between content script and service worker -export type MessageType = - | 'GET_STATE' - | 'UPDATE_PREFERENCES' - | 'APPLY_THEME' - | 'CACHE_GENERATE_KEY' - | 'CACHE_GET' - | 'CACHE_SET' - | 'CACHE_INVALIDATE' - | 'CACHE_INVALIDATE_BY_PATH' - | 'CACHE_STATS' - | 'REPORT_ERROR' - | 'CHECK_FILE_CHANGED' // New message type - | 'PREFERENCES_UPDATED' - | 'RELOAD_CONTENT'; - -export interface WorkerResponse { - id: string; - result?: unknown; - error?: string; -} - -export interface ParseTaskPayload { - markdown: string; - options?: { - breaks?: boolean; - linkify?: boolean; - typographer?: boolean; - enableHtml?: boolean; - }; -} - -export interface ParseTaskResult { - html: string; - metadata: ConversionResult['metadata']; -} - -export interface HighlightTaskPayload { - code: string; - language: string; -} - -export interface HighlightTaskResult { - html: string; - language: string; -} - -export interface MermaidTaskPayload { - code: string; - theme?: MermaidThemeConfig; - id: string; -} - -export interface MermaidTaskResult { - svg: string; - id: string; -} - -// Export feature types - -/** - * Supported export formats - */ -export type ExportFormat = 'docx' | 'pdf'; - -/** - * Standard paper sizes for PDF export - * ISO A-series: A0, A1, A3, A4, A5, A6 - * North American: Letter, Legal, Tabloid, Executive - */ -export type PaperSize = - | 'A0' - | 'A1' - | 'A3' - | 'A4' - | 'A5' - | 'A6' - | 'Letter' - | 'Legal' - | 'Tabloid' - | 'Executive'; - -/** - * Content node types for export - */ -export type ContentNodeType = - | 'heading' - | 'paragraph' - | 'list' - | 'code' - | 'table' - | 'image' - | 'mermaid' - | 'blockquote' - | 'hr'; - -/** - * Structured content node for export - */ -export interface ContentNode { - type: ContentNodeType; - content: string | ContentNode[]; - attributes: { - level?: number; // For headings (1-6) - language?: string; // For code blocks - ordered?: boolean; // For lists - id?: string; // For headings, images, mermaid - src?: string; // For images - alt?: string; // For images - [key: string]: unknown; - }; - children?: ContentNode[]; -} - -/** - * Collected content from rendered markdown - */ -export interface CollectedContent { - title: string; - nodes: ContentNode[]; - metadata: { - wordCount: number; - imageCount: number; - mermaidCount: number; - exportedAt: Date; - }; -} - -/** - * SVG conversion options - */ -export interface SVGConversionOptions { - scale?: number; // Default: 2 (retina) - only used for raster formats - format?: 'png' | 'jpeg' | 'svg'; // 'svg' preserves vector format - quality?: number; // JPEG quality 0-1 - backgroundColor?: string; -} - -/** - * Converted image data - */ -export interface ConvertedImage { - id: string; - data: string; // base64 encoded - width: number; - height: number; - format: string; -} - -/** - * Export options - */ -export interface ExportOptions { - format: ExportFormat; - filename?: string; - includeImages?: boolean; - includeMermaid?: boolean; -} - -/** - * Export progress information - */ -export interface ExportProgress { - stage: 'collecting' | 'converting' | 'generating' | 'downloading'; - progress: number; // 0-100 - message: string; -} - -/** - * Progress callback function - */ -export type ProgressCallback = (progress: ExportProgress) => void; - -/** - * DOCX Generator options - */ -export interface DOCXGeneratorOptions { - title?: string; - author?: string; - includeTableOfContents?: boolean; - pageSize?: PaperSize; - margins?: { - top: number; - bottom: number; - left: number; - right: number; - }; -} - -/** - * Export UI Options - */ -export interface ExportUIOptions { - position?: 'left' | 'right'; - formats?: ExportFormat[]; - defaultPageSize?: PaperSize; - defaultFormat?: ExportFormat; - includeTableOfContents?: boolean; - filenameTemplate?: string; -} - -/** - * Export UI State - */ -export interface ExportUIState { - isMenuOpen: boolean; - isExporting: boolean; - currentProgress: ExportProgress | null; - lastError: Error | null; -} - -/** - * PDF Generator options - */ -export interface PDFGeneratorOptions { - paperSize?: PaperSize; - orientation?: 'portrait' | 'landscape'; - margins?: string; // CSS margin value - convertSvgsToImages?: boolean; - pageBreakBeforeHeadings?: boolean; -} - -/** - * Filename template variables - */ -export type FilenameTemplateVar = - | '{title}' // Document title - | '{date}' // YYYY-MM-DD - | '{datetime}' // YYYY-MM-DD_HH-mm - | '{timestamp}' // Unix timestamp - | '{year}' // YYYY - | '{month}' // MM - | '{day}'; // DD - -// Comment feature types - -/** - * A reply within a comment thread - */ -export interface CommentReply { - id: string; // "reply-1", "reply-2", scoped within parent - author: string; - body: string; - date: string; // ISO 8601 -} - -/** - * Emoji reactions on a comment: emoji char → author names - */ -export type CommentReactions = Record; - -/** - * Tag that can be applied to a comment to signal severity/intent. - */ -export type CommentTag = - | 'blocking' - | 'nit' - | 'suggestion' - | 'question' - | 'praise' - | 'todo' - | 'fyi'; - -/** - * Positional context for a comment within the document structure. - * Computed at comment-creation time from the source offset and document headings. - * Stored in the footnote metadata so AI agents can read it directly from the file. - */ -export interface CommentContext { - /** 1-based line number where the commented text appears */ - line: number; - /** Nearest heading text above the comment (undefined if before any heading) */ - section?: string; - /** Heading level of the nearest heading (1-6, undefined if before any heading) */ - sectionLevel?: number; - /** Heading hierarchy from root to the containing section */ - breadcrumb: string[]; -} - -/** - * A single comment attached to text in the markdown - */ -export interface Comment { - id: string; // e.g. "comment-1" - selectedText: string; // The text the comment is anchored to - body: string; // The comment content - author: string; // From extension settings - date: string; // ISO 8601 timestamp - resolved: boolean; // Whether the comment has been resolved - /** Positional context within the document; undefined for legacy comments */ - context?: CommentContext; - /** Tags for categorizing comment severity/intent */ - tags?: CommentTag[]; - /** Threaded replies */ - replies?: CommentReply[]; - /** Emoji reactions */ - reactions?: CommentReactions; - /** Prefix context for TextQuoteSelector anchoring */ - anchorPrefix?: string; - /** Suffix context for TextQuoteSelector anchoring */ - anchorSuffix?: string; -} - -/** - * Result of parsing comments from raw markdown - */ -export interface CommentParseResult { - cleanedMarkdown: string; // Markdown with comment footnotes stripped - comments: Comment[]; // Extracted comments -} - -/** - * Comment metadata stored in the footnote HTML comment - */ -export interface CommentMetadata { - author: string; - date: string; - resolved?: boolean; - selectedText?: string; - // Positional context fields (optional for backward compatibility) - line?: number; - section?: string; - sectionLevel?: number; - breadcrumb?: string[]; - /** Tags for categorizing comment severity/intent */ - tags?: CommentTag[]; - /** Threaded replies */ - replies?: CommentReply[]; - /** Emoji reactions */ - reactions?: CommentReactions; -} +// Re-export shim: types have moved to @mdview/core +// This shim will be removed in Phase 2.9 +export * from '../../packages/core/src/types/index'; From 995c97ff4cd31b506d5a4b5d9d7958e8e2943cee Mon Sep 17 00:00:00 2001 From: James Ainslie <42301770+jamesainslie@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:16:57 -0400 Subject: [PATCH 05/26] refactor(core): move theme data modules to @mdview/core --- packages/core/src/__tests__/themes.test.ts | 60 +++++++++ packages/core/src/themes/catppuccin-frappe.ts | 110 +++++++++++++++ packages/core/src/themes/catppuccin-latte.ts | 110 +++++++++++++++ .../core/src/themes/catppuccin-macchiato.ts | 110 +++++++++++++++ packages/core/src/themes/catppuccin-mocha.ts | 110 +++++++++++++++ packages/core/src/themes/github-dark.ts | 126 +++++++++++++++++ packages/core/src/themes/github-light.ts | 126 +++++++++++++++++ packages/core/src/themes/monokai-pro.ts | 110 +++++++++++++++ packages/core/src/themes/monokai.ts | 110 +++++++++++++++ src/themes/catppuccin-frappe.ts | 111 +-------------- src/themes/catppuccin-latte.ts | 111 +-------------- src/themes/catppuccin-macchiato.ts | 111 +-------------- src/themes/catppuccin-mocha.ts | 111 +-------------- src/themes/github-dark.ts | 127 +----------------- src/themes/github-light.ts | 127 +----------------- src/themes/monokai-pro.ts | 111 +-------------- src/themes/monokai.ts | 111 +-------------- 17 files changed, 980 insertions(+), 912 deletions(-) create mode 100644 packages/core/src/__tests__/themes.test.ts create mode 100644 packages/core/src/themes/catppuccin-frappe.ts create mode 100644 packages/core/src/themes/catppuccin-latte.ts create mode 100644 packages/core/src/themes/catppuccin-macchiato.ts create mode 100644 packages/core/src/themes/catppuccin-mocha.ts create mode 100644 packages/core/src/themes/github-dark.ts create mode 100644 packages/core/src/themes/github-light.ts create mode 100644 packages/core/src/themes/monokai-pro.ts create mode 100644 packages/core/src/themes/monokai.ts diff --git a/packages/core/src/__tests__/themes.test.ts b/packages/core/src/__tests__/themes.test.ts new file mode 100644 index 0000000..af105c8 --- /dev/null +++ b/packages/core/src/__tests__/themes.test.ts @@ -0,0 +1,60 @@ +import type { Theme } from '../types/index'; + +import catppuccinFrappe from '../themes/catppuccin-frappe'; +import catppuccinLatte from '../themes/catppuccin-latte'; +import catppuccinMacchiato from '../themes/catppuccin-macchiato'; +import catppuccinMocha from '../themes/catppuccin-mocha'; +import githubDark from '../themes/github-dark'; +import githubLight from '../themes/github-light'; +import monokaiPro from '../themes/monokai-pro'; +import monokai from '../themes/monokai'; + +const requiredFields: (keyof Theme)[] = [ + 'name', + 'displayName', + 'variant', + 'colors', + 'typography', + 'spacing', + 'syntaxTheme', + 'mermaidTheme', +]; + +const themes: { label: string; theme: Theme }[] = [ + { label: 'catppuccin-frappe', theme: catppuccinFrappe }, + { label: 'catppuccin-latte', theme: catppuccinLatte }, + { label: 'catppuccin-macchiato', theme: catppuccinMacchiato }, + { label: 'catppuccin-mocha', theme: catppuccinMocha }, + { label: 'github-dark', theme: githubDark }, + { label: 'github-light', theme: githubLight }, + { label: 'monokai-pro', theme: monokaiPro }, + { label: 'monokai', theme: monokai }, +]; + +describe('@mdview/core themes', () => { + it.each(themes)('$label has all required Theme fields', ({ theme }) => { + for (const field of requiredFields) { + expect(theme).toHaveProperty(field); + expect(theme[field]).toBeDefined(); + } + }); + + it.each(themes)('$label has a valid variant', ({ theme }) => { + expect(['light', 'dark']).toContain(theme.variant); + }); + + it.each(themes)('$label has name matching its label', ({ label, theme }) => { + expect(theme.name).toBe(label); + }); + + it.each(themes)('$label has non-empty displayName', ({ theme }) => { + expect(theme.displayName.length).toBeGreaterThan(0); + }); + + it.each(themes)('$label has valid mermaidTheme', ({ theme }) => { + expect(theme.mermaidTheme).toHaveProperty('theme'); + expect(theme.mermaidTheme).toHaveProperty('themeVariables'); + expect(theme.mermaidTheme.themeVariables).toHaveProperty('primaryColor'); + expect(theme.mermaidTheme.themeVariables).toHaveProperty('primaryTextColor'); + }); +}); diff --git a/packages/core/src/themes/catppuccin-frappe.ts b/packages/core/src/themes/catppuccin-frappe.ts new file mode 100644 index 0000000..f60dfbe --- /dev/null +++ b/packages/core/src/themes/catppuccin-frappe.ts @@ -0,0 +1,110 @@ +/** + * Catppuccin Frappé Theme + * Light-medium variant of Catppuccin + */ + +import type { Theme } from '../types'; + +const catppuccinFrappe: Theme = { + name: 'catppuccin-frappe', + displayName: 'Catppuccin Frappé', + variant: 'light', + author: 'Catppuccin', + version: '1.0.0', + + colors: { + background: '#303446', + backgroundSecondary: '#292c3c', + backgroundTertiary: '#232634', + foreground: '#c6d0f5', + foregroundSecondary: '#b5bfe2', + foregroundMuted: '#a5adce', + primary: '#8caaee', + secondary: '#85c1dc', + accent: '#ca9ee6', + heading: '#c6d0f5', + link: '#8caaee', + linkHover: '#99d1db', + linkVisited: '#ca9ee6', + codeBackground: '#292c3c', + codeText: '#c6d0f5', + codeKeyword: '#e78284', + codeString: '#a6d189', + codeComment: '#737994', + codeFunction: '#ca9ee6', + border: '#414559', + borderLight: '#51576d', + borderHeavy: '#626880', + selection: 'rgba(140, 170, 238, 0.2)', + highlight: '#ef9f76', + shadow: 'rgba(0, 0, 0, 0.3)', + success: '#a6d189', + warning: '#ef9f76', + error: '#e78284', + info: '#8caaee', + commentHighlight: 'rgba(229, 200, 144, 0.25)', + commentHighlightResolved: 'rgba(229, 200, 144, 0.08)', + commentCardBg: '#292c3c', + }, + + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', + codeFontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + baseFontSize: '16px', + baseLineHeight: 1.5, + h1Size: '2em', + h2Size: '1.5em', + h3Size: '1.25em', + h4Size: '1em', + h5Size: '0.875em', + h6Size: '0.85em', + fontWeightNormal: 400, + fontWeightBold: 600, + headingFontWeight: 600, + }, + + spacing: { + blockMargin: '16px 0', + paragraphMargin: '16px 0', + listItemMargin: '4px 0', + headingMargin: '24px 0 16px 0', + codeBlockPadding: '16px', + tableCellPadding: '6px 13px', + }, + + syntaxTheme: 'github-dark', + + mermaidTheme: { + theme: 'dark', + themeVariables: { + primaryColor: '#8caaee', + primaryTextColor: '#303446', + primaryBorderColor: '#8caaee', + lineColor: '#c6d0f5', + secondaryColor: '#292c3c', + tertiaryColor: '#232634', + background: '#303446', + mainBkg: '#292c3c', + secondaryBkg: '#232634', + tertiaryBkg: '#303446', + textColor: '#c6d0f5', + tertiaryTextColor: '#c6d0f5', + nodeBkg: '#292c3c', + nodeBorder: '#414559', + clusterBkg: '#232634', + clusterBorder: '#414559', + defaultLinkColor: '#8caaee', + titleColor: '#c6d0f5', + edgeLabelBackground: '#303446', + // ER diagram specific + relationLabelColor: '#c6d0f5', + relationLabelBackground: '#303446', + attributeBackgroundColorOdd: '#292c3c', + attributeBackgroundColorEven: '#232634', + }, + }, +}; + +export default catppuccinFrappe; diff --git a/packages/core/src/themes/catppuccin-latte.ts b/packages/core/src/themes/catppuccin-latte.ts new file mode 100644 index 0000000..abd63db --- /dev/null +++ b/packages/core/src/themes/catppuccin-latte.ts @@ -0,0 +1,110 @@ +/** + * Catppuccin Latte Theme + * Light variant of Catppuccin + */ + +import type { Theme } from '../types'; + +const catppuccinLatte: Theme = { + name: 'catppuccin-latte', + displayName: 'Catppuccin Latte', + variant: 'light', + author: 'Catppuccin', + version: '1.0.0', + + colors: { + background: '#eff1f5', + backgroundSecondary: '#e6e9ef', + backgroundTertiary: '#dce0e8', + foreground: '#4c4f69', + foregroundSecondary: '#5c5f77', + foregroundMuted: '#6c6f85', + primary: '#1e66f5', + secondary: '#7287fd', + accent: '#8839ef', + heading: '#4c4f69', + link: '#1e66f5', + linkHover: '#04a5e5', + linkVisited: '#8839ef', + codeBackground: '#e6e9ef', + codeText: '#4c4f69', + codeKeyword: '#d20f39', + codeString: '#40a02b', + codeComment: '#7c7f93', + codeFunction: '#8839ef', + border: '#dce0e8', + borderLight: '#e6e9ef', + borderHeavy: '#bcc0cc', + selection: 'rgba(30, 102, 245, 0.2)', + highlight: '#df8e1d', + shadow: 'rgba(0, 0, 0, 0.1)', + success: '#40a02b', + warning: '#df8e1d', + error: '#d20f39', + info: '#1e66f5', + commentHighlight: 'rgba(223, 142, 29, 0.25)', + commentHighlightResolved: 'rgba(223, 142, 29, 0.08)', + commentCardBg: '#e6e9ef', + }, + + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', + codeFontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + baseFontSize: '16px', + baseLineHeight: 1.5, + h1Size: '2em', + h2Size: '1.5em', + h3Size: '1.25em', + h4Size: '1em', + h5Size: '0.875em', + h6Size: '0.85em', + fontWeightNormal: 400, + fontWeightBold: 600, + headingFontWeight: 600, + }, + + spacing: { + blockMargin: '16px 0', + paragraphMargin: '16px 0', + listItemMargin: '4px 0', + headingMargin: '24px 0 16px 0', + codeBlockPadding: '16px', + tableCellPadding: '6px 13px', + }, + + syntaxTheme: 'github', + + mermaidTheme: { + theme: 'base', + themeVariables: { + primaryColor: '#1e66f5', + primaryTextColor: '#eff1f5', + primaryBorderColor: '#1e66f5', + lineColor: '#4c4f69', + secondaryColor: '#e6e9ef', + tertiaryColor: '#dce0e8', + background: '#eff1f5', + mainBkg: '#e6e9ef', + secondaryBkg: '#dce0e8', + tertiaryBkg: '#eff1f5', + textColor: '#4c4f69', + tertiaryTextColor: '#4c4f69', + nodeBkg: '#e6e9ef', + nodeBorder: '#dce0e8', + clusterBkg: '#dce0e8', + clusterBorder: '#dce0e8', + defaultLinkColor: '#1e66f5', + titleColor: '#4c4f69', + edgeLabelBackground: '#eff1f5', + // ER diagram specific + relationLabelColor: '#4c4f69', + relationLabelBackground: '#eff1f5', + attributeBackgroundColorOdd: '#e6e9ef', + attributeBackgroundColorEven: '#dce0e8', + }, + }, +}; + +export default catppuccinLatte; diff --git a/packages/core/src/themes/catppuccin-macchiato.ts b/packages/core/src/themes/catppuccin-macchiato.ts new file mode 100644 index 0000000..3c26e89 --- /dev/null +++ b/packages/core/src/themes/catppuccin-macchiato.ts @@ -0,0 +1,110 @@ +/** + * Catppuccin Macchiato Theme + * Dark-medium variant of Catppuccin + */ + +import type { Theme } from '../types'; + +const catppuccinMacchiato: Theme = { + name: 'catppuccin-macchiato', + displayName: 'Catppuccin Macchiato', + variant: 'dark', + author: 'Catppuccin', + version: '1.0.0', + + colors: { + background: '#24273a', + backgroundSecondary: '#1e2030', + backgroundTertiary: '#181926', + foreground: '#cad3f5', + foregroundSecondary: '#b8c0e0', + foregroundMuted: '#a5adcb', + primary: '#8aadf4', + secondary: '#7dc4e4', + accent: '#c6a0f6', + heading: '#cad3f5', + link: '#8aadf4', + linkHover: '#91d7e3', + linkVisited: '#c6a0f6', + codeBackground: '#1e2030', + codeText: '#cad3f5', + codeKeyword: '#ed8796', + codeString: '#a6da95', + codeComment: '#6e738d', + codeFunction: '#c6a0f6', + border: '#363a4f', + borderLight: '#494d64', + borderHeavy: '#5b6078', + selection: 'rgba(138, 173, 244, 0.2)', + highlight: '#f5a97f', + shadow: 'rgba(0, 0, 0, 0.4)', + success: '#a6da95', + warning: '#eed49f', + error: '#ed8796', + info: '#8aadf4', + commentHighlight: 'rgba(238, 212, 159, 0.25)', + commentHighlightResolved: 'rgba(238, 212, 159, 0.08)', + commentCardBg: '#1e2030', + }, + + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', + codeFontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + baseFontSize: '16px', + baseLineHeight: 1.5, + h1Size: '2em', + h2Size: '1.5em', + h3Size: '1.25em', + h4Size: '1em', + h5Size: '0.875em', + h6Size: '0.85em', + fontWeightNormal: 400, + fontWeightBold: 600, + headingFontWeight: 600, + }, + + spacing: { + blockMargin: '16px 0', + paragraphMargin: '16px 0', + listItemMargin: '4px 0', + headingMargin: '24px 0 16px 0', + codeBlockPadding: '16px', + tableCellPadding: '6px 13px', + }, + + syntaxTheme: 'github-dark', + + mermaidTheme: { + theme: 'dark', + themeVariables: { + primaryColor: '#8aadf4', + primaryTextColor: '#24273a', + primaryBorderColor: '#8aadf4', + lineColor: '#cad3f5', + secondaryColor: '#1e2030', + tertiaryColor: '#181926', + background: '#24273a', + mainBkg: '#1e2030', + secondaryBkg: '#181926', + tertiaryBkg: '#24273a', + textColor: '#cad3f5', + tertiaryTextColor: '#cad3f5', + nodeBkg: '#1e2030', + nodeBorder: '#363a4f', + clusterBkg: '#181926', + clusterBorder: '#363a4f', + defaultLinkColor: '#8aadf4', + titleColor: '#cad3f5', + edgeLabelBackground: '#24273a', + // ER diagram specific + relationLabelColor: '#cad3f5', + relationLabelBackground: '#24273a', + attributeBackgroundColorOdd: '#1e2030', + attributeBackgroundColorEven: '#181926', + }, + }, +}; + +export default catppuccinMacchiato; diff --git a/packages/core/src/themes/catppuccin-mocha.ts b/packages/core/src/themes/catppuccin-mocha.ts new file mode 100644 index 0000000..1c69a97 --- /dev/null +++ b/packages/core/src/themes/catppuccin-mocha.ts @@ -0,0 +1,110 @@ +/** + * Catppuccin Mocha Theme + * Dark variant of Catppuccin + */ + +import type { Theme } from '../types'; + +const catppuccinMocha: Theme = { + name: 'catppuccin-mocha', + displayName: 'Catppuccin Mocha', + variant: 'dark', + author: 'Catppuccin', + version: '1.0.0', + + colors: { + background: '#1e1e2e', + backgroundSecondary: '#181825', + backgroundTertiary: '#11111b', + foreground: '#cdd6f4', + foregroundSecondary: '#bac2de', + foregroundMuted: '#a6adc8', + primary: '#89b4fa', + secondary: '#74c7ec', + accent: '#cba6f7', + heading: '#cdd6f4', + link: '#89b4fa', + linkHover: '#89dceb', + linkVisited: '#cba6f7', + codeBackground: '#181825', + codeText: '#cdd6f4', + codeKeyword: '#f38ba8', + codeString: '#a6e3a1', + codeComment: '#6c7086', + codeFunction: '#cba6f7', + border: '#313244', + borderLight: '#45475a', + borderHeavy: '#585b70', + selection: 'rgba(137, 180, 250, 0.2)', + highlight: '#f9e2af', + shadow: 'rgba(0, 0, 0, 0.5)', + success: '#a6e3a1', + warning: '#f9e2af', + error: '#f38ba8', + info: '#89b4fa', + commentHighlight: 'rgba(249, 226, 175, 0.25)', + commentHighlightResolved: 'rgba(249, 226, 175, 0.08)', + commentCardBg: '#1e1e2e', + }, + + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', + codeFontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + baseFontSize: '16px', + baseLineHeight: 1.5, + h1Size: '2em', + h2Size: '1.5em', + h3Size: '1.25em', + h4Size: '1em', + h5Size: '0.875em', + h6Size: '0.85em', + fontWeightNormal: 400, + fontWeightBold: 600, + headingFontWeight: 600, + }, + + spacing: { + blockMargin: '16px 0', + paragraphMargin: '16px 0', + listItemMargin: '4px 0', + headingMargin: '24px 0 16px 0', + codeBlockPadding: '16px', + tableCellPadding: '6px 13px', + }, + + syntaxTheme: 'github-dark', + + mermaidTheme: { + theme: 'dark', + themeVariables: { + primaryColor: '#89b4fa', + primaryTextColor: '#1e1e2e', + primaryBorderColor: '#89b4fa', + lineColor: '#cdd6f4', + secondaryColor: '#181825', + tertiaryColor: '#11111b', + background: '#1e1e2e', + mainBkg: '#181825', + secondaryBkg: '#11111b', + tertiaryBkg: '#1e1e2e', + textColor: '#cdd6f4', + tertiaryTextColor: '#cdd6f4', + nodeBkg: '#181825', + nodeBorder: '#313244', + clusterBkg: '#11111b', + clusterBorder: '#313244', + defaultLinkColor: '#89b4fa', + titleColor: '#cdd6f4', + edgeLabelBackground: '#1e1e2e', + // ER diagram specific + relationLabelColor: '#cdd6f4', + relationLabelBackground: '#1e1e2e', + attributeBackgroundColorOdd: '#181825', + attributeBackgroundColorEven: '#11111b', + }, + }, +}; + +export default catppuccinMocha; diff --git a/packages/core/src/themes/github-dark.ts b/packages/core/src/themes/github-dark.ts new file mode 100644 index 0000000..9c940b7 --- /dev/null +++ b/packages/core/src/themes/github-dark.ts @@ -0,0 +1,126 @@ +/** + * GitHub Dark Theme + * Based on GitHub's dark mode styles + */ + +import type { Theme } from '../types'; + +const githubDark: Theme = { + name: 'github-dark', + displayName: 'GitHub Dark', + variant: 'dark', + author: 'GitHub', + version: '1.0.0', + + colors: { + background: '#0d1117', + backgroundSecondary: '#161b22', + backgroundTertiary: '#21262d', + foreground: '#c9d1d9', + foregroundSecondary: '#8b949e', + foregroundMuted: '#6e7681', + primary: '#58a6ff', + secondary: '#8b949e', + accent: '#58a6ff', + heading: '#c9d1d9', + link: '#58a6ff', + linkHover: '#79c0ff', + linkVisited: '#a371f7', + codeBackground: '#161b22', + codeText: '#c9d1d9', + codeKeyword: '#ff7b72', + codeString: '#a5d6ff', + codeComment: '#8b949e', + codeFunction: '#d2a8ff', + border: '#30363d', + borderLight: '#21262d', + borderHeavy: '#6e7681', + selection: 'rgba(88, 166, 255, 0.2)', + highlight: 'rgba(187, 128, 9, 0.3)', + shadow: 'rgba(0, 0, 0, 0.4)', + success: '#3fb950', + warning: '#d29922', + error: '#f85149', + info: '#58a6ff', + commentHighlight: 'rgba(255, 183, 77, 0.25)', + commentHighlightResolved: 'rgba(255, 183, 77, 0.08)', + commentCardBg: '#161b22', + }, + + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"', + codeFontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + baseFontSize: '16px', + baseLineHeight: 1.5, + h1Size: '2em', + h2Size: '1.5em', + h3Size: '1.25em', + h4Size: '1em', + h5Size: '0.875em', + h6Size: '0.85em', + fontWeightNormal: 400, + fontWeightBold: 600, + headingFontWeight: 600, + }, + + spacing: { + blockMargin: '16px 0', + paragraphMargin: '16px 0', + listItemMargin: '4px 0', + headingMargin: '24px 0 16px 0', + codeBlockPadding: '16px', + tableCellPadding: '6px 13px', + }, + + syntaxTheme: 'github-dark', + + mermaidTheme: { + theme: 'dark', + themeVariables: { + primaryColor: '#58a6ff', + primaryTextColor: '#0d1117', + primaryBorderColor: '#58a6ff', + lineColor: '#c9d1d9', + secondaryColor: '#161b22', + tertiaryColor: '#21262d', + background: '#0d1117', + mainBkg: '#161b22', + secondaryBkg: '#21262d', + tertiaryBkg: '#0d1117', + textColor: '#c9d1d9', + tertiaryTextColor: '#c9d1d9', + nodeBkg: '#161b22', + nodeBorder: '#30363d', + clusterBkg: '#21262d', + clusterBorder: '#30363d', + defaultLinkColor: '#58a6ff', + titleColor: '#c9d1d9', + edgeLabelBackground: '#0d1117', + // ER diagram specific + relationLabelColor: '#c9d1d9', + relationLabelBackground: '#0d1117', + attributeBackgroundColorOdd: '#161b22', + attributeBackgroundColorEven: '#21262d', + actorBorder: '#30363d', + actorBkg: '#161b22', + actorTextColor: '#c9d1d9', + actorLineColor: '#30363d', + signalColor: '#c9d1d9', + signalTextColor: '#0d1117', + labelBoxBkgColor: '#161b22', + labelBoxBorderColor: '#30363d', + labelTextColor: '#c9d1d9', + loopTextColor: '#c9d1d9', + noteBorderColor: '#30363d', + noteBkgColor: 'rgba(187, 128, 9, 0.3)', + noteTextColor: '#c9d1d9', + activationBorderColor: '#30363d', + activationBkgColor: '#21262d', + sequenceNumberColor: '#0d1117', + }, + }, +}; + +export default githubDark; diff --git a/packages/core/src/themes/github-light.ts b/packages/core/src/themes/github-light.ts new file mode 100644 index 0000000..5a79ae0 --- /dev/null +++ b/packages/core/src/themes/github-light.ts @@ -0,0 +1,126 @@ +/** + * GitHub Light Theme + * Based on GitHub's markdown styles + */ + +import type { Theme } from '../types'; + +const githubLight: Theme = { + name: 'github-light', + displayName: 'GitHub Light', + variant: 'light', + author: 'GitHub', + version: '1.0.0', + + colors: { + background: '#ffffff', + backgroundSecondary: '#f6f8fa', + backgroundTertiary: '#eaeef2', + foreground: '#24292f', + foregroundSecondary: '#57606a', + foregroundMuted: '#6e7781', + primary: '#0969da', + secondary: '#6e7781', + accent: '#0969da', + heading: '#1f2328', + link: '#0969da', + linkHover: '#0550ae', + linkVisited: '#8250df', + codeBackground: '#f6f8fa', + codeText: '#24292f', + codeKeyword: '#cf222e', + codeString: '#0a3069', + codeComment: '#6e7781', + codeFunction: '#8250df', + border: '#d0d7de', + borderLight: '#d8dee4', + borderHeavy: '#8c959f', + selection: 'rgba(9, 105, 218, 0.2)', + highlight: '#fff8c5', + shadow: 'rgba(0, 0, 0, 0.1)', + success: '#1a7f37', + warning: '#9a6700', + error: '#cf222e', + info: '#0969da', + commentHighlight: 'rgba(255, 212, 59, 0.3)', + commentHighlightResolved: 'rgba(255, 212, 59, 0.1)', + commentCardBg: '#f6f8fa', + }, + + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"', + codeFontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + baseFontSize: '16px', + baseLineHeight: 1.5, + h1Size: '2em', + h2Size: '1.5em', + h3Size: '1.25em', + h4Size: '1em', + h5Size: '0.875em', + h6Size: '0.85em', + fontWeightNormal: 400, + fontWeightBold: 600, + headingFontWeight: 600, + }, + + spacing: { + blockMargin: '16px 0', + paragraphMargin: '16px 0', + listItemMargin: '4px 0', + headingMargin: '24px 0 16px 0', + codeBlockPadding: '16px', + tableCellPadding: '6px 13px', + }, + + syntaxTheme: 'github', + + mermaidTheme: { + theme: 'base', + themeVariables: { + primaryColor: '#0969da', + primaryTextColor: '#000000', + primaryBorderColor: '#0969da', + lineColor: '#24292f', + secondaryColor: '#f6f8fa', + tertiaryColor: '#eaeef2', + background: '#ffffff', + mainBkg: '#f6f8fa', + secondaryBkg: '#eaeef2', + tertiaryBkg: '#ffffff', + textColor: '#24292f', + tertiaryTextColor: '#24292f', + nodeBkg: '#f6f8fa', + nodeBorder: '#d0d7de', + clusterBkg: '#eaeef2', + clusterBorder: '#d0d7de', + defaultLinkColor: '#0969da', + titleColor: '#1f2328', + edgeLabelBackground: '#ffffff', + // ER diagram specific + relationLabelColor: '#24292f', + relationLabelBackground: '#ffffff', + attributeBackgroundColorOdd: '#f6f8fa', + attributeBackgroundColorEven: '#ffffff', + actorBorder: '#d0d7de', + actorBkg: '#f6f8fa', + actorTextColor: '#24292f', + actorLineColor: '#d0d7de', + signalColor: '#24292f', + signalTextColor: '#24292f', + labelBoxBkgColor: '#f6f8fa', + labelBoxBorderColor: '#d0d7de', + labelTextColor: '#24292f', + loopTextColor: '#24292f', + noteBorderColor: '#d0d7de', + noteBkgColor: '#fff8c5', + noteTextColor: '#24292f', + activationBorderColor: '#d0d7de', + activationBkgColor: '#eaeef2', + sequenceNumberColor: '#ffffff', + }, + }, +}; + +export default githubLight; diff --git a/packages/core/src/themes/monokai-pro.ts b/packages/core/src/themes/monokai-pro.ts new file mode 100644 index 0000000..bb2c5c9 --- /dev/null +++ b/packages/core/src/themes/monokai-pro.ts @@ -0,0 +1,110 @@ +/** + * Monokai Pro Theme + * Enhanced version of Monokai with refined colors + */ + +import type { Theme } from '../types'; + +const monokaiPro: Theme = { + name: 'monokai-pro', + displayName: 'Monokai Pro', + variant: 'dark', + author: 'Monokai', + version: '1.0.0', + + colors: { + background: '#2d2a2e', + backgroundSecondary: '#403e41', + backgroundTertiary: '#5b595c', + foreground: '#fcfcfa', + foregroundSecondary: '#d9d9d7', + foregroundMuted: '#939293', + primary: '#78dce8', + secondary: '#a9dc76', + accent: '#ab9df2', + heading: '#fcfcfa', + link: '#78dce8', + linkHover: '#a9dc76', + linkVisited: '#ab9df2', + codeBackground: '#403e41', + codeText: '#fcfcfa', + codeKeyword: '#ff6188', + codeString: '#ffd866', + codeComment: '#727072', + codeFunction: '#a9dc76', + border: '#5b595c', + borderLight: '#6e6c6f', + borderHeavy: '#939293', + selection: 'rgba(120, 220, 232, 0.2)', + highlight: '#ffd866', + shadow: 'rgba(0, 0, 0, 0.5)', + success: '#a9dc76', + warning: '#ffd866', + error: '#ff6188', + info: '#78dce8', + commentHighlight: 'rgba(255, 216, 102, 0.25)', + commentHighlightResolved: 'rgba(255, 216, 102, 0.08)', + commentCardBg: '#363537', + }, + + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', + codeFontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + baseFontSize: '16px', + baseLineHeight: 1.5, + h1Size: '2em', + h2Size: '1.5em', + h3Size: '1.25em', + h4Size: '1em', + h5Size: '0.875em', + h6Size: '0.85em', + fontWeightNormal: 400, + fontWeightBold: 600, + headingFontWeight: 600, + }, + + spacing: { + blockMargin: '16px 0', + paragraphMargin: '16px 0', + listItemMargin: '4px 0', + headingMargin: '24px 0 16px 0', + codeBlockPadding: '16px', + tableCellPadding: '6px 13px', + }, + + syntaxTheme: 'monokai', + + mermaidTheme: { + theme: 'dark', + themeVariables: { + primaryColor: '#78dce8', + primaryTextColor: '#2d2a2e', + primaryBorderColor: '#78dce8', + lineColor: '#fcfcfa', + secondaryColor: '#403e41', + tertiaryColor: '#5b595c', + background: '#2d2a2e', + mainBkg: '#403e41', + secondaryBkg: '#5b595c', + tertiaryBkg: '#2d2a2e', + textColor: '#fcfcfa', + tertiaryTextColor: '#fcfcfa', + nodeBkg: '#403e41', + nodeBorder: '#5b595c', + clusterBkg: '#5b595c', + clusterBorder: '#5b595c', + defaultLinkColor: '#78dce8', + titleColor: '#fcfcfa', + edgeLabelBackground: '#2d2a2e', + // ER diagram specific + relationLabelColor: '#fcfcfa', + relationLabelBackground: '#2d2a2e', + attributeBackgroundColorOdd: '#403e41', + attributeBackgroundColorEven: '#5b595c', + }, + }, +}; + +export default monokaiPro; diff --git a/packages/core/src/themes/monokai.ts b/packages/core/src/themes/monokai.ts new file mode 100644 index 0000000..d770edb --- /dev/null +++ b/packages/core/src/themes/monokai.ts @@ -0,0 +1,110 @@ +/** + * Monokai Theme + * Classic Monokai color scheme + */ + +import type { Theme } from '../types'; + +const monokai: Theme = { + name: 'monokai', + displayName: 'Monokai', + variant: 'dark', + author: 'Wimer Hazenberg', + version: '1.0.0', + + colors: { + background: '#272822', + backgroundSecondary: '#3e3d32', + backgroundTertiary: '#49483e', + foreground: '#f8f8f2', + foregroundSecondary: '#cfcfc2', + foregroundMuted: '#75715e', + primary: '#66d9ef', + secondary: '#a1efe4', + accent: '#ae81ff', + heading: '#f8f8f2', + link: '#66d9ef', + linkHover: '#a1efe4', + linkVisited: '#ae81ff', + codeBackground: '#3e3d32', + codeText: '#f8f8f2', + codeKeyword: '#f92672', + codeString: '#e6db74', + codeComment: '#75715e', + codeFunction: '#a6e22e', + border: '#49483e', + borderLight: '#5e5d52', + borderHeavy: '#75715e', + selection: 'rgba(102, 217, 239, 0.2)', + highlight: '#e6db74', + shadow: 'rgba(0, 0, 0, 0.5)', + success: '#a6e22e', + warning: '#e6db74', + error: '#f92672', + info: '#66d9ef', + commentHighlight: 'rgba(230, 219, 116, 0.25)', + commentHighlightResolved: 'rgba(230, 219, 116, 0.08)', + commentCardBg: '#3e3d32', + }, + + typography: { + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', + codeFontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + baseFontSize: '16px', + baseLineHeight: 1.5, + h1Size: '2em', + h2Size: '1.5em', + h3Size: '1.25em', + h4Size: '1em', + h5Size: '0.875em', + h6Size: '0.85em', + fontWeightNormal: 400, + fontWeightBold: 600, + headingFontWeight: 600, + }, + + spacing: { + blockMargin: '16px 0', + paragraphMargin: '16px 0', + listItemMargin: '4px 0', + headingMargin: '24px 0 16px 0', + codeBlockPadding: '16px', + tableCellPadding: '6px 13px', + }, + + syntaxTheme: 'monokai', + + mermaidTheme: { + theme: 'dark', + themeVariables: { + primaryColor: '#66d9ef', + primaryTextColor: '#272822', + primaryBorderColor: '#66d9ef', + lineColor: '#f8f8f2', + secondaryColor: '#3e3d32', + tertiaryColor: '#49483e', + background: '#272822', + mainBkg: '#3e3d32', + secondaryBkg: '#49483e', + tertiaryBkg: '#272822', + textColor: '#f8f8f2', + tertiaryTextColor: '#f8f8f2', + nodeBkg: '#3e3d32', + nodeBorder: '#49483e', + clusterBkg: '#49483e', + clusterBorder: '#49483e', + defaultLinkColor: '#66d9ef', + titleColor: '#f8f8f2', + edgeLabelBackground: '#272822', + // ER diagram specific + relationLabelColor: '#f8f8f2', + relationLabelBackground: '#272822', + attributeBackgroundColorOdd: '#3e3d32', + attributeBackgroundColorEven: '#49483e', + }, + }, +}; + +export default monokai; diff --git a/src/themes/catppuccin-frappe.ts b/src/themes/catppuccin-frappe.ts index f60dfbe..b4efbed 100644 --- a/src/themes/catppuccin-frappe.ts +++ b/src/themes/catppuccin-frappe.ts @@ -1,110 +1 @@ -/** - * Catppuccin Frappé Theme - * Light-medium variant of Catppuccin - */ - -import type { Theme } from '../types'; - -const catppuccinFrappe: Theme = { - name: 'catppuccin-frappe', - displayName: 'Catppuccin Frappé', - variant: 'light', - author: 'Catppuccin', - version: '1.0.0', - - colors: { - background: '#303446', - backgroundSecondary: '#292c3c', - backgroundTertiary: '#232634', - foreground: '#c6d0f5', - foregroundSecondary: '#b5bfe2', - foregroundMuted: '#a5adce', - primary: '#8caaee', - secondary: '#85c1dc', - accent: '#ca9ee6', - heading: '#c6d0f5', - link: '#8caaee', - linkHover: '#99d1db', - linkVisited: '#ca9ee6', - codeBackground: '#292c3c', - codeText: '#c6d0f5', - codeKeyword: '#e78284', - codeString: '#a6d189', - codeComment: '#737994', - codeFunction: '#ca9ee6', - border: '#414559', - borderLight: '#51576d', - borderHeavy: '#626880', - selection: 'rgba(140, 170, 238, 0.2)', - highlight: '#ef9f76', - shadow: 'rgba(0, 0, 0, 0.3)', - success: '#a6d189', - warning: '#ef9f76', - error: '#e78284', - info: '#8caaee', - commentHighlight: 'rgba(229, 200, 144, 0.25)', - commentHighlightResolved: 'rgba(229, 200, 144, 0.08)', - commentCardBg: '#292c3c', - }, - - typography: { - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', - codeFontFamily: - 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', - baseFontSize: '16px', - baseLineHeight: 1.5, - h1Size: '2em', - h2Size: '1.5em', - h3Size: '1.25em', - h4Size: '1em', - h5Size: '0.875em', - h6Size: '0.85em', - fontWeightNormal: 400, - fontWeightBold: 600, - headingFontWeight: 600, - }, - - spacing: { - blockMargin: '16px 0', - paragraphMargin: '16px 0', - listItemMargin: '4px 0', - headingMargin: '24px 0 16px 0', - codeBlockPadding: '16px', - tableCellPadding: '6px 13px', - }, - - syntaxTheme: 'github-dark', - - mermaidTheme: { - theme: 'dark', - themeVariables: { - primaryColor: '#8caaee', - primaryTextColor: '#303446', - primaryBorderColor: '#8caaee', - lineColor: '#c6d0f5', - secondaryColor: '#292c3c', - tertiaryColor: '#232634', - background: '#303446', - mainBkg: '#292c3c', - secondaryBkg: '#232634', - tertiaryBkg: '#303446', - textColor: '#c6d0f5', - tertiaryTextColor: '#c6d0f5', - nodeBkg: '#292c3c', - nodeBorder: '#414559', - clusterBkg: '#232634', - clusterBorder: '#414559', - defaultLinkColor: '#8caaee', - titleColor: '#c6d0f5', - edgeLabelBackground: '#303446', - // ER diagram specific - relationLabelColor: '#c6d0f5', - relationLabelBackground: '#303446', - attributeBackgroundColorOdd: '#292c3c', - attributeBackgroundColorEven: '#232634', - }, - }, -}; - -export default catppuccinFrappe; +export { default } from '../../packages/core/src/themes/catppuccin-frappe'; diff --git a/src/themes/catppuccin-latte.ts b/src/themes/catppuccin-latte.ts index abd63db..0b868f9 100644 --- a/src/themes/catppuccin-latte.ts +++ b/src/themes/catppuccin-latte.ts @@ -1,110 +1 @@ -/** - * Catppuccin Latte Theme - * Light variant of Catppuccin - */ - -import type { Theme } from '../types'; - -const catppuccinLatte: Theme = { - name: 'catppuccin-latte', - displayName: 'Catppuccin Latte', - variant: 'light', - author: 'Catppuccin', - version: '1.0.0', - - colors: { - background: '#eff1f5', - backgroundSecondary: '#e6e9ef', - backgroundTertiary: '#dce0e8', - foreground: '#4c4f69', - foregroundSecondary: '#5c5f77', - foregroundMuted: '#6c6f85', - primary: '#1e66f5', - secondary: '#7287fd', - accent: '#8839ef', - heading: '#4c4f69', - link: '#1e66f5', - linkHover: '#04a5e5', - linkVisited: '#8839ef', - codeBackground: '#e6e9ef', - codeText: '#4c4f69', - codeKeyword: '#d20f39', - codeString: '#40a02b', - codeComment: '#7c7f93', - codeFunction: '#8839ef', - border: '#dce0e8', - borderLight: '#e6e9ef', - borderHeavy: '#bcc0cc', - selection: 'rgba(30, 102, 245, 0.2)', - highlight: '#df8e1d', - shadow: 'rgba(0, 0, 0, 0.1)', - success: '#40a02b', - warning: '#df8e1d', - error: '#d20f39', - info: '#1e66f5', - commentHighlight: 'rgba(223, 142, 29, 0.25)', - commentHighlightResolved: 'rgba(223, 142, 29, 0.08)', - commentCardBg: '#e6e9ef', - }, - - typography: { - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', - codeFontFamily: - 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', - baseFontSize: '16px', - baseLineHeight: 1.5, - h1Size: '2em', - h2Size: '1.5em', - h3Size: '1.25em', - h4Size: '1em', - h5Size: '0.875em', - h6Size: '0.85em', - fontWeightNormal: 400, - fontWeightBold: 600, - headingFontWeight: 600, - }, - - spacing: { - blockMargin: '16px 0', - paragraphMargin: '16px 0', - listItemMargin: '4px 0', - headingMargin: '24px 0 16px 0', - codeBlockPadding: '16px', - tableCellPadding: '6px 13px', - }, - - syntaxTheme: 'github', - - mermaidTheme: { - theme: 'base', - themeVariables: { - primaryColor: '#1e66f5', - primaryTextColor: '#eff1f5', - primaryBorderColor: '#1e66f5', - lineColor: '#4c4f69', - secondaryColor: '#e6e9ef', - tertiaryColor: '#dce0e8', - background: '#eff1f5', - mainBkg: '#e6e9ef', - secondaryBkg: '#dce0e8', - tertiaryBkg: '#eff1f5', - textColor: '#4c4f69', - tertiaryTextColor: '#4c4f69', - nodeBkg: '#e6e9ef', - nodeBorder: '#dce0e8', - clusterBkg: '#dce0e8', - clusterBorder: '#dce0e8', - defaultLinkColor: '#1e66f5', - titleColor: '#4c4f69', - edgeLabelBackground: '#eff1f5', - // ER diagram specific - relationLabelColor: '#4c4f69', - relationLabelBackground: '#eff1f5', - attributeBackgroundColorOdd: '#e6e9ef', - attributeBackgroundColorEven: '#dce0e8', - }, - }, -}; - -export default catppuccinLatte; +export { default } from '../../packages/core/src/themes/catppuccin-latte'; diff --git a/src/themes/catppuccin-macchiato.ts b/src/themes/catppuccin-macchiato.ts index 3c26e89..c48eba1 100644 --- a/src/themes/catppuccin-macchiato.ts +++ b/src/themes/catppuccin-macchiato.ts @@ -1,110 +1 @@ -/** - * Catppuccin Macchiato Theme - * Dark-medium variant of Catppuccin - */ - -import type { Theme } from '../types'; - -const catppuccinMacchiato: Theme = { - name: 'catppuccin-macchiato', - displayName: 'Catppuccin Macchiato', - variant: 'dark', - author: 'Catppuccin', - version: '1.0.0', - - colors: { - background: '#24273a', - backgroundSecondary: '#1e2030', - backgroundTertiary: '#181926', - foreground: '#cad3f5', - foregroundSecondary: '#b8c0e0', - foregroundMuted: '#a5adcb', - primary: '#8aadf4', - secondary: '#7dc4e4', - accent: '#c6a0f6', - heading: '#cad3f5', - link: '#8aadf4', - linkHover: '#91d7e3', - linkVisited: '#c6a0f6', - codeBackground: '#1e2030', - codeText: '#cad3f5', - codeKeyword: '#ed8796', - codeString: '#a6da95', - codeComment: '#6e738d', - codeFunction: '#c6a0f6', - border: '#363a4f', - borderLight: '#494d64', - borderHeavy: '#5b6078', - selection: 'rgba(138, 173, 244, 0.2)', - highlight: '#f5a97f', - shadow: 'rgba(0, 0, 0, 0.4)', - success: '#a6da95', - warning: '#eed49f', - error: '#ed8796', - info: '#8aadf4', - commentHighlight: 'rgba(238, 212, 159, 0.25)', - commentHighlightResolved: 'rgba(238, 212, 159, 0.08)', - commentCardBg: '#1e2030', - }, - - typography: { - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', - codeFontFamily: - 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', - baseFontSize: '16px', - baseLineHeight: 1.5, - h1Size: '2em', - h2Size: '1.5em', - h3Size: '1.25em', - h4Size: '1em', - h5Size: '0.875em', - h6Size: '0.85em', - fontWeightNormal: 400, - fontWeightBold: 600, - headingFontWeight: 600, - }, - - spacing: { - blockMargin: '16px 0', - paragraphMargin: '16px 0', - listItemMargin: '4px 0', - headingMargin: '24px 0 16px 0', - codeBlockPadding: '16px', - tableCellPadding: '6px 13px', - }, - - syntaxTheme: 'github-dark', - - mermaidTheme: { - theme: 'dark', - themeVariables: { - primaryColor: '#8aadf4', - primaryTextColor: '#24273a', - primaryBorderColor: '#8aadf4', - lineColor: '#cad3f5', - secondaryColor: '#1e2030', - tertiaryColor: '#181926', - background: '#24273a', - mainBkg: '#1e2030', - secondaryBkg: '#181926', - tertiaryBkg: '#24273a', - textColor: '#cad3f5', - tertiaryTextColor: '#cad3f5', - nodeBkg: '#1e2030', - nodeBorder: '#363a4f', - clusterBkg: '#181926', - clusterBorder: '#363a4f', - defaultLinkColor: '#8aadf4', - titleColor: '#cad3f5', - edgeLabelBackground: '#24273a', - // ER diagram specific - relationLabelColor: '#cad3f5', - relationLabelBackground: '#24273a', - attributeBackgroundColorOdd: '#1e2030', - attributeBackgroundColorEven: '#181926', - }, - }, -}; - -export default catppuccinMacchiato; +export { default } from '../../packages/core/src/themes/catppuccin-macchiato'; diff --git a/src/themes/catppuccin-mocha.ts b/src/themes/catppuccin-mocha.ts index 1c69a97..2202138 100644 --- a/src/themes/catppuccin-mocha.ts +++ b/src/themes/catppuccin-mocha.ts @@ -1,110 +1 @@ -/** - * Catppuccin Mocha Theme - * Dark variant of Catppuccin - */ - -import type { Theme } from '../types'; - -const catppuccinMocha: Theme = { - name: 'catppuccin-mocha', - displayName: 'Catppuccin Mocha', - variant: 'dark', - author: 'Catppuccin', - version: '1.0.0', - - colors: { - background: '#1e1e2e', - backgroundSecondary: '#181825', - backgroundTertiary: '#11111b', - foreground: '#cdd6f4', - foregroundSecondary: '#bac2de', - foregroundMuted: '#a6adc8', - primary: '#89b4fa', - secondary: '#74c7ec', - accent: '#cba6f7', - heading: '#cdd6f4', - link: '#89b4fa', - linkHover: '#89dceb', - linkVisited: '#cba6f7', - codeBackground: '#181825', - codeText: '#cdd6f4', - codeKeyword: '#f38ba8', - codeString: '#a6e3a1', - codeComment: '#6c7086', - codeFunction: '#cba6f7', - border: '#313244', - borderLight: '#45475a', - borderHeavy: '#585b70', - selection: 'rgba(137, 180, 250, 0.2)', - highlight: '#f9e2af', - shadow: 'rgba(0, 0, 0, 0.5)', - success: '#a6e3a1', - warning: '#f9e2af', - error: '#f38ba8', - info: '#89b4fa', - commentHighlight: 'rgba(249, 226, 175, 0.25)', - commentHighlightResolved: 'rgba(249, 226, 175, 0.08)', - commentCardBg: '#1e1e2e', - }, - - typography: { - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', - codeFontFamily: - 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', - baseFontSize: '16px', - baseLineHeight: 1.5, - h1Size: '2em', - h2Size: '1.5em', - h3Size: '1.25em', - h4Size: '1em', - h5Size: '0.875em', - h6Size: '0.85em', - fontWeightNormal: 400, - fontWeightBold: 600, - headingFontWeight: 600, - }, - - spacing: { - blockMargin: '16px 0', - paragraphMargin: '16px 0', - listItemMargin: '4px 0', - headingMargin: '24px 0 16px 0', - codeBlockPadding: '16px', - tableCellPadding: '6px 13px', - }, - - syntaxTheme: 'github-dark', - - mermaidTheme: { - theme: 'dark', - themeVariables: { - primaryColor: '#89b4fa', - primaryTextColor: '#1e1e2e', - primaryBorderColor: '#89b4fa', - lineColor: '#cdd6f4', - secondaryColor: '#181825', - tertiaryColor: '#11111b', - background: '#1e1e2e', - mainBkg: '#181825', - secondaryBkg: '#11111b', - tertiaryBkg: '#1e1e2e', - textColor: '#cdd6f4', - tertiaryTextColor: '#cdd6f4', - nodeBkg: '#181825', - nodeBorder: '#313244', - clusterBkg: '#11111b', - clusterBorder: '#313244', - defaultLinkColor: '#89b4fa', - titleColor: '#cdd6f4', - edgeLabelBackground: '#1e1e2e', - // ER diagram specific - relationLabelColor: '#cdd6f4', - relationLabelBackground: '#1e1e2e', - attributeBackgroundColorOdd: '#181825', - attributeBackgroundColorEven: '#11111b', - }, - }, -}; - -export default catppuccinMocha; +export { default } from '../../packages/core/src/themes/catppuccin-mocha'; diff --git a/src/themes/github-dark.ts b/src/themes/github-dark.ts index 9c940b7..50edacf 100644 --- a/src/themes/github-dark.ts +++ b/src/themes/github-dark.ts @@ -1,126 +1 @@ -/** - * GitHub Dark Theme - * Based on GitHub's dark mode styles - */ - -import type { Theme } from '../types'; - -const githubDark: Theme = { - name: 'github-dark', - displayName: 'GitHub Dark', - variant: 'dark', - author: 'GitHub', - version: '1.0.0', - - colors: { - background: '#0d1117', - backgroundSecondary: '#161b22', - backgroundTertiary: '#21262d', - foreground: '#c9d1d9', - foregroundSecondary: '#8b949e', - foregroundMuted: '#6e7681', - primary: '#58a6ff', - secondary: '#8b949e', - accent: '#58a6ff', - heading: '#c9d1d9', - link: '#58a6ff', - linkHover: '#79c0ff', - linkVisited: '#a371f7', - codeBackground: '#161b22', - codeText: '#c9d1d9', - codeKeyword: '#ff7b72', - codeString: '#a5d6ff', - codeComment: '#8b949e', - codeFunction: '#d2a8ff', - border: '#30363d', - borderLight: '#21262d', - borderHeavy: '#6e7681', - selection: 'rgba(88, 166, 255, 0.2)', - highlight: 'rgba(187, 128, 9, 0.3)', - shadow: 'rgba(0, 0, 0, 0.4)', - success: '#3fb950', - warning: '#d29922', - error: '#f85149', - info: '#58a6ff', - commentHighlight: 'rgba(255, 183, 77, 0.25)', - commentHighlightResolved: 'rgba(255, 183, 77, 0.08)', - commentCardBg: '#161b22', - }, - - typography: { - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"', - codeFontFamily: - 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', - baseFontSize: '16px', - baseLineHeight: 1.5, - h1Size: '2em', - h2Size: '1.5em', - h3Size: '1.25em', - h4Size: '1em', - h5Size: '0.875em', - h6Size: '0.85em', - fontWeightNormal: 400, - fontWeightBold: 600, - headingFontWeight: 600, - }, - - spacing: { - blockMargin: '16px 0', - paragraphMargin: '16px 0', - listItemMargin: '4px 0', - headingMargin: '24px 0 16px 0', - codeBlockPadding: '16px', - tableCellPadding: '6px 13px', - }, - - syntaxTheme: 'github-dark', - - mermaidTheme: { - theme: 'dark', - themeVariables: { - primaryColor: '#58a6ff', - primaryTextColor: '#0d1117', - primaryBorderColor: '#58a6ff', - lineColor: '#c9d1d9', - secondaryColor: '#161b22', - tertiaryColor: '#21262d', - background: '#0d1117', - mainBkg: '#161b22', - secondaryBkg: '#21262d', - tertiaryBkg: '#0d1117', - textColor: '#c9d1d9', - tertiaryTextColor: '#c9d1d9', - nodeBkg: '#161b22', - nodeBorder: '#30363d', - clusterBkg: '#21262d', - clusterBorder: '#30363d', - defaultLinkColor: '#58a6ff', - titleColor: '#c9d1d9', - edgeLabelBackground: '#0d1117', - // ER diagram specific - relationLabelColor: '#c9d1d9', - relationLabelBackground: '#0d1117', - attributeBackgroundColorOdd: '#161b22', - attributeBackgroundColorEven: '#21262d', - actorBorder: '#30363d', - actorBkg: '#161b22', - actorTextColor: '#c9d1d9', - actorLineColor: '#30363d', - signalColor: '#c9d1d9', - signalTextColor: '#0d1117', - labelBoxBkgColor: '#161b22', - labelBoxBorderColor: '#30363d', - labelTextColor: '#c9d1d9', - loopTextColor: '#c9d1d9', - noteBorderColor: '#30363d', - noteBkgColor: 'rgba(187, 128, 9, 0.3)', - noteTextColor: '#c9d1d9', - activationBorderColor: '#30363d', - activationBkgColor: '#21262d', - sequenceNumberColor: '#0d1117', - }, - }, -}; - -export default githubDark; +export { default } from '../../packages/core/src/themes/github-dark'; diff --git a/src/themes/github-light.ts b/src/themes/github-light.ts index 5a79ae0..aaf8959 100644 --- a/src/themes/github-light.ts +++ b/src/themes/github-light.ts @@ -1,126 +1 @@ -/** - * GitHub Light Theme - * Based on GitHub's markdown styles - */ - -import type { Theme } from '../types'; - -const githubLight: Theme = { - name: 'github-light', - displayName: 'GitHub Light', - variant: 'light', - author: 'GitHub', - version: '1.0.0', - - colors: { - background: '#ffffff', - backgroundSecondary: '#f6f8fa', - backgroundTertiary: '#eaeef2', - foreground: '#24292f', - foregroundSecondary: '#57606a', - foregroundMuted: '#6e7781', - primary: '#0969da', - secondary: '#6e7781', - accent: '#0969da', - heading: '#1f2328', - link: '#0969da', - linkHover: '#0550ae', - linkVisited: '#8250df', - codeBackground: '#f6f8fa', - codeText: '#24292f', - codeKeyword: '#cf222e', - codeString: '#0a3069', - codeComment: '#6e7781', - codeFunction: '#8250df', - border: '#d0d7de', - borderLight: '#d8dee4', - borderHeavy: '#8c959f', - selection: 'rgba(9, 105, 218, 0.2)', - highlight: '#fff8c5', - shadow: 'rgba(0, 0, 0, 0.1)', - success: '#1a7f37', - warning: '#9a6700', - error: '#cf222e', - info: '#0969da', - commentHighlight: 'rgba(255, 212, 59, 0.3)', - commentHighlightResolved: 'rgba(255, 212, 59, 0.1)', - commentCardBg: '#f6f8fa', - }, - - typography: { - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"', - codeFontFamily: - 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', - baseFontSize: '16px', - baseLineHeight: 1.5, - h1Size: '2em', - h2Size: '1.5em', - h3Size: '1.25em', - h4Size: '1em', - h5Size: '0.875em', - h6Size: '0.85em', - fontWeightNormal: 400, - fontWeightBold: 600, - headingFontWeight: 600, - }, - - spacing: { - blockMargin: '16px 0', - paragraphMargin: '16px 0', - listItemMargin: '4px 0', - headingMargin: '24px 0 16px 0', - codeBlockPadding: '16px', - tableCellPadding: '6px 13px', - }, - - syntaxTheme: 'github', - - mermaidTheme: { - theme: 'base', - themeVariables: { - primaryColor: '#0969da', - primaryTextColor: '#000000', - primaryBorderColor: '#0969da', - lineColor: '#24292f', - secondaryColor: '#f6f8fa', - tertiaryColor: '#eaeef2', - background: '#ffffff', - mainBkg: '#f6f8fa', - secondaryBkg: '#eaeef2', - tertiaryBkg: '#ffffff', - textColor: '#24292f', - tertiaryTextColor: '#24292f', - nodeBkg: '#f6f8fa', - nodeBorder: '#d0d7de', - clusterBkg: '#eaeef2', - clusterBorder: '#d0d7de', - defaultLinkColor: '#0969da', - titleColor: '#1f2328', - edgeLabelBackground: '#ffffff', - // ER diagram specific - relationLabelColor: '#24292f', - relationLabelBackground: '#ffffff', - attributeBackgroundColorOdd: '#f6f8fa', - attributeBackgroundColorEven: '#ffffff', - actorBorder: '#d0d7de', - actorBkg: '#f6f8fa', - actorTextColor: '#24292f', - actorLineColor: '#d0d7de', - signalColor: '#24292f', - signalTextColor: '#24292f', - labelBoxBkgColor: '#f6f8fa', - labelBoxBorderColor: '#d0d7de', - labelTextColor: '#24292f', - loopTextColor: '#24292f', - noteBorderColor: '#d0d7de', - noteBkgColor: '#fff8c5', - noteTextColor: '#24292f', - activationBorderColor: '#d0d7de', - activationBkgColor: '#eaeef2', - sequenceNumberColor: '#ffffff', - }, - }, -}; - -export default githubLight; +export { default } from '../../packages/core/src/themes/github-light'; diff --git a/src/themes/monokai-pro.ts b/src/themes/monokai-pro.ts index bb2c5c9..d56cfe2 100644 --- a/src/themes/monokai-pro.ts +++ b/src/themes/monokai-pro.ts @@ -1,110 +1 @@ -/** - * Monokai Pro Theme - * Enhanced version of Monokai with refined colors - */ - -import type { Theme } from '../types'; - -const monokaiPro: Theme = { - name: 'monokai-pro', - displayName: 'Monokai Pro', - variant: 'dark', - author: 'Monokai', - version: '1.0.0', - - colors: { - background: '#2d2a2e', - backgroundSecondary: '#403e41', - backgroundTertiary: '#5b595c', - foreground: '#fcfcfa', - foregroundSecondary: '#d9d9d7', - foregroundMuted: '#939293', - primary: '#78dce8', - secondary: '#a9dc76', - accent: '#ab9df2', - heading: '#fcfcfa', - link: '#78dce8', - linkHover: '#a9dc76', - linkVisited: '#ab9df2', - codeBackground: '#403e41', - codeText: '#fcfcfa', - codeKeyword: '#ff6188', - codeString: '#ffd866', - codeComment: '#727072', - codeFunction: '#a9dc76', - border: '#5b595c', - borderLight: '#6e6c6f', - borderHeavy: '#939293', - selection: 'rgba(120, 220, 232, 0.2)', - highlight: '#ffd866', - shadow: 'rgba(0, 0, 0, 0.5)', - success: '#a9dc76', - warning: '#ffd866', - error: '#ff6188', - info: '#78dce8', - commentHighlight: 'rgba(255, 216, 102, 0.25)', - commentHighlightResolved: 'rgba(255, 216, 102, 0.08)', - commentCardBg: '#363537', - }, - - typography: { - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', - codeFontFamily: - 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', - baseFontSize: '16px', - baseLineHeight: 1.5, - h1Size: '2em', - h2Size: '1.5em', - h3Size: '1.25em', - h4Size: '1em', - h5Size: '0.875em', - h6Size: '0.85em', - fontWeightNormal: 400, - fontWeightBold: 600, - headingFontWeight: 600, - }, - - spacing: { - blockMargin: '16px 0', - paragraphMargin: '16px 0', - listItemMargin: '4px 0', - headingMargin: '24px 0 16px 0', - codeBlockPadding: '16px', - tableCellPadding: '6px 13px', - }, - - syntaxTheme: 'monokai', - - mermaidTheme: { - theme: 'dark', - themeVariables: { - primaryColor: '#78dce8', - primaryTextColor: '#2d2a2e', - primaryBorderColor: '#78dce8', - lineColor: '#fcfcfa', - secondaryColor: '#403e41', - tertiaryColor: '#5b595c', - background: '#2d2a2e', - mainBkg: '#403e41', - secondaryBkg: '#5b595c', - tertiaryBkg: '#2d2a2e', - textColor: '#fcfcfa', - tertiaryTextColor: '#fcfcfa', - nodeBkg: '#403e41', - nodeBorder: '#5b595c', - clusterBkg: '#5b595c', - clusterBorder: '#5b595c', - defaultLinkColor: '#78dce8', - titleColor: '#fcfcfa', - edgeLabelBackground: '#2d2a2e', - // ER diagram specific - relationLabelColor: '#fcfcfa', - relationLabelBackground: '#2d2a2e', - attributeBackgroundColorOdd: '#403e41', - attributeBackgroundColorEven: '#5b595c', - }, - }, -}; - -export default monokaiPro; +export { default } from '../../packages/core/src/themes/monokai-pro'; diff --git a/src/themes/monokai.ts b/src/themes/monokai.ts index d770edb..2cf4e24 100644 --- a/src/themes/monokai.ts +++ b/src/themes/monokai.ts @@ -1,110 +1 @@ -/** - * Monokai Theme - * Classic Monokai color scheme - */ - -import type { Theme } from '../types'; - -const monokai: Theme = { - name: 'monokai', - displayName: 'Monokai', - variant: 'dark', - author: 'Wimer Hazenberg', - version: '1.0.0', - - colors: { - background: '#272822', - backgroundSecondary: '#3e3d32', - backgroundTertiary: '#49483e', - foreground: '#f8f8f2', - foregroundSecondary: '#cfcfc2', - foregroundMuted: '#75715e', - primary: '#66d9ef', - secondary: '#a1efe4', - accent: '#ae81ff', - heading: '#f8f8f2', - link: '#66d9ef', - linkHover: '#a1efe4', - linkVisited: '#ae81ff', - codeBackground: '#3e3d32', - codeText: '#f8f8f2', - codeKeyword: '#f92672', - codeString: '#e6db74', - codeComment: '#75715e', - codeFunction: '#a6e22e', - border: '#49483e', - borderLight: '#5e5d52', - borderHeavy: '#75715e', - selection: 'rgba(102, 217, 239, 0.2)', - highlight: '#e6db74', - shadow: 'rgba(0, 0, 0, 0.5)', - success: '#a6e22e', - warning: '#e6db74', - error: '#f92672', - info: '#66d9ef', - commentHighlight: 'rgba(230, 219, 116, 0.25)', - commentHighlightResolved: 'rgba(230, 219, 116, 0.08)', - commentCardBg: '#3e3d32', - }, - - typography: { - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif', - codeFontFamily: - 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', - baseFontSize: '16px', - baseLineHeight: 1.5, - h1Size: '2em', - h2Size: '1.5em', - h3Size: '1.25em', - h4Size: '1em', - h5Size: '0.875em', - h6Size: '0.85em', - fontWeightNormal: 400, - fontWeightBold: 600, - headingFontWeight: 600, - }, - - spacing: { - blockMargin: '16px 0', - paragraphMargin: '16px 0', - listItemMargin: '4px 0', - headingMargin: '24px 0 16px 0', - codeBlockPadding: '16px', - tableCellPadding: '6px 13px', - }, - - syntaxTheme: 'monokai', - - mermaidTheme: { - theme: 'dark', - themeVariables: { - primaryColor: '#66d9ef', - primaryTextColor: '#272822', - primaryBorderColor: '#66d9ef', - lineColor: '#f8f8f2', - secondaryColor: '#3e3d32', - tertiaryColor: '#49483e', - background: '#272822', - mainBkg: '#3e3d32', - secondaryBkg: '#49483e', - tertiaryBkg: '#272822', - textColor: '#f8f8f2', - tertiaryTextColor: '#f8f8f2', - nodeBkg: '#3e3d32', - nodeBorder: '#49483e', - clusterBkg: '#49483e', - clusterBorder: '#49483e', - defaultLinkColor: '#66d9ef', - titleColor: '#f8f8f2', - edgeLabelBackground: '#272822', - // ER diagram specific - relationLabelColor: '#f8f8f2', - relationLabelBackground: '#272822', - attributeBackgroundColorOdd: '#3e3d32', - attributeBackgroundColorEven: '#49483e', - }, - }, -}; - -export default monokai; +export { default } from '../../packages/core/src/themes/monokai'; From a96ec9452f840b31b2997b8f2e6221580a5b2664 Mon Sep 17 00:00:00 2001 From: James Ainslie <42301770+jamesainslie@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:18:09 -0400 Subject: [PATCH 06/26] refactor(core): move pure utility modules to @mdview/core --- packages/core/src/__tests__/utils.test.ts | 183 ++++++++++++++++++ packages/core/src/utils/filename-generator.ts | 88 +++++++++ packages/core/src/utils/section-splitter.ts | 178 +++++++++++++++++ packages/core/src/utils/toc-stripper.ts | 163 ++++++++++++++++ src/utils/filename-generator.ts | 90 +-------- src/utils/section-splitter.ts | 179 +---------------- src/utils/toc-stripper.ts | 145 +------------- 7 files changed, 621 insertions(+), 405 deletions(-) create mode 100644 packages/core/src/__tests__/utils.test.ts create mode 100644 packages/core/src/utils/filename-generator.ts create mode 100644 packages/core/src/utils/section-splitter.ts create mode 100644 packages/core/src/utils/toc-stripper.ts diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts new file mode 100644 index 0000000..75665b1 --- /dev/null +++ b/packages/core/src/__tests__/utils.test.ts @@ -0,0 +1,183 @@ +import { + splitIntoSections, + splitIntoChunks, + getInitialSections, + type MarkdownSection, +} from '../utils/section-splitter'; +import { FilenameGenerator } from '../utils/filename-generator'; +import { stripTableOfContents } from '../utils/toc-stripper'; + +describe('section-splitter', () => { + describe('splitIntoSections', () => { + it('should split markdown by headings', () => { + const md = '# Heading 1\nContent 1\n## Heading 2\nContent 2'; + const sections = splitIntoSections(md); + expect(sections).toHaveLength(2); + expect(sections[0].heading).toBe('Heading 1'); + expect(sections[1].heading).toBe('Heading 2'); + }); + + it('should return a single section for markdown without headings', () => { + const md = 'Just some text\nwith multiple lines'; + const sections = splitIntoSections(md); + expect(sections).toHaveLength(1); + expect(sections[0].heading).toBeUndefined(); + }); + + it('should not split on headings inside code fences', () => { + const md = '# Real Heading\n```\n# Not a heading\n```\nMore content'; + const sections = splitIntoSections(md); + expect(sections).toHaveLength(1); + expect(sections[0].heading).toBe('Real Heading'); + }); + + it('should assign sequential section IDs', () => { + const md = '# A\ntext\n## B\ntext\n### C\ntext'; + const sections = splitIntoSections(md); + expect(sections.map((s) => s.id)).toEqual(['section-0', 'section-1', 'section-2']); + }); + + it('should track line numbers', () => { + const md = '# First\nline 1\nline 2\n## Second\nline 3'; + const sections = splitIntoSections(md); + expect(sections[0].startLine).toBe(0); + expect(sections[0].endLine).toBe(2); + expect(sections[1].startLine).toBe(3); + expect(sections[1].endLine).toBe(4); + }); + }); + + describe('splitIntoChunks', () => { + it('should split large content into chunks', () => { + const line = 'x'.repeat(100) + '\n'; + const md = line.repeat(100); + const sections = splitIntoChunks(md, 500); + expect(sections.length).toBeGreaterThan(1); + sections.forEach((s) => { + expect(s.id).toMatch(/^chunk-\d+$/); + }); + }); + + it('should return single chunk for small content', () => { + const md = 'small text'; + const sections = splitIntoChunks(md); + expect(sections).toHaveLength(1); + }); + }); + + describe('getInitialSections', () => { + const makeSections = (count: number): MarkdownSection[] => + Array.from({ length: count }, (_, i) => ({ + markdown: `Section ${i}`, + startLine: i * 2, + endLine: i * 2 + 1, + heading: `Heading ${i}`, + level: 1, + id: `section-${i}`, + })); + + it('should return up to maxSections sections', () => { + const sections = makeSections(10); + const initial = getInitialSections(sections, { maxSections: 2 }); + expect(initial).toHaveLength(2); + }); + + it('should return at least one section even if maxSize is very small', () => { + const sections = makeSections(5); + const initial = getInitialSections(sections, { maxSize: 1 }); + expect(initial).toHaveLength(1); + }); + + it('should return sections up to a specific ID', () => { + const sections = makeSections(5); + const initial = getInitialSections(sections, { upToSectionId: 'section-2' }); + // Should include sections 0, 1, 2 plus one more for context + expect(initial).toHaveLength(4); + }); + }); +}); + +describe('filename-generator', () => { + it('should generate a filename with default template', () => { + const result = FilenameGenerator.generate({ title: 'My Document', extension: 'pdf' }); + expect(result).toBe('my-document.pdf'); + }); + + it('should sanitize illegal characters', () => { + const result = FilenameGenerator.generate({ title: 'file<>:"/\\|?*name', extension: 'txt' }); + expect(result).toBe('filename.txt'); + }); + + it('should handle empty title', () => { + const result = FilenameGenerator.generate({ title: '', extension: 'docx' }); + expect(result).toBe('document.docx'); + }); + + it('should use a custom template with date vars', () => { + const result = FilenameGenerator.generate({ + title: 'Report', + extension: 'pdf', + template: '{title}-{year}', + }); + const year = new Date().getFullYear(); + expect(result).toBe(`report-${year}.pdf`); + }); + + it('should truncate overly long filenames', () => { + const longTitle = 'a'.repeat(300); + const result = FilenameGenerator.generate({ title: longTitle, extension: 'pdf' }); + // 200 char max + '.pdf' = 204 + expect(result.length).toBeLessThanOrEqual(204); + }); +}); + +describe('toc-stripper', () => { + it('should return original markdown when no TOC is found', () => { + const md = '# Hello\nSome content'; + const result = stripTableOfContents(md); + expect(result.tocFound).toBe(false); + expect(result.markdown).toBe(md); + }); + + it('should strip a TOC section with anchor links', () => { + const md = [ + '# My Doc', + '', + '## Table of Contents', + '- [Section 1](#section-1)', + '- [Section 2](#section-2)', + '- [Section 3](#section-3)', + '', + '## Section 1', + 'Content 1', + ].join('\n'); + const result = stripTableOfContents(md); + expect(result.tocFound).toBe(true); + expect(result.markdown).not.toContain('Table of Contents'); + expect(result.markdown).toContain('Section 1'); + expect(result.markdown).toContain('Content 1'); + }); + + it('should not strip a heading named Contents without anchor links', () => { + const md = [ + '## Contents', + 'This is just regular content, not a TOC.', + '', + '## Other', + 'More content', + ].join('\n'); + const result = stripTableOfContents(md); + expect(result.tocFound).toBe(false); + }); + + it('should accept an optional logger', () => { + const logs: string[] = []; + const logger = { + debug: (_ctx: string, msg: string) => logs.push(msg), + info: (_ctx: string, msg: string) => logs.push(msg), + }; + const md = '# Doc\nNo TOC here'; + stripTableOfContents(md, logger); + expect(logs.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/src/utils/filename-generator.ts b/packages/core/src/utils/filename-generator.ts new file mode 100644 index 0000000..b307646 --- /dev/null +++ b/packages/core/src/utils/filename-generator.ts @@ -0,0 +1,88 @@ +/** + * Filename Generator + * Generates filenames from templates with variable substitution + */ + +export interface FilenameGeneratorOptions { + title: string; + extension: string; + template?: string; +} + +export class FilenameGenerator { + private static readonly DEFAULT_TEMPLATE = '{title}'; + private static readonly MAX_FILENAME_LENGTH = 200; + + /** + * Generate a filename from a template + */ + static generate(options: FilenameGeneratorOptions): string { + const { title, extension, template = this.DEFAULT_TEMPLATE } = options; + + const vars: Record = { + '{title}': this.sanitizeFilename(title || 'document'), + ...this.getDateVars(), + }; + + let filename = this.parseTemplate(template, vars); + + // Sanitize the final filename (remove any remaining illegal chars, lowercase) + filename = filename + .replace(/[<>:"/\\|?*]/g, '') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .toLowerCase(); + + // Ensure filename doesn't exceed max length + if (filename.length > this.MAX_FILENAME_LENGTH) { + filename = filename.substring(0, this.MAX_FILENAME_LENGTH); + } + + return `${filename}.${extension}`; + } + + /** + * Sanitize filename by removing illegal characters + */ + private static sanitizeFilename(name: string): string { + return ( + name + .replace(/[<>:"/\\|?*]/g, '') // Remove illegal chars + .replace(/\s+/g, '-') // Replace spaces with dashes + .replace(/-+/g, '-') // Collapse multiple dashes + .replace(/^-|-$/g, '') // Trim leading/trailing dashes + .toLowerCase() + ); + } + + /** + * Parse template by replacing variables + */ + private static parseTemplate(template: string, vars: Record): string { + let result = template; + + // Replace all variables in the template + for (const [key, value] of Object.entries(vars)) { + result = result.replace(new RegExp(key.replace(/[{}]/g, '\\$&'), 'g'), value); + } + + return result; + } + + /** + * Get date-related template variables + */ + private static getDateVars(): Record { + const now = new Date(); + const pad = (n: number) => n.toString().padStart(2, '0'); + + return { + '{date}': `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`, + '{datetime}': `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}`, + '{timestamp}': now.getTime().toString(), + '{year}': now.getFullYear().toString(), + '{month}': pad(now.getMonth() + 1), + '{day}': pad(now.getDate()), + }; + } +} diff --git a/packages/core/src/utils/section-splitter.ts b/packages/core/src/utils/section-splitter.ts new file mode 100644 index 0000000..a301192 --- /dev/null +++ b/packages/core/src/utils/section-splitter.ts @@ -0,0 +1,178 @@ +/** + * Section Splitter + * Splits markdown into logical sections for lazy rendering + */ + +export interface MarkdownSection { + markdown: string; + startLine: number; + endLine: number; + heading?: string; + level?: number; + id: string; +} + +/** + * Split markdown into sections by headings + * Each section includes the heading and all content until the next heading + */ +export function splitIntoSections(markdown: string): MarkdownSection[] { + const lines = markdown.split('\n'); + const sections: MarkdownSection[] = []; + + let currentSection: string[] = []; + let currentHeading: string | undefined; + let currentLevel: number | undefined; + let sectionStartLine = 0; + let sectionId = 0; + + // Track fenced code block state + let inCodeFence = false; + let codeFenceChar = ''; // '`' or '~' + let codeFenceLength = 0; // minimum 3 + + lines.forEach((line, index) => { + // Check for code fence start/end (``` or ~~~) + const fenceMatch = line.match(/^(`{3,}|~{3,})/); + + if (fenceMatch) { + const fenceStr = fenceMatch[1]; + const fenceChar = fenceStr[0]; + const fenceLen = fenceStr.length; + + if (!inCodeFence) { + // Starting a code fence + inCodeFence = true; + codeFenceChar = fenceChar; + codeFenceLength = fenceLen; + } else if (fenceChar === codeFenceChar && fenceLen >= codeFenceLength) { + // Closing the code fence (must use same char and >= length) + inCodeFence = false; + codeFenceChar = ''; + codeFenceLength = 0; + } + // If different fence char or shorter length, it's content inside the fence + } + + // Only check for headings when NOT inside a code fence + const headingMatch = !inCodeFence && line.match(/^(#{1,6})\s+(.+)$/); + + if (headingMatch) { + // Save previous section if it exists + if (currentSection.length > 0) { + sections.push({ + markdown: currentSection.join('\n'), + startLine: sectionStartLine, + endLine: index - 1, + heading: currentHeading, + level: currentLevel, + id: `section-${sectionId++}`, + }); + } + + // Start new section + currentSection = [line]; + currentHeading = headingMatch[2]; + currentLevel = headingMatch[1].length; + sectionStartLine = index; + } else { + currentSection.push(line); + } + }); + + // Add final section + if (currentSection.length > 0) { + sections.push({ + markdown: currentSection.join('\n'), + startLine: sectionStartLine, + endLine: lines.length - 1, + heading: currentHeading, + level: currentLevel, + id: `section-${sectionId++}`, + }); + } + + return sections; +} + +/** + * Split markdown into chunks by size (for very large files without headings) + */ +export function splitIntoChunks(markdown: string, chunkSize = 50000): MarkdownSection[] { + const sections: MarkdownSection[] = []; + const lines = markdown.split('\n'); + + let currentChunk: string[] = []; + let currentSize = 0; + let chunkStartLine = 0; + let chunkId = 0; + + lines.forEach((line, index) => { + currentChunk.push(line); + currentSize += line.length + 1; // +1 for newline + + // If chunk is large enough, save it + if (currentSize >= chunkSize) { + sections.push({ + markdown: currentChunk.join('\n'), + startLine: chunkStartLine, + endLine: index, + id: `chunk-${chunkId++}`, + }); + + currentChunk = []; + currentSize = 0; + chunkStartLine = index + 1; + } + }); + + // Add remaining lines + if (currentChunk.length > 0) { + sections.push({ + markdown: currentChunk.join('\n'), + startLine: chunkStartLine, + endLine: lines.length - 1, + id: `chunk-${chunkId++}`, + }); + } + + return sections; +} + +/** + * Get initial sections to render (above the fold) + */ +export function getInitialSections( + sections: MarkdownSection[], + options: { maxSections?: number; maxSize?: number; upToSectionId?: string } = {} +): MarkdownSection[] { + const { maxSections = 3, maxSize = 30000, upToSectionId } = options; + + // If we need to render up to a specific section (for scroll restoration), + // render all sections up to and including that one + if (upToSectionId) { + const targetIndex = sections.findIndex((s) => s.id === upToSectionId); + if (targetIndex !== -1) { + // Render all sections up to the target, plus one more for context + return sections.slice(0, Math.min(targetIndex + 2, sections.length)); + } + } + + const initial: MarkdownSection[] = []; + let totalSize = 0; + + for (const section of sections) { + if (initial.length >= maxSections) break; + if (totalSize + section.markdown.length > maxSize) break; + + initial.push(section); + totalSize += section.markdown.length; + } + + // Always include at least one section + if (initial.length === 0 && sections.length > 0) { + initial.push(sections[0]); + } + + return initial; +} diff --git a/packages/core/src/utils/toc-stripper.ts b/packages/core/src/utils/toc-stripper.ts new file mode 100644 index 0000000..f317cdc --- /dev/null +++ b/packages/core/src/utils/toc-stripper.ts @@ -0,0 +1,163 @@ +/** + * TOC Stripper + * Detects and removes Table of Contents sections from markdown + */ + +export interface TocStripResult { + markdown: string; + tocFound: boolean; + tocRange?: { start: number; end: number }; +} + +/** + * Optional logger interface for debug output. + * When not provided, logging is silently skipped. + */ +export interface TocStripperLogger { + debug(context: string, ...args: unknown[]): void; + info(context: string, ...args: unknown[]): void; +} + +const noopLogger: TocStripperLogger = { + debug: () => {}, + info: () => {}, +}; + +/** + * Strip Table of Contents sections from markdown when custom TOC is enabled + * Detects common TOC patterns: + * - Headings like "Table of Contents", "Contents", "TOC" + * - Followed by list items with anchor links + * + * @param markdown - The markdown content to strip TOC from + * @param logger - Optional logger for debug output (defaults to no-op) + */ +export function stripTableOfContents( + markdown: string, + logger: TocStripperLogger = noopLogger +): TocStripResult { + const lines = markdown.split('\n'); + const tocRanges: Array<{ start: number; end: number }> = []; + + // Pattern to match TOC headings (case-insensitive) + const tocHeadingPattern = /^#{1,6}\s+(table of contents|contents|toc)\s*$/i; + + // Pattern to match list items (with or without anchor links) + const listItemPattern = /^\s*[-*+]\s+/; + const orderedListPattern = /^\s*\d+\.\s+/; + const anchorLinkPattern = /\[.*?\]\(#.*?\)/; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Check if this line is a TOC heading + if (tocHeadingPattern.test(line.trim())) { + logger.debug('TocStripper', `Found TOC heading at line ${i}: "${line}"`); + + const tocStart = i; + let tocEnd = i; + let anchorLinkCount = 0; + let consecutiveNonListLines = 0; + + // Scan forward to find the extent of the TOC + for (let j = i + 1; j < lines.length; j++) { + const currentLine = lines[j]; + const trimmedLine = currentLine.trim(); + + // Stop at the next heading + if (/^#{1,6}\s+/.test(trimmedLine)) { + logger.debug('TocStripper', `TOC ends at line ${j - 1} (next heading found)`); + break; + } + + // Check if this is a list item + const isListItem = + listItemPattern.test(currentLine) || orderedListPattern.test(currentLine); + + if (isListItem) { + tocEnd = j; + consecutiveNonListLines = 0; + + // Count anchor links to confirm this is a TOC + if (anchorLinkPattern.test(currentLine)) { + anchorLinkCount++; + } + } else if (trimmedLine === '') { + // Empty lines are okay within TOC, but track consecutive ones + consecutiveNonListLines++; + + // If we have multiple consecutive blank lines, stop + if (consecutiveNonListLines > 2) { + logger.debug( + 'TocStripper', + `TOC ends at line ${tocEnd} (multiple blank lines after list)` + ); + break; + } + } else { + // Non-list, non-empty line - TOC has ended + logger.debug('TocStripper', `TOC ends at line ${tocEnd} (non-list content found)`); + break; + } + } + + // Only consider it a TOC if we found list items with anchor links + // OR if we found ordered/unordered lists right after the heading + const hasListItems = tocEnd > tocStart; + const likelyToc = anchorLinkCount >= 2 || (hasListItems && anchorLinkCount >= 1); + + if (likelyToc) { + logger.info( + 'TocStripper', + `Confirmed TOC from line ${tocStart} to ${tocEnd} (${anchorLinkCount} anchor links)` + ); + tocRanges.push({ start: tocStart, end: tocEnd }); + // Skip ahead to avoid re-processing + i = tocEnd; + } else { + logger.debug( + 'TocStripper', + `Skipping potential TOC at line ${tocStart} (insufficient anchor links: ${anchorLinkCount})` + ); + } + } + } + + // If no TOC found, return original markdown + if (tocRanges.length === 0) { + logger.debug('TocStripper', 'No TOC found in markdown'); + return { + markdown, + tocFound: false, + }; + } + + // Remove TOC ranges (in reverse order to preserve line numbers) + const strippedLines = [...lines]; + for (let i = tocRanges.length - 1; i >= 0; i--) { + const range = tocRanges[i]; + // Remove lines from start to end (inclusive) + strippedLines.splice(range.start, range.end - range.start + 1); + + // Clean up excess blank lines after removal + // Remove consecutive blank lines at the splice point + while ( + range.start < strippedLines.length && + range.start > 0 && + strippedLines[range.start]?.trim() === '' && + strippedLines[range.start - 1]?.trim() === '' + ) { + strippedLines.splice(range.start, 1); + } + } + + const strippedMarkdown = strippedLines.join('\n'); + + logger.info('TocStripper', `Stripped ${tocRanges.length} TOC section(s) from markdown`); + + return { + markdown: strippedMarkdown, + tocFound: true, + tocRange: tocRanges[0], // Return first TOC range for reference + }; +} diff --git a/src/utils/filename-generator.ts b/src/utils/filename-generator.ts index caccb9f..a33afba 100644 --- a/src/utils/filename-generator.ts +++ b/src/utils/filename-generator.ts @@ -1,89 +1 @@ -/** - * Filename Generator - * Generates filenames from templates with variable substitution - */ - -export interface FilenameGeneratorOptions { - title: string; - extension: string; - template?: string; -} - -export class FilenameGenerator { - private static readonly DEFAULT_TEMPLATE = '{title}'; - private static readonly MAX_FILENAME_LENGTH = 200; - - /** - * Generate a filename from a template - */ - static generate(options: FilenameGeneratorOptions): string { - const { title, extension, template = this.DEFAULT_TEMPLATE } = options; - - const vars: Record = { - '{title}': this.sanitizeFilename(title || 'document'), - ...this.getDateVars(), - }; - - let filename = this.parseTemplate(template, vars); - - // Sanitize the final filename (remove any remaining illegal chars, lowercase) - filename = filename - .replace(/[<>:"/\\|?*]/g, '') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .toLowerCase(); - - // Ensure filename doesn't exceed max length - if (filename.length > this.MAX_FILENAME_LENGTH) { - filename = filename.substring(0, this.MAX_FILENAME_LENGTH); - } - - return `${filename}.${extension}`; - } - - /** - * Sanitize filename by removing illegal characters - */ - private static sanitizeFilename(name: string): string { - return ( - name - .replace(/[<>:"/\\|?*]/g, '') // Remove illegal chars - .replace(/\s+/g, '-') // Replace spaces with dashes - .replace(/-+/g, '-') // Collapse multiple dashes - .replace(/^-|-$/g, '') // Trim leading/trailing dashes - .toLowerCase() - ); - } - - /** - * Parse template by replacing variables - */ - private static parseTemplate(template: string, vars: Record): string { - let result = template; - - // Replace all variables in the template - for (const [key, value] of Object.entries(vars)) { - result = result.replace(new RegExp(key.replace(/[{}]/g, '\\$&'), 'g'), value); - } - - return result; - } - - /** - * Get date-related template variables - */ - private static getDateVars(): Record { - const now = new Date(); - const pad = (n: number) => n.toString().padStart(2, '0'); - - return { - '{date}': `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`, - '{datetime}': `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}`, - '{timestamp}': now.getTime().toString(), - '{year}': now.getFullYear().toString(), - '{month}': pad(now.getMonth() + 1), - '{day}': pad(now.getDate()), - }; - } -} - +export * from '../../packages/core/src/utils/filename-generator'; diff --git a/src/utils/section-splitter.ts b/src/utils/section-splitter.ts index a301192..7fb20df 100644 --- a/src/utils/section-splitter.ts +++ b/src/utils/section-splitter.ts @@ -1,178 +1 @@ -/** - * Section Splitter - * Splits markdown into logical sections for lazy rendering - */ - -export interface MarkdownSection { - markdown: string; - startLine: number; - endLine: number; - heading?: string; - level?: number; - id: string; -} - -/** - * Split markdown into sections by headings - * Each section includes the heading and all content until the next heading - */ -export function splitIntoSections(markdown: string): MarkdownSection[] { - const lines = markdown.split('\n'); - const sections: MarkdownSection[] = []; - - let currentSection: string[] = []; - let currentHeading: string | undefined; - let currentLevel: number | undefined; - let sectionStartLine = 0; - let sectionId = 0; - - // Track fenced code block state - let inCodeFence = false; - let codeFenceChar = ''; // '`' or '~' - let codeFenceLength = 0; // minimum 3 - - lines.forEach((line, index) => { - // Check for code fence start/end (``` or ~~~) - const fenceMatch = line.match(/^(`{3,}|~{3,})/); - - if (fenceMatch) { - const fenceStr = fenceMatch[1]; - const fenceChar = fenceStr[0]; - const fenceLen = fenceStr.length; - - if (!inCodeFence) { - // Starting a code fence - inCodeFence = true; - codeFenceChar = fenceChar; - codeFenceLength = fenceLen; - } else if (fenceChar === codeFenceChar && fenceLen >= codeFenceLength) { - // Closing the code fence (must use same char and >= length) - inCodeFence = false; - codeFenceChar = ''; - codeFenceLength = 0; - } - // If different fence char or shorter length, it's content inside the fence - } - - // Only check for headings when NOT inside a code fence - const headingMatch = !inCodeFence && line.match(/^(#{1,6})\s+(.+)$/); - - if (headingMatch) { - // Save previous section if it exists - if (currentSection.length > 0) { - sections.push({ - markdown: currentSection.join('\n'), - startLine: sectionStartLine, - endLine: index - 1, - heading: currentHeading, - level: currentLevel, - id: `section-${sectionId++}`, - }); - } - - // Start new section - currentSection = [line]; - currentHeading = headingMatch[2]; - currentLevel = headingMatch[1].length; - sectionStartLine = index; - } else { - currentSection.push(line); - } - }); - - // Add final section - if (currentSection.length > 0) { - sections.push({ - markdown: currentSection.join('\n'), - startLine: sectionStartLine, - endLine: lines.length - 1, - heading: currentHeading, - level: currentLevel, - id: `section-${sectionId++}`, - }); - } - - return sections; -} - -/** - * Split markdown into chunks by size (for very large files without headings) - */ -export function splitIntoChunks(markdown: string, chunkSize = 50000): MarkdownSection[] { - const sections: MarkdownSection[] = []; - const lines = markdown.split('\n'); - - let currentChunk: string[] = []; - let currentSize = 0; - let chunkStartLine = 0; - let chunkId = 0; - - lines.forEach((line, index) => { - currentChunk.push(line); - currentSize += line.length + 1; // +1 for newline - - // If chunk is large enough, save it - if (currentSize >= chunkSize) { - sections.push({ - markdown: currentChunk.join('\n'), - startLine: chunkStartLine, - endLine: index, - id: `chunk-${chunkId++}`, - }); - - currentChunk = []; - currentSize = 0; - chunkStartLine = index + 1; - } - }); - - // Add remaining lines - if (currentChunk.length > 0) { - sections.push({ - markdown: currentChunk.join('\n'), - startLine: chunkStartLine, - endLine: lines.length - 1, - id: `chunk-${chunkId++}`, - }); - } - - return sections; -} - -/** - * Get initial sections to render (above the fold) - */ -export function getInitialSections( - sections: MarkdownSection[], - options: { maxSections?: number; maxSize?: number; upToSectionId?: string } = {} -): MarkdownSection[] { - const { maxSections = 3, maxSize = 30000, upToSectionId } = options; - - // If we need to render up to a specific section (for scroll restoration), - // render all sections up to and including that one - if (upToSectionId) { - const targetIndex = sections.findIndex((s) => s.id === upToSectionId); - if (targetIndex !== -1) { - // Render all sections up to the target, plus one more for context - return sections.slice(0, Math.min(targetIndex + 2, sections.length)); - } - } - - const initial: MarkdownSection[] = []; - let totalSize = 0; - - for (const section of sections) { - if (initial.length >= maxSections) break; - if (totalSize + section.markdown.length > maxSize) break; - - initial.push(section); - totalSize += section.markdown.length; - } - - // Always include at least one section - if (initial.length === 0 && sections.length > 0) { - initial.push(sections[0]); - } - - return initial; -} +export * from '../../packages/core/src/utils/section-splitter'; diff --git a/src/utils/toc-stripper.ts b/src/utils/toc-stripper.ts index e2838d9..b0b315f 100644 --- a/src/utils/toc-stripper.ts +++ b/src/utils/toc-stripper.ts @@ -1,146 +1,15 @@ /** - * TOC Stripper - * Detects and removes Table of Contents sections from markdown + * Re-export shim: delegates to @mdview/core and injects the Chrome extension logger. */ +export type { TocStripResult, TocStripperLogger } from '../../packages/core/src/utils/toc-stripper'; +import { stripTableOfContents as coreStripTableOfContents } from '../../packages/core/src/utils/toc-stripper'; import { debug } from './debug-logger'; -export interface TocStripResult { - markdown: string; - tocFound: boolean; - tocRange?: { start: number; end: number }; -} - /** - * Strip Table of Contents sections from markdown when custom TOC is enabled - * Detects common TOC patterns: - * - Headings like "Table of Contents", "Contents", "TOC" - * - Followed by list items with anchor links + * Strip Table of Contents sections from markdown. + * This wrapper injects the extension's debug logger into the core implementation. */ -export function stripTableOfContents(markdown: string): TocStripResult { - const lines = markdown.split('\n'); - const tocRanges: Array<{ start: number; end: number }> = []; - - // Pattern to match TOC headings (case-insensitive) - const tocHeadingPattern = /^#{1,6}\s+(table of contents|contents|toc)\s*$/i; - - // Pattern to match list items (with or without anchor links) - const listItemPattern = /^\s*[-*+]\s+/; - const orderedListPattern = /^\s*\d+\.\s+/; - const anchorLinkPattern = /\[.*?\]\(#.*?\)/; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Check if this line is a TOC heading - if (tocHeadingPattern.test(line.trim())) { - debug.debug('TocStripper', `Found TOC heading at line ${i}: "${line}"`); - - const tocStart = i; - let tocEnd = i; - let anchorLinkCount = 0; - let consecutiveNonListLines = 0; - - // Scan forward to find the extent of the TOC - for (let j = i + 1; j < lines.length; j++) { - const currentLine = lines[j]; - const trimmedLine = currentLine.trim(); - - // Stop at the next heading - if (/^#{1,6}\s+/.test(trimmedLine)) { - debug.debug('TocStripper', `TOC ends at line ${j - 1} (next heading found)`); - break; - } - - // Check if this is a list item - const isListItem = - listItemPattern.test(currentLine) || orderedListPattern.test(currentLine); - - if (isListItem) { - tocEnd = j; - consecutiveNonListLines = 0; - - // Count anchor links to confirm this is a TOC - if (anchorLinkPattern.test(currentLine)) { - anchorLinkCount++; - } - } else if (trimmedLine === '') { - // Empty lines are okay within TOC, but track consecutive ones - consecutiveNonListLines++; - - // If we have multiple consecutive blank lines, stop - if (consecutiveNonListLines > 2) { - debug.debug( - 'TocStripper', - `TOC ends at line ${tocEnd} (multiple blank lines after list)` - ); - break; - } - } else { - // Non-list, non-empty line - TOC has ended - debug.debug('TocStripper', `TOC ends at line ${tocEnd} (non-list content found)`); - break; - } - } - - // Only consider it a TOC if we found list items with anchor links - // OR if we found ordered/unordered lists right after the heading - const hasListItems = tocEnd > tocStart; - const likelyToc = anchorLinkCount >= 2 || (hasListItems && anchorLinkCount >= 1); - - if (likelyToc) { - debug.info( - 'TocStripper', - `Confirmed TOC from line ${tocStart} to ${tocEnd} (${anchorLinkCount} anchor links)` - ); - tocRanges.push({ start: tocStart, end: tocEnd }); - // Skip ahead to avoid re-processing - i = tocEnd; - } else { - debug.debug( - 'TocStripper', - `Skipping potential TOC at line ${tocStart} (insufficient anchor links: ${anchorLinkCount})` - ); - } - } - } - - // If no TOC found, return original markdown - if (tocRanges.length === 0) { - debug.debug('TocStripper', 'No TOC found in markdown'); - return { - markdown, - tocFound: false, - }; - } - - // Remove TOC ranges (in reverse order to preserve line numbers) - const strippedLines = [...lines]; - for (let i = tocRanges.length - 1; i >= 0; i--) { - const range = tocRanges[i]; - // Remove lines from start to end (inclusive) - strippedLines.splice(range.start, range.end - range.start + 1); - - // Clean up excess blank lines after removal - // Remove consecutive blank lines at the splice point - while ( - range.start < strippedLines.length && - range.start > 0 && - strippedLines[range.start]?.trim() === '' && - strippedLines[range.start - 1]?.trim() === '' - ) { - strippedLines.splice(range.start, 1); - } - } - - const strippedMarkdown = strippedLines.join('\n'); - - debug.info('TocStripper', `Stripped ${tocRanges.length} TOC section(s) from markdown`); - - return { - markdown: strippedMarkdown, - tocFound: true, - tocRange: tocRanges[0], // Return first TOC range for reference - }; +export function stripTableOfContents(markdown: string) { + return coreStripTableOfContents(markdown, debug); } - From 8c6a3c9d4d83484fb233ad423451cdf92a1595d2 Mon Sep 17 00:00:00 2001 From: James Ainslie <42301770+jamesainslie@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:20:26 -0400 Subject: [PATCH 07/26] refactor(core): move markdown-converter, cache-manager, frontmatter to @mdview/core --- package-lock.json | 13 +- packages/core/package.json | 11 + packages/core/src/__tests__/converter.test.ts | 232 ++++++++++++ packages/core/src/cache-manager.ts | 239 ++++++++++++ packages/core/src/frontmatter-extractor.ts | 78 ++++ packages/core/src/index.ts | 15 + packages/core/src/markdown-converter.ts | 353 ++++++++++++++++++ src/core/cache-manager.ts | 237 +----------- src/core/frontmatter-extractor.ts | 81 +--- src/core/markdown-converter.ts | 353 +----------------- 10 files changed, 949 insertions(+), 663 deletions(-) create mode 100644 packages/core/src/__tests__/converter.test.ts create mode 100644 packages/core/src/cache-manager.ts create mode 100644 packages/core/src/frontmatter-extractor.ts create mode 100644 packages/core/src/markdown-converter.ts diff --git a/package-lock.json b/package-lock.json index 74f53af..4d77690 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10234,7 +10234,18 @@ "packages/core": { "name": "@mdview/core", "version": "0.0.1", - "license": "MIT" + "license": "MIT", + "dependencies": { + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", + "markdown-it-attrs": "^4.1.6", + "markdown-it-emoji": "^3.0.0", + "markdown-it-footnote": "^4.0.0", + "markdown-it-task-lists": "^2.1.1" + }, + "devDependencies": { + "@types/markdown-it": "^13.0.9" + } } } } diff --git a/packages/core/package.json b/packages/core/package.json index 8f21f63..66845cc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -10,6 +10,17 @@ "test": "vitest", "test:ci": "vitest --run" }, + "dependencies": { + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", + "markdown-it-attrs": "^4.1.6", + "markdown-it-emoji": "^3.0.0", + "markdown-it-footnote": "^4.0.0", + "markdown-it-task-lists": "^2.1.1" + }, + "devDependencies": { + "@types/markdown-it": "^13.0.9" + }, "author": "James Ainslie", "license": "MIT" } diff --git a/packages/core/src/__tests__/converter.test.ts b/packages/core/src/__tests__/converter.test.ts new file mode 100644 index 0000000..a602827 --- /dev/null +++ b/packages/core/src/__tests__/converter.test.ts @@ -0,0 +1,232 @@ +import { MarkdownConverter } from '../markdown-converter'; +import { extractFrontmatter, renderFrontmatterHtml } from '../frontmatter-extractor'; +import { CacheManager } from '../cache-manager'; + +describe('MarkdownConverter', () => { + let converter: MarkdownConverter; + + beforeEach(() => { + converter = new MarkdownConverter(); + }); + + it('should convert basic markdown to HTML', () => { + const result = converter.convert('# Hello World'); + expect(result.html).toContain(' { + const result = converter.convert('# Title\n\n## Subtitle'); + expect(result.metadata.headings).toHaveLength(2); + expect(result.metadata.headings[0].level).toBe(1); + // The anchor plugin wraps heading content in text, + // making the inline token's content empty. The heading ID is still generated. + expect(result.metadata.headings[0].id).toBe(''); + expect(result.metadata.headings[1].level).toBe(2); + }); + + it('should count words', () => { + const result = converter.convert('one two three four five'); + expect(result.metadata.wordCount).toBe(5); + }); + + it('should detect code blocks', () => { + const result = converter.convert('```javascript\nconsole.log("hi");\n```'); + expect(result.metadata.codeBlocks).toHaveLength(1); + expect(result.metadata.codeBlocks[0].language).toBe('javascript'); + }); + + it('should detect mermaid blocks', () => { + const result = converter.convert('```mermaid\ngraph TD\n A --> B\n```'); + expect(result.metadata.mermaidBlocks).toHaveLength(1); + expect(result.metadata.mermaidBlocks[0].code).toContain('graph TD'); + expect(result.html).toContain('mermaid-container'); + }); + + it('should extract link metadata', () => { + const result = converter.convert('[Example](https://example.com)'); + expect(result.metadata.links).toHaveLength(1); + expect(result.metadata.links[0].href).toBe('https://example.com'); + }); + + it('should extract image metadata', () => { + const result = converter.convert('![alt text](image.png "title")'); + expect(result.metadata.images).toHaveLength(1); + expect(result.metadata.images[0].src).toBe('image.png'); + expect(result.metadata.images[0].alt).toBe('alt text'); + }); + + it('should render task lists', () => { + const result = converter.convert('- [x] Done\n- [ ] Todo'); + expect(result.html).toContain('type="checkbox"'); + }); + + it('should validate valid markdown', () => { + const result = converter.validateSyntax('# Valid markdown'); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should handle empty input', () => { + const result = converter.convert(''); + expect(result.html).toBeDefined(); + expect(result.errors).toHaveLength(0); + }); +}); + +describe('extractFrontmatter', () => { + it('should extract frontmatter from markdown', () => { + const markdown = '---\ntitle: Hello\nauthor: Test\n---\n# Content'; + const result = extractFrontmatter(markdown); + expect(result.frontmatter).not.toBeNull(); + expect(result.frontmatter!.title).toBe('Hello'); + expect(result.frontmatter!.author).toBe('Test'); + expect(result.cleanedMarkdown).toBe('# Content'); + }); + + it('should return null frontmatter when none present', () => { + const result = extractFrontmatter('# No frontmatter'); + expect(result.frontmatter).toBeNull(); + expect(result.cleanedMarkdown).toBe('# No frontmatter'); + }); + + it('should handle empty input', () => { + const result = extractFrontmatter(''); + expect(result.frontmatter).toBeNull(); + expect(result.cleanedMarkdown).toBe(''); + }); + + it('should strip surrounding quotes from values', () => { + const markdown = '---\ntitle: "Quoted Title"\n---\n'; + const result = extractFrontmatter(markdown); + expect(result.frontmatter!.title).toBe('Quoted Title'); + }); +}); + +describe('renderFrontmatterHtml', () => { + it('should render frontmatter as details/table HTML', () => { + const html = renderFrontmatterHtml({ title: 'Test', author: 'Author' }); + expect(html).toContain(' { + let cache: CacheManager; + + beforeEach(() => { + cache = new CacheManager({ maxSize: 3 }); + }); + + it('should return null for missing keys', () => { + expect(cache.get('nonexistent')).toBeNull(); + }); + + it('should store and retrieve cache entries', () => { + const result = { + html: '

test

', + metadata: { + wordCount: 1, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }, + highlightedBlocks: new Map(), + mermaidSVGs: new Map(), + timestamp: Date.now(), + cacheKey: 'test-key', + }; + + cache.set('test-key', result, '/test.md', 'hash123', 'github-light'); + const retrieved = cache.get('test-key'); + expect(retrieved).not.toBeNull(); + expect(retrieved!.html).toBe('

test

'); + }); + + it('should evict oldest entry when maxSize is reached', () => { + const makeResult = (key: string) => ({ + html: `

${key}

`, + metadata: { + wordCount: 1, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }, + highlightedBlocks: new Map(), + mermaidSVGs: new Map(), + timestamp: Date.now(), + cacheKey: key, + }); + + cache.set('key1', makeResult('key1'), '/a.md', 'h1', 'github-light'); + cache.set('key2', makeResult('key2'), '/b.md', 'h2', 'github-light'); + cache.set('key3', makeResult('key3'), '/c.md', 'h3', 'github-light'); + // This should evict key1 (oldest) + cache.set('key4', makeResult('key4'), '/d.md', 'h4', 'github-light'); + + expect(cache.get('key1')).toBeNull(); + expect(cache.get('key4')).not.toBeNull(); + }); + + it('should invalidate by path', () => { + const result = { + html: '

test

', + metadata: { + wordCount: 1, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }, + highlightedBlocks: new Map(), + mermaidSVGs: new Map(), + timestamp: Date.now(), + cacheKey: 'k', + }; + + cache.set('k', result, '/file.md', 'hash', 'github-light'); + cache.invalidateByPath('/file.md'); + expect(cache.get('k')).toBeNull(); + }); + + it('should clear all entries', () => { + const result = { + html: '

test

', + metadata: { + wordCount: 1, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }, + highlightedBlocks: new Map(), + mermaidSVGs: new Map(), + timestamp: Date.now(), + cacheKey: 'k', + }; + + cache.set('k', result, '/file.md', 'hash', 'github-light'); + cache.clear(); + expect(cache.getStats().size).toBe(0); + }); + + it('should report stats', () => { + const stats = cache.getStats(); + expect(stats.size).toBe(0); + expect(stats.maxSize).toBe(3); + expect(stats.oldestEntry).toBeNull(); + }); +}); diff --git a/packages/core/src/cache-manager.ts b/packages/core/src/cache-manager.ts new file mode 100644 index 0000000..8b297cb --- /dev/null +++ b/packages/core/src/cache-manager.ts @@ -0,0 +1,239 @@ +/** + * Cache Manager + * In-memory cache with SHA-256 hash-based keys for rendered markdown content + */ + +import type { CachedResult, CacheEntry, ThemeName } from './types/index'; + +// Lightweight debug facade — no Chrome dependency +const debug = { + log: (..._args: unknown[]) => {}, + info: (..._args: unknown[]) => {}, +}; + +export interface CacheOptions { + maxSize?: number; // Max cache entries (default: 50) + maxAge?: number; // Max age in ms (default: 1 hour) +} + +export class CacheManager { + private cache: Map = new Map(); + private fileHashes: Map = new Map(); + private maxSize: number; + private maxAge: number; + + constructor(options: CacheOptions = {}) { + this.maxSize = options.maxSize || 50; + this.maxAge = options.maxAge || 3600000; // 1 hour + debug.log('CacheManager', 'Initialized with max size:', this.maxSize); + } + + /** + * Generate cache key from file path, content, theme, and preferences + */ + async generateKey( + filePath: string, + content: string, + theme: ThemeName, + preferences: Record + ): Promise { + // Create stable string from inputs + const input = JSON.stringify({ + path: filePath, + content, + theme, + prefs: preferences, + }); + + // Generate SHA-256 hash + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + + return hashHex; + } + + /** + * Generate content hash for file monitoring + */ + async generateContentHash(content: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(content); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + + return hashHex; + } + + /** + * Get cached result by key + */ + get(key: string): CachedResult | null { + const entry = this.cache.get(key); + + if (!entry) { + debug.info('CacheManager', 'Cache miss for key:', key.substring(0, 8)); + return null; + } + + // Check if expired + const age = Date.now() - entry.result.timestamp; + if (age > this.maxAge) { + debug.info('CacheManager', 'Cache expired for key:', key.substring(0, 8)); + this.cache.delete(key); + return null; + } + + // Update last accessed time + entry.lastAccessed = Date.now(); + debug.info('CacheManager', 'Cache hit for key:', key.substring(0, 8)); + + return entry.result; + } + + /** + * Set cached result + */ + set( + key: string, + result: CachedResult, + filePath: string, + contentHash: string, + theme: ThemeName + ): void { + // Evict old entries if cache is full + if (this.cache.size >= this.maxSize) { + this.evictOldest(); + } + + const entry: CacheEntry = { + result, + filePath, + contentHash, + theme, + lastAccessed: Date.now(), + }; + + this.cache.set(key, entry); + this.fileHashes.set(filePath, contentHash); + + debug.info( + 'CacheManager', + `Cached result for key: ${key.substring(0, 8)}, size: ${this.cache.size}/${this.maxSize}` + ); + } + + /** + * Invalidate cache entry by key + */ + invalidate(key: string): void { + const entry = this.cache.get(key); + if (entry) { + this.cache.delete(key); + // Remove file hash if this was the last entry for this file + const hasOtherEntries = Array.from(this.cache.values()).some( + (e) => e.filePath === entry.filePath + ); + if (!hasOtherEntries) { + this.fileHashes.delete(entry.filePath); + } + debug.info('CacheManager', 'Invalidated cache key:', key.substring(0, 8)); + } + } + + /** + * Invalidate all cache entries for a file path + */ + invalidateByPath(filePath: string): void { + let invalidated = 0; + for (const [key, entry] of this.cache.entries()) { + if (entry.filePath === filePath) { + this.cache.delete(key); + invalidated++; + } + } + this.fileHashes.delete(filePath); + debug.info('CacheManager', `Invalidated ${invalidated} entries for path:`, filePath); + } + + /** + * Check if file content has changed + */ + async hasFileChanged(filePath: string, currentContent: string): Promise { + const storedHash = this.fileHashes.get(filePath); + if (!storedHash) { + return true; // No hash stored, consider it changed + } + + const currentHash = await this.generateContentHash(currentContent); + return currentHash !== storedHash; + } + + /** + * Get file hash + */ + getFileHash(filePath: string): string | undefined { + return this.fileHashes.get(filePath); + } + + /** + * Clear all cache entries + */ + clear(): void { + const size = this.cache.size; + this.cache.clear(); + this.fileHashes.clear(); + debug.info('CacheManager', `Cleared ${size} cache entries`); + } + + /** + * Get cache statistics + */ + getStats(): { + size: number; + maxSize: number; + hitRate: number; + oldestEntry: number | null; + } { + let oldestTimestamp: number | null = null; + + for (const entry of this.cache.values()) { + if (oldestTimestamp === null || entry.result.timestamp < oldestTimestamp) { + oldestTimestamp = entry.result.timestamp; + } + } + + return { + size: this.cache.size, + maxSize: this.maxSize, + hitRate: 0, // Would need to track hits/misses for real hit rate + oldestEntry: oldestTimestamp, + }; + } + + /** + * Evict least recently accessed entry + */ + private evictOldest(): void { + let oldestKey: string | null = null; + let oldestTime = Infinity; + + for (const [key, entry] of this.cache.entries()) { + if (entry.lastAccessed < oldestTime) { + oldestTime = entry.lastAccessed; + oldestKey = key; + } + } + + if (oldestKey) { + this.invalidate(oldestKey); + debug.info('CacheManager', 'Evicted oldest entry:', oldestKey.substring(0, 8)); + } + } +} + +// Export singleton +export const cacheManager = new CacheManager(); diff --git a/packages/core/src/frontmatter-extractor.ts b/packages/core/src/frontmatter-extractor.ts new file mode 100644 index 0000000..b9d8d49 --- /dev/null +++ b/packages/core/src/frontmatter-extractor.ts @@ -0,0 +1,78 @@ +/** + * Frontmatter Extractor + * Extracts YAML frontmatter from markdown and renders it as an HTML card + */ + +export interface FrontmatterResult { + cleanedMarkdown: string; + frontmatter: Record | null; +} + +const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; + +/** + * Extract YAML frontmatter from the beginning of a markdown string. + * Returns the cleaned markdown (frontmatter stripped) and parsed key-value pairs. + */ +export function extractFrontmatter(markdown: string): FrontmatterResult { + if (!markdown) { + return { cleanedMarkdown: '', frontmatter: null }; + } + + const match = FRONTMATTER_RE.exec(markdown); + if (!match) { + return { cleanedMarkdown: markdown, frontmatter: null }; + } + + const rawBlock = match[1]; + const frontmatter: Record = {}; + + for (const line of rawBlock.split(/\r?\n/)) { + const colonIdx = line.indexOf(':'); + if (colonIdx === -1) continue; + + const key = line.slice(0, colonIdx).trim(); + if (!key) continue; + + let value = line.slice(colonIdx + 1).trim(); + + // Strip surrounding quotes + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + frontmatter[key] = value; + } + + const cleanedMarkdown = markdown.slice(match[0].length); + + return { + cleanedMarkdown, + frontmatter: Object.keys(frontmatter).length > 0 ? frontmatter : null, + }; +} + +/** + * Render frontmatter data as a collapsible HTML details card. + */ +export function renderFrontmatterHtml(frontmatter: Record): string { + const rows = Object.entries(frontmatter) + .map( + ([key, value]) => + `${escapeHtml(key)}${escapeHtml(value)}` + ) + .join(''); + + return `
Frontmatter${rows}
`; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1da5182..1b6448f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,18 @@ // @mdview/core - shared rendering engine // Modules will be exported here as they are extracted from the Chrome extension. export const VERSION = '0.0.1'; + +// Types +export type * from './types/index'; + +// Markdown converter +export { MarkdownConverter, markdownConverter } from './markdown-converter'; +export type { ConvertOptions } from './markdown-converter'; + +// Cache manager +export { CacheManager, cacheManager } from './cache-manager'; +export type { CacheOptions } from './cache-manager'; + +// Frontmatter extractor +export { extractFrontmatter, renderFrontmatterHtml } from './frontmatter-extractor'; +export type { FrontmatterResult } from './frontmatter-extractor'; diff --git a/packages/core/src/markdown-converter.ts b/packages/core/src/markdown-converter.ts new file mode 100644 index 0000000..31154d6 --- /dev/null +++ b/packages/core/src/markdown-converter.ts @@ -0,0 +1,353 @@ +/** + * Markdown Converter + * Parses markdown to HTML using markdown-it with CommonMark + GFM support + */ + +import MarkdownIt from 'markdown-it'; +import markdownItAttrs from 'markdown-it-attrs'; +import markdownItAnchor from 'markdown-it-anchor'; +import markdownItTaskLists from 'markdown-it-task-lists'; +import * as emojiPlugin from 'markdown-it-emoji'; +import type { ConversionResult, ParseError, ValidationResult } from './types/index'; + +export interface ConvertOptions { + baseUrl?: string; + breaks?: boolean; + linkify?: boolean; + typographer?: boolean; + enableHtml?: boolean; + highlight?: (code: string, lang: string) => string; +} + +export class MarkdownConverter { + private md: MarkdownIt; + private metadata: ConversionResult['metadata'] = { + wordCount: 0, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }; + + constructor(options?: ConvertOptions) { + // Initialize markdown-it + this.md = new MarkdownIt({ + html: options?.enableHtml ?? false, // Security: no raw HTML tags by default + breaks: options?.breaks ?? true, // GFM line breaks + linkify: options?.linkify ?? true, // Auto-convert URLs to links + typographer: options?.typographer ?? true, // Smart quotes and replacements + highlight: options?.highlight, + }); + + // Configure plugins + this.configurePlugins(); + } + + private configurePlugins(): void { + // markdown-it-attrs: Add custom attributes to elements + this.md.use(markdownItAttrs, { + leftDelimiter: '{', + rightDelimiter: '}', + allowedAttributes: ['id', 'class', 'style'], + }); + + // markdown-it-anchor: Add anchors to headings + this.md.use(markdownItAnchor, { + permalink: markdownItAnchor.permalink.headerLink({ + safariReaderFix: true, + }), + slugify: (s: string) => + s + .toLowerCase() + .trim() + .replace(/[\s\W-]+/g, '-') + .replace(/^-+|-+$/g, ''), + }); + + // markdown-it-task-lists: GitHub-style task lists + this.md.use(markdownItTaskLists, { + enabled: true, + label: true, + labelAfter: true, + }); + + // markdown-it-emoji: Emoji support + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unnecessary-type-assertion + const emojiPluginToUse = ((emojiPlugin as any).full || emojiPlugin) as any; + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.md.use(emojiPluginToUse); + + // markdown-it-footnote: Footnotes support + // TODO: markdown-it-footnote has ESM export issues, will be fixed in future version + // this.md.use(markdownItFootnote); + + // Add custom rule for Mermaid blocks + this.addMermaidRule(); + } + + private addMermaidRule(): void { + const defaultFenceRenderer = + this.md.renderer.rules.fence || + function (tokens, idx, options, _env, self) { + return self.renderToken(tokens, idx, options); + }; + + this.md.renderer.rules.fence = (tokens, idx, options, env, self) => { + const token = tokens[idx]; + const info = token.info ? token.info.trim() : ''; + const langName = info.split(/\s+/g)[0]; + + // Check if it's a Mermaid diagram + if (langName === 'mermaid' || langName === 'mmd') { + const code = token.content.trim(); + const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`; + + // Store in metadata with ID mapping + this.metadata.mermaidBlocks.push({ + code, + line: token.map ? token.map[0] : 0, + }); + + // Store code in global registry (bypasses DOM insertion issues) + // Guard for non-browser environments + if (typeof window !== 'undefined') { + if (!window.__MDVIEW_MERMAID_CODE__) { + window.__MDVIEW_MERMAID_CODE__ = new Map(); + } + window.__MDVIEW_MERMAID_CODE__.set(id, code); + } + + // Escape the code for safe HTML attribute storage + // This allows the code to survive caching and be retrieved later + const escapedCode = code + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + + // Return container with code stored in data attribute for cache persistence + return `
+
Rendering diagram...
+
\n`; + } + + // Check if it's a code block for syntax highlighting + if (langName) { + this.metadata.codeBlocks.push({ + language: langName, + code: token.content, + line: token.map ? token.map[0] : 0, + lines: token.content.split('\n').length, + }); + } + + // Use default renderer for code blocks + return defaultFenceRenderer(tokens, idx, options, env, self); + }; + + // Add heading metadata extraction + const defaultHeadingOpenRenderer = + this.md.renderer.rules.heading_open || + function (tokens, idx, options, _env, self) { + return self.renderToken(tokens, idx, options); + }; + + this.md.renderer.rules.heading_open = (tokens, idx, options, env, self) => { + const token = tokens[idx]; + const level = parseInt(token.tag.substr(1)); + const nextToken = tokens[idx + 1]; + const text = nextToken && nextToken.type === 'inline' ? nextToken.content : ''; + + // Generate ID + const id = text + .toLowerCase() + .trim() + .replace(/[\s\W-]+/g, '-') + .replace(/^-+|-+$/g, ''); + + this.metadata.headings.push({ + level, + text, + id, + line: token.map ? token.map[0] : 0, + }); + + return defaultHeadingOpenRenderer(tokens, idx, options, env, self); + }; + + // Add image metadata extraction + const defaultImageRenderer = + this.md.renderer.rules.image || + function (tokens, idx, options, _env, self) { + return self.renderToken(tokens, idx, options); + }; + + this.md.renderer.rules.image = (tokens, idx, options, env, self) => { + const token = tokens[idx]; + const srcIndex = token.attrIndex('src'); + const attrs = token.attrs || []; + const src = srcIndex >= 0 ? attrs[srcIndex][1] : ''; + const alt = token.content; + const titleIndex = token.attrIndex('title'); + const title = titleIndex >= 0 ? attrs[titleIndex][1] : undefined; + + this.metadata.images.push({ + src, + alt, + title, + line: token.map ? token.map[0] : 0, + }); + + return defaultImageRenderer(tokens, idx, options, env, self); + }; + + // Add link metadata extraction + const defaultLinkOpenRenderer = + this.md.renderer.rules.link_open || + function (tokens, idx, options, _env, self) { + return self.renderToken(tokens, idx, options); + }; + + this.md.renderer.rules.link_open = (tokens, idx, options, env, self) => { + const token = tokens[idx]; + const hrefIndex = token.attrIndex('href'); + const attrs = token.attrs || []; + const href = hrefIndex >= 0 ? attrs[hrefIndex][1] : ''; + const nextToken = tokens[idx + 1]; + const text = nextToken && nextToken.type === 'inline' ? nextToken.content : ''; + + this.metadata.links.push({ + href, + text, + line: token.map ? token.map[0] : 0, + }); + + return defaultLinkOpenRenderer(tokens, idx, options, env, self); + }; + } + + /** + * Convert markdown string to HTML + */ + convert(markdown: string, _options?: ConvertOptions): ConversionResult { + // Reset metadata + this.metadata = { + wordCount: 0, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }; + + const errors: ParseError[] = []; + + try { + // Calculate word count + this.metadata.wordCount = markdown.trim().split(/\s+/).length; + + // Parse and render + const html = this.md.render(markdown); + + return { + html, + metadata: this.metadata, + errors, + }; + } catch (error) { + const parseError: ParseError = { + message: error instanceof Error ? error.message : String(error), + line: 0, + column: 0, + severity: 'error', + }; + errors.push(parseError); + + return { + html: `
+

Markdown Parse Error

+

${this.escapeHtml(parseError.message)}

+
`, + metadata: this.metadata, + errors, + }; + } + } + + /** + * Validate markdown syntax + */ + validateSyntax(markdown: string): ValidationResult { + const errors: ParseError[] = []; + const warnings: ParseError[] = []; + + try { + // Try to parse + this.md.parse(markdown, {}); + return { valid: true, errors, warnings }; + } catch (error) { + errors.push({ + message: error instanceof Error ? error.message : String(error), + line: 0, + column: 0, + severity: 'error', + }); + return { valid: false, errors, warnings }; + } + } + + /** + * Register a custom plugin + */ + registerPlugin( + plugin: (md: MarkdownIt, options?: Record) => void, + options?: Record + ): void { + this.md.use(plugin, options); + } + + /** + * Escape HTML for safe rendering + */ + private escapeHtml(text: string): string { + const map: { [key: string]: string } = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return text.replace(/[&<>"']/g, (m) => map[m]); + } + + /** + * Update converter options (e.g., to enable/disable HTML) + */ + updateOptions(options: Partial): void { + if (options.enableHtml !== undefined) { + this.md.set({ html: options.enableHtml }); + } + if (options.breaks !== undefined) { + this.md.set({ breaks: options.breaks }); + } + if (options.linkify !== undefined) { + this.md.set({ linkify: options.linkify }); + } + if (options.typographer !== undefined) { + this.md.set({ typographer: options.typographer }); + } + } + + /** + * Get markdown-it instance for advanced usage + */ + getInstance(): MarkdownIt { + return this.md; + } +} + +// Export singleton instance +export const markdownConverter = new MarkdownConverter(); diff --git a/src/core/cache-manager.ts b/src/core/cache-manager.ts index a4e1cec..cb0d68b 100644 --- a/src/core/cache-manager.ts +++ b/src/core/cache-manager.ts @@ -1,234 +1,3 @@ -/** - * Cache Manager - * In-memory cache with SHA-256 hash-based keys for rendered markdown content - */ - -import type { CachedResult, CacheEntry, ThemeName } from '../types'; -import { debug } from '../utils/debug-logger'; - -export interface CacheOptions { - maxSize?: number; // Max cache entries (default: 50) - maxAge?: number; // Max age in ms (default: 1 hour) -} - -export class CacheManager { - private cache: Map = new Map(); - private fileHashes: Map = new Map(); - private maxSize: number; - private maxAge: number; - - constructor(options: CacheOptions = {}) { - this.maxSize = options.maxSize || 50; - this.maxAge = options.maxAge || 3600000; // 1 hour - debug.log('CacheManager', 'Initialized with max size:', this.maxSize); - } - - /** - * Generate cache key from file path, content, theme, and preferences - */ - async generateKey( - filePath: string, - content: string, - theme: ThemeName, - preferences: Record - ): Promise { - // Create stable string from inputs - const input = JSON.stringify({ - path: filePath, - content, - theme, - prefs: preferences, - }); - - // Generate SHA-256 hash - const encoder = new TextEncoder(); - const data = encoder.encode(input); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - - return hashHex; - } - - /** - * Generate content hash for file monitoring - */ - async generateContentHash(content: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(content); - const hashBuffer = await crypto.subtle.digest('SHA-256', data); - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - - return hashHex; - } - - /** - * Get cached result by key - */ - get(key: string): CachedResult | null { - const entry = this.cache.get(key); - - if (!entry) { - debug.info('CacheManager', 'Cache miss for key:', key.substring(0, 8)); - return null; - } - - // Check if expired - const age = Date.now() - entry.result.timestamp; - if (age > this.maxAge) { - debug.info('CacheManager', 'Cache expired for key:', key.substring(0, 8)); - this.cache.delete(key); - return null; - } - - // Update last accessed time - entry.lastAccessed = Date.now(); - debug.info('CacheManager', 'Cache hit for key:', key.substring(0, 8)); - - return entry.result; - } - - /** - * Set cached result - */ - set( - key: string, - result: CachedResult, - filePath: string, - contentHash: string, - theme: ThemeName - ): void { - // Evict old entries if cache is full - if (this.cache.size >= this.maxSize) { - this.evictOldest(); - } - - const entry: CacheEntry = { - result, - filePath, - contentHash, - theme, - lastAccessed: Date.now(), - }; - - this.cache.set(key, entry); - this.fileHashes.set(filePath, contentHash); - - debug.info( - 'CacheManager', - `Cached result for key: ${key.substring(0, 8)}, size: ${this.cache.size}/${this.maxSize}` - ); - } - - /** - * Invalidate cache entry by key - */ - invalidate(key: string): void { - const entry = this.cache.get(key); - if (entry) { - this.cache.delete(key); - // Remove file hash if this was the last entry for this file - const hasOtherEntries = Array.from(this.cache.values()).some( - (e) => e.filePath === entry.filePath - ); - if (!hasOtherEntries) { - this.fileHashes.delete(entry.filePath); - } - debug.info('CacheManager', 'Invalidated cache key:', key.substring(0, 8)); - } - } - - /** - * Invalidate all cache entries for a file path - */ - invalidateByPath(filePath: string): void { - let invalidated = 0; - for (const [key, entry] of this.cache.entries()) { - if (entry.filePath === filePath) { - this.cache.delete(key); - invalidated++; - } - } - this.fileHashes.delete(filePath); - debug.info('CacheManager', `Invalidated ${invalidated} entries for path:`, filePath); - } - - /** - * Check if file content has changed - */ - async hasFileChanged(filePath: string, currentContent: string): Promise { - const storedHash = this.fileHashes.get(filePath); - if (!storedHash) { - return true; // No hash stored, consider it changed - } - - const currentHash = await this.generateContentHash(currentContent); - return currentHash !== storedHash; - } - - /** - * Get file hash - */ - getFileHash(filePath: string): string | undefined { - return this.fileHashes.get(filePath); - } - - /** - * Clear all cache entries - */ - clear(): void { - const size = this.cache.size; - this.cache.clear(); - this.fileHashes.clear(); - debug.info('CacheManager', `Cleared ${size} cache entries`); - } - - /** - * Get cache statistics - */ - getStats(): { - size: number; - maxSize: number; - hitRate: number; - oldestEntry: number | null; - } { - let oldestTimestamp: number | null = null; - - for (const entry of this.cache.values()) { - if (oldestTimestamp === null || entry.result.timestamp < oldestTimestamp) { - oldestTimestamp = entry.result.timestamp; - } - } - - return { - size: this.cache.size, - maxSize: this.maxSize, - hitRate: 0, // Would need to track hits/misses for real hit rate - oldestEntry: oldestTimestamp, - }; - } - - /** - * Evict least recently accessed entry - */ - private evictOldest(): void { - let oldestKey: string | null = null; - let oldestTime = Infinity; - - for (const [key, entry] of this.cache.entries()) { - if (entry.lastAccessed < oldestTime) { - oldestTime = entry.lastAccessed; - oldestKey = key; - } - } - - if (oldestKey) { - this.invalidate(oldestKey); - debug.info('CacheManager', 'Evicted oldest entry:', oldestKey.substring(0, 8)); - } - } -} - -// Export singleton -export const cacheManager = new CacheManager(); +// Re-export shim: cache-manager has moved to @mdview/core +// This shim will be removed in Phase 2.9 +export * from '../../packages/core/src/cache-manager'; diff --git a/src/core/frontmatter-extractor.ts b/src/core/frontmatter-extractor.ts index b9d8d49..d73a524 100644 --- a/src/core/frontmatter-extractor.ts +++ b/src/core/frontmatter-extractor.ts @@ -1,78 +1,3 @@ -/** - * Frontmatter Extractor - * Extracts YAML frontmatter from markdown and renders it as an HTML card - */ - -export interface FrontmatterResult { - cleanedMarkdown: string; - frontmatter: Record | null; -} - -const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; - -/** - * Extract YAML frontmatter from the beginning of a markdown string. - * Returns the cleaned markdown (frontmatter stripped) and parsed key-value pairs. - */ -export function extractFrontmatter(markdown: string): FrontmatterResult { - if (!markdown) { - return { cleanedMarkdown: '', frontmatter: null }; - } - - const match = FRONTMATTER_RE.exec(markdown); - if (!match) { - return { cleanedMarkdown: markdown, frontmatter: null }; - } - - const rawBlock = match[1]; - const frontmatter: Record = {}; - - for (const line of rawBlock.split(/\r?\n/)) { - const colonIdx = line.indexOf(':'); - if (colonIdx === -1) continue; - - const key = line.slice(0, colonIdx).trim(); - if (!key) continue; - - let value = line.slice(colonIdx + 1).trim(); - - // Strip surrounding quotes - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - frontmatter[key] = value; - } - - const cleanedMarkdown = markdown.slice(match[0].length); - - return { - cleanedMarkdown, - frontmatter: Object.keys(frontmatter).length > 0 ? frontmatter : null, - }; -} - -/** - * Render frontmatter data as a collapsible HTML details card. - */ -export function renderFrontmatterHtml(frontmatter: Record): string { - const rows = Object.entries(frontmatter) - .map( - ([key, value]) => - `${escapeHtml(key)}${escapeHtml(value)}` - ) - .join(''); - - return `
Frontmatter${rows}
`; -} - -function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} +// Re-export shim: frontmatter-extractor has moved to @mdview/core +// This shim will be removed in Phase 2.9 +export * from '../../packages/core/src/frontmatter-extractor'; diff --git a/src/core/markdown-converter.ts b/src/core/markdown-converter.ts index 789c970..f673b36 100644 --- a/src/core/markdown-converter.ts +++ b/src/core/markdown-converter.ts @@ -1,350 +1,3 @@ -/** - * Markdown Converter - * Parses markdown to HTML using markdown-it with CommonMark + GFM support - */ - -import MarkdownIt from 'markdown-it'; -import markdownItAttrs from 'markdown-it-attrs'; -import markdownItAnchor from 'markdown-it-anchor'; -import markdownItTaskLists from 'markdown-it-task-lists'; -import * as emojiPlugin from 'markdown-it-emoji'; -import type { ConversionResult, ParseError, ValidationResult } from '../types'; - -export interface ConvertOptions { - baseUrl?: string; - breaks?: boolean; - linkify?: boolean; - typographer?: boolean; - enableHtml?: boolean; - highlight?: (code: string, lang: string) => string; -} - -export class MarkdownConverter { - private md: MarkdownIt; - private metadata: ConversionResult['metadata'] = { - wordCount: 0, - headings: [], - codeBlocks: [], - mermaidBlocks: [], - images: [], - links: [], - frontmatter: null, - }; - - constructor(options?: ConvertOptions) { - // Initialize markdown-it - this.md = new MarkdownIt({ - html: options?.enableHtml ?? false, // Security: no raw HTML tags by default - breaks: options?.breaks ?? true, // GFM line breaks - linkify: options?.linkify ?? true, // Auto-convert URLs to links - typographer: options?.typographer ?? true, // Smart quotes and replacements - highlight: options?.highlight, - }); - - // Configure plugins - this.configurePlugins(); - } - - private configurePlugins(): void { - // markdown-it-attrs: Add custom attributes to elements - this.md.use(markdownItAttrs, { - leftDelimiter: '{', - rightDelimiter: '}', - allowedAttributes: ['id', 'class', 'style'], - }); - - // markdown-it-anchor: Add anchors to headings - this.md.use(markdownItAnchor, { - permalink: markdownItAnchor.permalink.headerLink({ - safariReaderFix: true, - }), - slugify: (s: string) => - s - .toLowerCase() - .trim() - .replace(/[\s\W-]+/g, '-') - .replace(/^-+|-+$/g, ''), - }); - - // markdown-it-task-lists: GitHub-style task lists - this.md.use(markdownItTaskLists, { - enabled: true, - label: true, - labelAfter: true, - }); - - // markdown-it-emoji: Emoji support - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unnecessary-type-assertion - const emojiPluginToUse = ((emojiPlugin as any).full || emojiPlugin) as any; - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.md.use(emojiPluginToUse); - - // markdown-it-footnote: Footnotes support - // TODO: markdown-it-footnote has ESM export issues, will be fixed in future version - // this.md.use(markdownItFootnote); - - // Add custom rule for Mermaid blocks - this.addMermaidRule(); - } - - private addMermaidRule(): void { - const defaultFenceRenderer = - this.md.renderer.rules.fence || - function (tokens, idx, options, _env, self) { - return self.renderToken(tokens, idx, options); - }; - - this.md.renderer.rules.fence = (tokens, idx, options, env, self) => { - const token = tokens[idx]; - const info = token.info ? token.info.trim() : ''; - const langName = info.split(/\s+/g)[0]; - - // Check if it's a Mermaid diagram - if (langName === 'mermaid' || langName === 'mmd') { - const code = token.content.trim(); - const id = `mermaid-${Math.random().toString(36).substr(2, 9)}`; - - // Store in metadata with ID mapping - this.metadata.mermaidBlocks.push({ - code, - line: token.map ? token.map[0] : 0, - }); - - // Store code in global registry (bypasses DOM insertion issues) - if (!window.__MDVIEW_MERMAID_CODE__) { - window.__MDVIEW_MERMAID_CODE__ = new Map(); - } - window.__MDVIEW_MERMAID_CODE__.set(id, code); - - // Escape the code for safe HTML attribute storage - // This allows the code to survive caching and be retrieved later - const escapedCode = code - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(//g, '>'); - - // Return container with code stored in data attribute for cache persistence - return `
-
Rendering diagram...
-
\n`; - } - - // Check if it's a code block for syntax highlighting - if (langName) { - this.metadata.codeBlocks.push({ - language: langName, - code: token.content, - line: token.map ? token.map[0] : 0, - lines: token.content.split('\n').length, - }); - } - - // Use default renderer for code blocks - return defaultFenceRenderer(tokens, idx, options, env, self); - }; - - // Add heading metadata extraction - const defaultHeadingOpenRenderer = - this.md.renderer.rules.heading_open || - function (tokens, idx, options, _env, self) { - return self.renderToken(tokens, idx, options); - }; - - this.md.renderer.rules.heading_open = (tokens, idx, options, env, self) => { - const token = tokens[idx]; - const level = parseInt(token.tag.substr(1)); - const nextToken = tokens[idx + 1]; - const text = nextToken && nextToken.type === 'inline' ? nextToken.content : ''; - - // Generate ID - const id = text - .toLowerCase() - .trim() - .replace(/[\s\W-]+/g, '-') - .replace(/^-+|-+$/g, ''); - - this.metadata.headings.push({ - level, - text, - id, - line: token.map ? token.map[0] : 0, - }); - - return defaultHeadingOpenRenderer(tokens, idx, options, env, self); - }; - - // Add image metadata extraction - const defaultImageRenderer = - this.md.renderer.rules.image || - function (tokens, idx, options, _env, self) { - return self.renderToken(tokens, idx, options); - }; - - this.md.renderer.rules.image = (tokens, idx, options, env, self) => { - const token = tokens[idx]; - const srcIndex = token.attrIndex('src'); - const attrs = token.attrs || []; - const src = srcIndex >= 0 ? attrs[srcIndex][1] : ''; - const alt = token.content; - const titleIndex = token.attrIndex('title'); - const title = titleIndex >= 0 ? attrs[titleIndex][1] : undefined; - - this.metadata.images.push({ - src, - alt, - title, - line: token.map ? token.map[0] : 0, - }); - - return defaultImageRenderer(tokens, idx, options, env, self); - }; - - // Add link metadata extraction - const defaultLinkOpenRenderer = - this.md.renderer.rules.link_open || - function (tokens, idx, options, _env, self) { - return self.renderToken(tokens, idx, options); - }; - - this.md.renderer.rules.link_open = (tokens, idx, options, env, self) => { - const token = tokens[idx]; - const hrefIndex = token.attrIndex('href'); - const attrs = token.attrs || []; - const href = hrefIndex >= 0 ? attrs[hrefIndex][1] : ''; - const nextToken = tokens[idx + 1]; - const text = nextToken && nextToken.type === 'inline' ? nextToken.content : ''; - - this.metadata.links.push({ - href, - text, - line: token.map ? token.map[0] : 0, - }); - - return defaultLinkOpenRenderer(tokens, idx, options, env, self); - }; - } - - /** - * Convert markdown string to HTML - */ - convert(markdown: string, _options?: ConvertOptions): ConversionResult { - // Reset metadata - this.metadata = { - wordCount: 0, - headings: [], - codeBlocks: [], - mermaidBlocks: [], - images: [], - links: [], - frontmatter: null, - }; - - const errors: ParseError[] = []; - - try { - // Calculate word count - this.metadata.wordCount = markdown.trim().split(/\s+/).length; - - // Parse and render - const html = this.md.render(markdown); - - return { - html, - metadata: this.metadata, - errors, - }; - } catch (error) { - const parseError: ParseError = { - message: error instanceof Error ? error.message : String(error), - line: 0, - column: 0, - severity: 'error', - }; - errors.push(parseError); - - return { - html: `
-

Markdown Parse Error

-

${this.escapeHtml(parseError.message)}

-
`, - metadata: this.metadata, - errors, - }; - } - } - - /** - * Validate markdown syntax - */ - validateSyntax(markdown: string): ValidationResult { - const errors: ParseError[] = []; - const warnings: ParseError[] = []; - - try { - // Try to parse - this.md.parse(markdown, {}); - return { valid: true, errors, warnings }; - } catch (error) { - errors.push({ - message: error instanceof Error ? error.message : String(error), - line: 0, - column: 0, - severity: 'error', - }); - return { valid: false, errors, warnings }; - } - } - - /** - * Register a custom plugin - */ - registerPlugin( - plugin: (md: MarkdownIt, options?: Record) => void, - options?: Record - ): void { - this.md.use(plugin, options); - } - - /** - * Escape HTML for safe rendering - */ - private escapeHtml(text: string): string { - const map: { [key: string]: string } = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - }; - return text.replace(/[&<>"']/g, (m) => map[m]); - } - - /** - * Update converter options (e.g., to enable/disable HTML) - */ - updateOptions(options: Partial): void { - if (options.enableHtml !== undefined) { - this.md.set({ html: options.enableHtml }); - } - if (options.breaks !== undefined) { - this.md.set({ breaks: options.breaks }); - } - if (options.linkify !== undefined) { - this.md.set({ linkify: options.linkify }); - } - if (options.typographer !== undefined) { - this.md.set({ typographer: options.typographer }); - } - } - - /** - * Get markdown-it instance for advanced usage - */ - getInstance(): MarkdownIt { - return this.md; - } -} - -// Export singleton instance -export const markdownConverter = new MarkdownConverter(); +// Re-export shim: markdown-converter has moved to @mdview/core +// This shim will be removed in Phase 2.9 +export * from '../../packages/core/src/markdown-converter'; From f2db9aba151400b3c649035a79ca9397d626373e Mon Sep 17 00:00:00 2001 From: James Ainslie <42301770+jamesainslie@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:24:34 -0400 Subject: [PATCH 08/26] refactor(core): move comment parsers and serializers to @mdview/core --- .../comments/annotation-parser.test.ts | 245 +++++++++ .../comments/annotation-serializer.test.ts | 197 +++++++ .../comments/comment-context.test.ts | 103 ++++ .../__tests__/comments/comment-parser.test.ts | 107 ++++ .../comments/comment-serializer.test.ts | 106 ++++ .../src/__tests__/comments/emoji-data.test.ts | 61 +++ .../comments/source-position-map.test.ts | 107 ++++ .../core/src/comments/annotation-parser.ts | 187 +++++++ .../src/comments/annotation-serializer.ts | 497 +++++++++++++++++ packages/core/src/comments/comment-context.ts | 107 ++++ packages/core/src/comments/comment-parser.ts | 164 ++++++ .../core/src/comments/comment-serializer.ts | 509 +++++++++++++++++ packages/core/src/comments/emoji-data.ts | 277 ++++++++++ .../core/src/comments/source-position-map.ts | 460 ++++++++++++++++ src/comments/annotation-parser.ts | 188 +------ src/comments/annotation-serializer.ts | 498 +---------------- src/comments/comment-context.ts | 108 +--- src/comments/comment-parser.ts | 165 +----- src/comments/comment-serializer.ts | 510 +----------------- src/comments/emoji-data.ts | 278 +--------- src/comments/source-position-map.ts | 461 +--------------- 21 files changed, 3134 insertions(+), 2201 deletions(-) create mode 100644 packages/core/src/__tests__/comments/annotation-parser.test.ts create mode 100644 packages/core/src/__tests__/comments/annotation-serializer.test.ts create mode 100644 packages/core/src/__tests__/comments/comment-context.test.ts create mode 100644 packages/core/src/__tests__/comments/comment-parser.test.ts create mode 100644 packages/core/src/__tests__/comments/comment-serializer.test.ts create mode 100644 packages/core/src/__tests__/comments/emoji-data.test.ts create mode 100644 packages/core/src/__tests__/comments/source-position-map.test.ts create mode 100644 packages/core/src/comments/annotation-parser.ts create mode 100644 packages/core/src/comments/annotation-serializer.ts create mode 100644 packages/core/src/comments/comment-context.ts create mode 100644 packages/core/src/comments/comment-parser.ts create mode 100644 packages/core/src/comments/comment-serializer.ts create mode 100644 packages/core/src/comments/emoji-data.ts create mode 100644 packages/core/src/comments/source-position-map.ts diff --git a/packages/core/src/__tests__/comments/annotation-parser.test.ts b/packages/core/src/__tests__/comments/annotation-parser.test.ts new file mode 100644 index 0000000..38552d4 --- /dev/null +++ b/packages/core/src/__tests__/comments/annotation-parser.test.ts @@ -0,0 +1,245 @@ +/** + * Tests for annotation parser — v2 format with v1 backward compatibility + */ + +import { describe, it, expect, vi } from 'vitest'; +import { parseAnnotations, detectFormat } from '../../comments/annotation-parser'; + +describe('detectFormat', () => { + it('should detect v1 format from sentinel', () => { + const md = `# Hello\n\n\n[^comment-1]: \n body`; + expect(detectFormat(md)).toBe('v1'); + }); + + it('should detect v2 format from sentinel', () => { + const md = `# Hello\n\n`; + expect(detectFormat(md)).toBe('v2'); + }); + + it('should return none when neither sentinel is present', () => { + expect(detectFormat('# Just a document\n\nSome text.')).toBe('none'); + }); + + it('should return v1 when both sentinels present (v1 takes priority)', () => { + const md = `# Hello\n\n\n`; + expect(detectFormat(md)).toBe('v1'); + }); + + it('should handle empty string', () => { + expect(detectFormat('')).toBe('none'); + }); +}); + +describe('parseAnnotations', () => { + describe('No annotations', () => { + it('should return unchanged markdown and empty array when no annotations exist', () => { + const markdown = `# Hello World\n\nThis is a paragraph.\n\n## Section Two\n\nMore content here.`; + const result = parseAnnotations(markdown); + expect(result.cleanedMarkdown).toBe(markdown); + expect(result.comments).toEqual([]); + }); + + it('should handle empty markdown', () => { + const result = parseAnnotations(''); + expect(result.cleanedMarkdown).toBe(''); + expect(result.comments).toEqual([]); + }); + }); + + describe('v2 single annotation', () => { + it('should parse a single annotation with all fields', () => { + const markdown = `Some highlighted[@1] text in context. + +`; + + const result = parseAnnotations(markdown); + + expect(result.comments).toHaveLength(1); + const comment = result.comments[0]; + expect(comment.id).toBe('comment-1'); + expect(comment.selectedText).toBe('highlighted'); + expect(comment.anchorPrefix).toBe('Some '); + expect(comment.anchorSuffix).toBe(' text'); + expect(comment.body).toBe('This needs attention'); + expect(comment.author).toBe('reviewer'); + expect(comment.date).toBe('2026-03-03T14:30:00Z'); + expect(comment.resolved).toBe(false); + }); + + it('should remove [@N] markers from cleaned markdown', () => { + const markdown = `Some highlighted[@1] text in context. + +`; + + const result = parseAnnotations(markdown); + expect(result.cleanedMarkdown).toBe('Some highlighted text in context.'); + expect(result.cleanedMarkdown).not.toContain('[@1]'); + expect(result.cleanedMarkdown).not.toContain('mdview:annotations'); + }); + }); + + describe('v2 multiple annotations', () => { + it('should parse multiple annotations', () => { + const markdown = `First[@1] and second[@2] words. + +`; + + const result = parseAnnotations(markdown); + expect(result.comments).toHaveLength(2); + expect(result.comments[0].id).toBe('comment-1'); + expect(result.comments[0].author).toBe('alice'); + expect(result.comments[0].body).toBe('First comment'); + expect(result.comments[1].id).toBe('comment-2'); + expect(result.comments[1].author).toBe('bob'); + expect(result.comments[1].body).toBe('Second comment'); + }); + + it('should remove all [@N] markers from cleaned markdown', () => { + const markdown = `First[@1] and second[@2] words. + +`; + + const result = parseAnnotations(markdown); + expect(result.cleanedMarkdown).toBe('First and second words.'); + }); + }); + + describe('v2 resolved annotations', () => { + it('should parse resolved flag', () => { + const markdown = `Some text[@1] here. + +`; + + const result = parseAnnotations(markdown); + expect(result.comments[0].resolved).toBe(true); + }); + + it('should default resolved to false when not specified', () => { + const markdown = `Some text[@1] here. + +`; + + const result = parseAnnotations(markdown); + expect(result.comments[0].resolved).toBe(false); + }); + }); + + describe('v2 edge cases', () => { + it('should handle malformed JSON gracefully (warn, return empty)', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const markdown = `Text[@1] here. + +`; + + const result = parseAnnotations(markdown); + expect(result.comments).toEqual([]); + expect(result.cleanedMarkdown).toBe('Text here.'); + consoleSpy.mockRestore(); + }); + + it('should handle empty annotations array', () => { + const markdown = `Some text here. + +`; + + const result = parseAnnotations(markdown); + expect(result.comments).toEqual([]); + expect(result.cleanedMarkdown).toBe('Some text here.'); + }); + }); + + describe('v1 backward compatibility', () => { + it('should parse v1 format comments through delegation', () => { + const markdown = `Some highlighted text[^comment-1] in context. + + +[^comment-1]: + This API endpoint needs error handling + for the 404 case.`; + + const result = parseAnnotations(markdown); + + expect(result.comments).toHaveLength(1); + expect(result.comments[0].id).toBe('comment-1'); + expect(result.comments[0].selectedText).toBe('text'); + expect(result.comments[0].body).toBe('This API endpoint needs error handling\nfor the 404 case.'); + expect(result.comments[0].author).toBe('reviewer'); + expect(result.comments[0].date).toBe('2026-03-03T14:30:00Z'); + expect(result.comments[0].resolved).toBe(false); + }); + + it('should remove v1 comment references from cleaned markdown', () => { + const markdown = `Some highlighted text[^comment-1] in context. + + +[^comment-1]: + This is a comment.`; + + const result = parseAnnotations(markdown); + expect(result.cleanedMarkdown).toBe('Some highlighted text in context.'); + }); + }); +}); diff --git a/packages/core/src/__tests__/comments/annotation-serializer.test.ts b/packages/core/src/__tests__/comments/annotation-serializer.test.ts new file mode 100644 index 0000000..04701d0 --- /dev/null +++ b/packages/core/src/__tests__/comments/annotation-serializer.test.ts @@ -0,0 +1,197 @@ +/** + * Tests for Annotation Serializer (v2 format) + * + * Verifies v2 annotation operations: generating IDs, adding, removing, + * updating, resolving comments, replies, reactions, and v1 migration. + */ + +import { describe, it, expect } from 'vitest'; +import { + generateNextCommentId, + addComment, + addCommentAtOffset, + removeComment, + updateComment, + resolveComment, + updateCommentMetadata, + addReply, + toggleReaction, +} from '../../comments/annotation-serializer'; +import { parseAnnotations } from '../../comments/annotation-parser'; +import { buildSourceMap } from '../../comments/source-position-map'; +import type { Comment, CommentContext, CommentReply } from '../../types/index'; + +function makeComment(overrides: Partial = {}): Comment { + return { + id: 'comment-1', + selectedText: 'highlighted text', + body: 'This is a comment', + author: 'reviewer', + date: '2026-03-03T14:30:00Z', + resolved: false, + ...overrides, + }; +} + +describe('generateNextCommentId', () => { + it('should return "comment-1" for empty markdown', () => { + expect(generateNextCommentId('')).toBe('comment-1'); + }); + + it('should return "comment-2" when annotation 1 exists', () => { + const md = `Text[@1] here. + +`; + expect(generateNextCommentId(md)).toBe('comment-2'); + }); +}); + +describe('addComment', () => { + it('should add a [@N] marker and annotation block', () => { + const md = 'Some highlighted text in context.\n'; + const comment = makeComment(); + const result = addComment(md, comment); + + expect(result).toContain('Some highlighted text[@1] in context.'); + expect(result).toContain('`; + const result = removeComment(md, 'comment-1'); + + expect(result).not.toContain('[@1]'); + expect(result).not.toContain('This is a comment'); + expect(result).toContain('Some highlighted text in context.'); + }); +}); + +describe('updateComment', () => { + it('should replace the body while keeping other fields unchanged', () => { + const md = `Some highlighted text[@1] in context. + +`; + const result = updateComment(md, 'comment-1', 'Updated body text'); + + expect(result).toContain('"body": "Updated body text"'); + expect(result).not.toContain('Original body text'); + }); +}); + +describe('resolveComment', () => { + it('should set resolved:true in annotation JSON', () => { + const md = `Some highlighted text[@1] in context. + +`; + const result = resolveComment(md, 'comment-1'); + + expect(result).toContain('"resolved": true'); + }); +}); + +describe('addCommentAtOffset', () => { + it('should insert marker after bold text using source map', () => { + const md = 'This is **important** for the review.\n'; + const sourceMap = buildSourceMap(md); + const comment = makeComment({ selectedText: 'important' }); + const result = addCommentAtOffset(md, comment, sourceMap); + + expect(result).toContain('**important**[@1]'); + expect(result).toContain('`; + + it('should add a reply to annotation thread', () => { + const reply: Omit = { + author: 'bob', + body: 'Good catch', + date: '2026-03-04T10:00:00Z', + }; + const { markdown, replyId } = addReply(v2Md, 'comment-1', reply); + + expect(replyId).toBe('reply-1'); + expect(markdown).toContain('"thread"'); + expect(markdown).toContain('"author": "bob"'); + }); +}); + +describe('toggleReaction', () => { + const v2Md = `Some highlighted text[@1] in context. + +`; + + it('should add a reaction when none exist', () => { + const result = toggleReaction(v2Md, 'comment-1', '\u{1F44D}', 'bob'); + + expect(result).toContain('"reactions"'); + const parsed = parseAnnotations(result); + expect(parsed.comments[0].reactions).toBeDefined(); + expect(parsed.comments[0].reactions!['\u{1F44D}']).toContain('bob'); + }); +}); + +describe('round-trip: serialize then parse', () => { + it('should round-trip a basic comment', () => { + const md = 'Some highlighted text in context.\n'; + const comment = makeComment(); + const serialized = addComment(md, comment); + const parsed = parseAnnotations(serialized); + + expect(parsed.comments).toHaveLength(1); + expect(parsed.comments[0].id).toBe('comment-1'); + expect(parsed.comments[0].selectedText).toBe('highlighted text'); + expect(parsed.comments[0].body).toBe('This is a comment'); + }); +}); diff --git a/packages/core/src/__tests__/comments/comment-context.test.ts b/packages/core/src/__tests__/comments/comment-context.test.ts new file mode 100644 index 0000000..1882eee --- /dev/null +++ b/packages/core/src/__tests__/comments/comment-context.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for Comment Context + * + * Verifies that computeCommentContext correctly derives positional context + * (line number, section heading, breadcrumb) from a character offset in + * raw markdown content. + */ + +import { describe, it, expect } from 'vitest'; +import { computeCommentContext } from '../../comments/comment-context'; + +describe('computeCommentContext', () => { + it('should return line number and section for offset in a section', () => { + const md = [ + '# Title', + '', + '## Installation', + '', + 'Run npm install to get started.', + ].join('\n'); + + const offset = md.indexOf('npm'); + const ctx = computeCommentContext(md, offset); + + expect(ctx.line).toBe(5); + expect(ctx.section).toBe('Installation'); + expect(ctx.sectionLevel).toBe(2); + expect(ctx.breadcrumb).toEqual(['Title', 'Installation']); + }); + + it('should handle offset before any heading (preamble)', () => { + const md = [ + 'Some preamble text here.', + '', + '# First Heading', + '', + 'Content under heading.', + ].join('\n'); + + const offset = md.indexOf('preamble'); + const ctx = computeCommentContext(md, offset); + + expect(ctx.line).toBe(1); + expect(ctx.section).toBeUndefined(); + expect(ctx.sectionLevel).toBeUndefined(); + expect(ctx.breadcrumb).toEqual([]); + }); + + it('should build nested breadcrumb (h1 > h2 > h3)', () => { + const md = [ + '# Getting Started', + '', + '## Installation', + '', + '### Prerequisites', + '', + 'You need Node.js 18+.', + ].join('\n'); + + const offset = md.indexOf('Node.js'); + const ctx = computeCommentContext(md, offset); + + expect(ctx.line).toBe(7); + expect(ctx.section).toBe('Prerequisites'); + expect(ctx.sectionLevel).toBe(3); + expect(ctx.breadcrumb).toEqual([ + 'Getting Started', + 'Installation', + 'Prerequisites', + ]); + }); + + it('should handle empty string', () => { + const ctx = computeCommentContext('', 0); + + expect(ctx.line).toBe(1); + expect(ctx.breadcrumb).toEqual([]); + }); + + it('should reset breadcrumb correctly for peer sections', () => { + const md = [ + '# Guide', + '', + '## Alpha', + '', + '### Alpha Sub', + '', + 'Alpha sub content.', + '', + '## Beta', + '', + 'Beta content here.', + ].join('\n'); + + const offset = md.indexOf('Beta content'); + const ctx = computeCommentContext(md, offset); + + expect(ctx.line).toBe(11); + expect(ctx.section).toBe('Beta'); + expect(ctx.sectionLevel).toBe(2); + expect(ctx.breadcrumb).toEqual(['Guide', 'Beta']); + }); +}); diff --git a/packages/core/src/__tests__/comments/comment-parser.test.ts b/packages/core/src/__tests__/comments/comment-parser.test.ts new file mode 100644 index 0000000..67fbc6a --- /dev/null +++ b/packages/core/src/__tests__/comments/comment-parser.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for comment parser - extracts structured comments from markdown footnotes + */ + +import { describe, it, expect } from 'vitest'; +import { parseComments } from '../../comments/comment-parser'; + +describe('parseComments', () => { + describe('No comments', () => { + it('should return unchanged markdown and empty array when no comments exist', () => { + const markdown = `# Hello World + +This is a paragraph. + +## Section Two + +More content here.`; + + const result = parseComments(markdown); + + expect(result.cleanedMarkdown).toBe(markdown); + expect(result.comments).toEqual([]); + }); + + it('should handle empty markdown', () => { + const result = parseComments(''); + + expect(result.cleanedMarkdown).toBe(''); + expect(result.comments).toEqual([]); + }); + }); + + describe('Single comment extraction', () => { + it('should extract a single comment with all fields', () => { + const markdown = `Some highlighted text[^comment-1] in context. + + +[^comment-1]: + This API endpoint needs error handling + for the 404 case.`; + + const result = parseComments(markdown); + + expect(result.comments).toHaveLength(1); + const comment = result.comments[0]; + expect(comment.id).toBe('comment-1'); + expect(comment.selectedText).toBe('text'); + expect(comment.body).toBe('This API endpoint needs error handling\nfor the 404 case.'); + expect(comment.author).toBe('reviewer'); + expect(comment.date).toBe('2026-03-03T14:30:00Z'); + expect(comment.resolved).toBe(false); + }); + + it('should remove comment reference from cleaned markdown', () => { + const markdown = `Some highlighted text[^comment-1] in context. + + +[^comment-1]: + This is a comment.`; + + const result = parseComments(markdown); + + expect(result.cleanedMarkdown).toBe('Some highlighted text in context.'); + expect(result.cleanedMarkdown).not.toContain('[^comment-1]'); + expect(result.cleanedMarkdown).not.toContain('mdview:comment'); + }); + }); + + describe('Multiple comments', () => { + it('should extract multiple comments', () => { + const markdown = `First text[^comment-1] and second text[^comment-2] in the doc. + + +[^comment-1]: + First comment body. +[^comment-2]: + Second comment body.`; + + const result = parseComments(markdown); + + expect(result.comments).toHaveLength(2); + expect(result.comments[0].id).toBe('comment-1'); + expect(result.comments[0].author).toBe('alice'); + expect(result.comments[0].body).toBe('First comment body.'); + expect(result.comments[1].id).toBe('comment-2'); + expect(result.comments[1].author).toBe('bob'); + expect(result.comments[1].body).toBe('Second comment body.'); + }); + }); + + describe('Malformed metadata', () => { + it('should skip comments with invalid JSON metadata', () => { + const markdown = `Text[^comment-1] and more text[^comment-2] here. + + +[^comment-1]: + This should be skipped. +[^comment-2]: + This should be parsed.`; + + const result = parseComments(markdown); + + expect(result.comments).toHaveLength(1); + expect(result.comments[0].id).toBe('comment-2'); + }); + }); +}); diff --git a/packages/core/src/__tests__/comments/comment-serializer.test.ts b/packages/core/src/__tests__/comments/comment-serializer.test.ts new file mode 100644 index 0000000..3226acc --- /dev/null +++ b/packages/core/src/__tests__/comments/comment-serializer.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for Comment Serializer (v1 format) + * + * Verifies markdown footnote-based comment operations: + * generating IDs, adding, removing, updating, and resolving comments. + */ + +import { describe, it, expect } from 'vitest'; +import { + generateNextCommentId, + addComment, + removeComment, + updateComment, + resolveComment, +} from '../../comments/comment-serializer'; +import type { Comment } from '../../types/index'; + +function makeComment(overrides: Partial = {}): Comment { + return { + id: 'comment-1', + selectedText: 'highlighted text', + body: 'This is a comment', + author: 'reviewer', + date: '2026-03-03T14:30:00Z', + resolved: false, + ...overrides, + }; +} + +describe('generateNextCommentId', () => { + it('should return "comment-1" for empty markdown', () => { + expect(generateNextCommentId('')).toBe('comment-1'); + }); + + it('should return "comment-2" when comment-1 exists', () => { + const md = [ + 'Some highlighted text[^comment-1] in context.', + '', + '', + '[^comment-1]: ', + ' This is a comment', + ].join('\n'); + expect(generateNextCommentId(md)).toBe('comment-2'); + }); +}); + +describe('addComment', () => { + it('should add a reference after selectedText and a footnote body at the end', () => { + const md = 'Some highlighted text in context.\n'; + const comment = makeComment(); + const result = addComment(md, comment); + + expect(result).toContain('Some highlighted text[^comment-1] in context.'); + expect(result).toContain(''); + expect(result).toContain('[^comment-1]: ', + '[^comment-1]: ', + ' This is a comment', + ].join('\n'); + const result = removeComment(md, 'comment-1'); + + expect(result).not.toContain('[^comment-1]'); + expect(result).not.toContain('This is a comment'); + expect(result).toContain('Some highlighted text in context.'); + }); +}); + +describe('updateComment', () => { + it('should replace the body text while keeping metadata unchanged', () => { + const md = [ + 'Some highlighted text[^comment-1] in context.', + '', + '', + '[^comment-1]: ', + ' Original body text', + ].join('\n'); + const result = updateComment(md, 'comment-1', 'Updated body text'); + + expect(result).toContain(' Updated body text'); + expect(result).not.toContain('Original body text'); + }); +}); + +describe('resolveComment', () => { + it('should add "resolved":true to metadata JSON', () => { + const md = [ + 'Some highlighted text[^comment-1] in context.', + '', + '', + '[^comment-1]: ', + ' This is a comment', + ].join('\n'); + const result = resolveComment(md, 'comment-1'); + + expect(result).toContain('"resolved":true'); + }); +}); diff --git a/packages/core/src/__tests__/comments/emoji-data.test.ts b/packages/core/src/__tests__/comments/emoji-data.test.ts new file mode 100644 index 0000000..86ee996 --- /dev/null +++ b/packages/core/src/__tests__/comments/emoji-data.test.ts @@ -0,0 +1,61 @@ +/** + * Tests for emoji data module + */ + +import { describe, it, expect } from 'vitest'; +import { QUICK_EMOJIS, EMOJI_CATEGORIES, searchEmojis } from '../../comments/emoji-data'; + +describe('QUICK_EMOJIS', () => { + it('should have exactly 12 entries', () => { + expect(QUICK_EMOJIS).toHaveLength(12); + }); + + it('should include common reaction emojis', () => { + const chars = QUICK_EMOJIS.map((e) => e.char); + expect(chars).toContain('\u{1F44D}'); // thumbs up + expect(chars).toContain('\u{1F44E}'); // thumbs down + expect(chars).toContain('\u{2764}\u{FE0F}'); // red heart + expect(chars).toContain('\u{1F680}'); // rocket + expect(chars).toContain('\u{2705}'); // check mark + expect(chars).toContain('\u{274C}'); // cross mark + }); +}); + +describe('EMOJI_CATEGORIES', () => { + it('should have exactly 8 categories', () => { + expect(EMOJI_CATEGORIES).toHaveLength(8); + }); + + it('should have name and emojis on each category', () => { + for (const cat of EMOJI_CATEGORIES) { + expect(cat.name).toBeTruthy(); + expect(cat.emojis.length).toBeGreaterThan(0); + } + }); +}); + +describe('searchEmojis', () => { + it('should find emojis by name', () => { + const results = searchEmojis('thumbs'); + expect(results.length).toBeGreaterThan(0); + const chars = results.map((e) => e.char); + expect(chars).toContain('\u{1F44D}'); + }); + + it('should find emojis by keyword', () => { + const results = searchEmojis('fire'); + expect(results.length).toBeGreaterThan(0); + const chars = results.map((e) => e.char); + expect(chars).toContain('\u{1F525}'); + }); + + it('should return empty array for no match', () => { + const results = searchEmojis('zzzznotanemoji'); + expect(results).toEqual([]); + }); + + it('should return empty array for empty query', () => { + const results = searchEmojis(''); + expect(results).toEqual([]); + }); +}); diff --git a/packages/core/src/__tests__/comments/source-position-map.test.ts b/packages/core/src/__tests__/comments/source-position-map.test.ts new file mode 100644 index 0000000..512a955 --- /dev/null +++ b/packages/core/src/__tests__/comments/source-position-map.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for Source Position Map + * + * Verifies the inline syntax stripping with offset tracking that enables + * accurate comment footnote reference insertion into formatted markdown. + */ + +import { describe, it, expect } from 'vitest'; +import { + buildSourceMap, + findInsertionPoint, +} from '../../comments/source-position-map'; + +describe('buildSourceMap', () => { + it('should produce identity mapping for plain text', () => { + const map = buildSourceMap('Hello world'); + expect(map.plainText).toBe('Hello world'); + expect(map.offsets).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(map.spans).toEqual([]); + }); + + it('should strip bold markers and track offsets', () => { + const map = buildSourceMap('**bold**'); + expect(map.plainText).toBe('bold'); + expect(map.offsets).toEqual([2, 3, 4, 5]); + expect(map.spans).toHaveLength(1); + expect(map.spans[0]).toMatchObject({ + sourceStart: 0, + sourceEnd: 8, + plainStart: 0, + plainEnd: 4, + type: 'bold', + }); + }); + + it('should strip link syntax keeping display text', () => { + const map = buildSourceMap('[click](https://example.com)'); + expect(map.plainText).toBe('click'); + expect(map.spans.some((s) => s.type === 'link')).toBe(true); + }); + + it('should stop at comment separator', () => { + const md = 'Content **here**\n\n\n[^comment-1]: stuff'; + const map = buildSourceMap(md); + expect(map.plainText).toBe('Content here\n\n'); + expect(map.rawSource).toBe('Content **here**\n\n'); + }); + + it('should handle empty string', () => { + const map = buildSourceMap(''); + expect(map.plainText).toBe(''); + expect(map.offsets).toEqual([]); + expect(map.spans).toEqual([]); + }); +}); + +describe('findInsertionPoint', () => { + it('should find insertion point for plain text', () => { + const map = buildSourceMap('Hello world'); + const point = findInsertionPoint(map, 'world'); + expect(point).toBe(11); + }); + + it('should find insertion point after bold text', () => { + const map = buildSourceMap('Hello **important** text'); + const point = findInsertionPoint(map, 'important'); + expect(point).toBe(19); + }); + + it('should find insertion point after link', () => { + const map = buildSourceMap('Click [here](https://example.com) for more'); + const point = findInsertionPoint(map, 'here'); + expect(point).toBe(33); + }); + + it('should return null for text not found', () => { + const map = buildSourceMap('Hello world'); + const point = findInsertionPoint(map, 'missing'); + expect(point).toBeNull(); + }); + + it('should return null for empty selectedText', () => { + const map = buildSourceMap('Hello world'); + const point = findInsertionPoint(map, ''); + expect(point).toBeNull(); + }); + + it('should disambiguate using prefix context', () => { + const map = buildSourceMap('The word test and another test here'); + const point = findInsertionPoint(map, 'test', { + prefix: 'another ', + suffix: ' here', + }); + expect(point).toBe(30); + }); +}); + +describe('source map insertion integration', () => { + it('should allow correct footnote insertion after bold text', () => { + const md = 'This is **important** for the review.'; + const map = buildSourceMap(md); + const point = findInsertionPoint(map, 'important'); + expect(point).not.toBeNull(); + const result = md.slice(0, point!) + '[^comment-1]' + md.slice(point!); + expect(result).toBe('This is **important**[^comment-1] for the review.'); + }); +}); diff --git a/packages/core/src/comments/annotation-parser.ts b/packages/core/src/comments/annotation-parser.ts new file mode 100644 index 0000000..7789d2b --- /dev/null +++ b/packages/core/src/comments/annotation-parser.ts @@ -0,0 +1,187 @@ +/** + * Annotation Parser (v2) — unified entry point for parsing both v1 and v2 + * comment formats from markdown. + * + * v2 uses `[@N]` inline markers and a structured JSON annotation block + * in `` at the end of the document. + * + * v1 uses `[^comment-N]` footnote references and `` + * separator with footnote definitions. + */ + +import type { Comment, CommentContext, CommentParseResult, CommentReply } from '../types/index'; +import { parseComments } from './comment-parser'; + +const V1_SENTINEL = ''; +const V2_SENTINEL_PREFIX = '/; + +/** + * v2 annotation JSON shape (as stored in the markdown file). + */ +interface Annotation { + id: number; + anchor: { + text: string; + prefix?: string; + suffix?: string; + }; + body: string; + author: string; + date: string; + resolved?: boolean; + tags?: string[]; + thread?: Array<{ + author: string; + body: string; + date: string; + }>; + reactions?: Record; + context?: { + line?: number; + section?: string; + sectionLevel?: number; + breadcrumb?: string[]; + }; +} + +/** + * Detect whether a markdown string uses v1, v2, or no comment format. + */ +export function detectFormat(markdown: string): 'v1' | 'v2' | 'none' { + const hasV1 = markdown.includes(V1_SENTINEL); + const hasV2 = markdown.includes(V2_SENTINEL_PREFIX); + + if (hasV1) return 'v1'; + if (hasV2) return 'v2'; + return 'none'; +} + +/** + * Parse annotations from markdown text. + * Detects format automatically: v1 is delegated to comment-parser, + * v2 is handled natively. + */ +export function parseAnnotations(markdown: string): CommentParseResult { + const format = detectFormat(markdown); + + if (format === 'v1') { + return parseComments(markdown); + } + + if (format === 'v2') { + return parseV2(markdown); + } + + return { cleanedMarkdown: markdown, comments: [] }; +} + +/** + * Alias for backward-compatible import as `parseComments`. + */ +export { parseAnnotations as parseComments }; + +/** + * Parse v2 annotation format. + */ +function parseV2(markdown: string): CommentParseResult { + // Find the annotation block + const blockMatch = markdown.match(V2_BLOCK_PATTERN); + if (!blockMatch) { + // Has sentinel prefix but malformed — strip markers and return empty + const cleanedMarkdown = stripV2Markers(stripV2Block(markdown)); + return { cleanedMarkdown, comments: [] }; + } + + // Parse JSON array + let annotations: Annotation[]; + try { + annotations = JSON.parse(blockMatch[1]) as Annotation[]; + } catch { + console.warn('[MDView] Failed to parse v2 annotation JSON'); + const cleanedMarkdown = stripV2Markers(stripV2Block(markdown)); + return { cleanedMarkdown, comments: [] }; + } + + // Build Comment objects + const comments = annotations.map(annotationToComment); + + // Remove the annotation block and [@N] markers from content + const cleanedMarkdown = stripV2Markers(stripV2Block(markdown)); + + return { cleanedMarkdown, comments }; +} + +/** + * Remove the `` block from the end of the document. + */ +function stripV2Block(markdown: string): string { + const idx = markdown.indexOf(V2_SENTINEL_PREFIX); + if (idx === -1) return markdown; + return markdown.slice(0, idx).trimEnd(); +} + +/** + * Remove all `[@N]` markers from content. + */ +function stripV2Markers(content: string): string { + return content.replace(V2_MARKER_PATTERN, ''); +} + +/** + * Convert a v2 annotation JSON object to an internal Comment. + */ +function annotationToComment(annotation: Annotation): Comment { + const comment: Comment = { + id: `comment-${annotation.id}`, + selectedText: annotation.anchor.text, + body: annotation.body, + author: annotation.author, + date: annotation.date, + resolved: annotation.resolved ?? false, + }; + + // Anchor context + if (annotation.anchor.prefix) { + comment.anchorPrefix = annotation.anchor.prefix; + } + if (annotation.anchor.suffix) { + comment.anchorSuffix = annotation.anchor.suffix; + } + + // Positional context + if (annotation.context && annotation.context.line !== undefined) { + const ctx: CommentContext = { + line: annotation.context.line, + section: annotation.context.section, + sectionLevel: annotation.context.sectionLevel, + breadcrumb: annotation.context.breadcrumb ?? [], + }; + comment.context = ctx; + } + + // Tags + if (annotation.tags && annotation.tags.length > 0) { + comment.tags = annotation.tags as Comment['tags']; + } + + // Thread → replies + if (annotation.thread && annotation.thread.length > 0) { + comment.replies = annotation.thread.map( + (t, i): CommentReply => ({ + id: `reply-${i + 1}`, + author: t.author, + body: t.body, + date: t.date, + }) + ); + } + + // Reactions + if (annotation.reactions && Object.keys(annotation.reactions).length > 0) { + comment.reactions = annotation.reactions; + } + + return comment; +} diff --git a/packages/core/src/comments/annotation-serializer.ts b/packages/core/src/comments/annotation-serializer.ts new file mode 100644 index 0000000..623eec8 --- /dev/null +++ b/packages/core/src/comments/annotation-serializer.ts @@ -0,0 +1,497 @@ +/** + * Annotation Serializer (v2) + * + * Generates markdown with `[@N]` inline markers and a structured JSON + * annotation block in ``. + * + * Drop-in replacement for comment-serializer: same exported function + * signatures. On v1 input, automatically migrates to v2 format. + */ + +import type { Comment, CommentReply } from '../types/index'; +import type { SourcePositionMap, SelectionContext } from './source-position-map'; +import { findInsertionPoint } from './source-position-map'; +import { parseComments } from './comment-parser'; + +const V1_SENTINEL = ''; +const V2_SENTINEL_PREFIX = '/; +const V2_MARKER_PATTERN = /\[@(\d+)\]/g; + +// ─── v2 annotation JSON shape ──────────────────────────────────────── + +interface Annotation { + id: number; + anchor: { text: string; prefix?: string; suffix?: string }; + body: string; + author: string; + date: string; + resolved?: boolean; + tags?: string[]; + thread?: Array<{ author: string; body: string; date: string }>; + reactions?: Record; + context?: { + line?: number; + section?: string; + sectionLevel?: number; + breadcrumb?: string[]; + }; +} + +// ─── Internal helpers ──────────────────────────────────────────────── + +/** + * Split markdown into content (above annotation block) and annotations array. + */ +function splitAtAnnotationBlock( + markdown: string +): [string, Annotation[] | null] { + const blockMatch = markdown.match(V2_BLOCK_PATTERN); + if (!blockMatch) { + return [markdown, null]; + } + + const blockStart = markdown.indexOf(V2_SENTINEL_PREFIX); + const content = markdown.slice(0, blockStart); + + try { + const annotations = JSON.parse(blockMatch[1]) as Annotation[]; + return [content, annotations]; + } catch { + return [content, null]; + } +} + +/** + * Pretty-print the annotation block with 2-space indentation. + */ +function serializeAnnotationBlock(annotations: Annotation[]): string { + const json = JSON.stringify(annotations, null, 2); + return ``; +} + +/** + * Convert an internal Comment to a v2 annotation JSON object. + */ +function commentToAnnotation(comment: Comment): Annotation { + const numId = parseInt(comment.id.replace('comment-', ''), 10); + + const anchor: Annotation['anchor'] = { text: comment.selectedText }; + if (comment.anchorPrefix) anchor.prefix = comment.anchorPrefix; + if (comment.anchorSuffix) anchor.suffix = comment.anchorSuffix; + + const annotation: Annotation = { + id: numId, + anchor, + body: comment.body, + author: comment.author, + date: comment.date, + }; + + if (comment.resolved) { + annotation.resolved = true; + } + + if (comment.tags && comment.tags.length > 0) { + annotation.tags = comment.tags; + } + + if (comment.replies && comment.replies.length > 0) { + annotation.thread = comment.replies.map((r) => ({ + author: r.author, + body: r.body, + date: r.date, + })); + } + + if (comment.reactions && Object.keys(comment.reactions).length > 0) { + annotation.reactions = comment.reactions; + } + + if (comment.context) { + const ctx: Annotation['context'] = {}; + if (comment.context.line !== undefined) ctx.line = comment.context.line; + if (comment.context.section !== undefined) + ctx.section = comment.context.section; + if (comment.context.sectionLevel !== undefined) + ctx.sectionLevel = comment.context.sectionLevel; + if (comment.context.breadcrumb && comment.context.breadcrumb.length > 0) + ctx.breadcrumb = comment.context.breadcrumb; + if (Object.keys(ctx).length > 0) annotation.context = ctx; + } + + return annotation; +} + +/** + * Convert a v2 annotation to an internal Comment. + */ +function annotationToComment(annotation: Annotation): Comment { + const comment: Comment = { + id: `comment-${annotation.id}`, + selectedText: annotation.anchor.text, + body: annotation.body, + author: annotation.author, + date: annotation.date, + resolved: annotation.resolved ?? false, + }; + + if (annotation.anchor.prefix) comment.anchorPrefix = annotation.anchor.prefix; + if (annotation.anchor.suffix) comment.anchorSuffix = annotation.anchor.suffix; + + if (annotation.tags && annotation.tags.length > 0) { + comment.tags = annotation.tags as Comment['tags']; + } + + if (annotation.thread && annotation.thread.length > 0) { + comment.replies = annotation.thread.map( + (t, i): CommentReply => ({ + id: `reply-${i + 1}`, + author: t.author, + body: t.body, + date: t.date, + }) + ); + } + + if (annotation.reactions && Object.keys(annotation.reactions).length > 0) { + comment.reactions = annotation.reactions; + } + + if (annotation.context && annotation.context.line !== undefined) { + comment.context = { + line: annotation.context.line, + section: annotation.context.section, + sectionLevel: annotation.context.sectionLevel, + breadcrumb: annotation.context.breadcrumb ?? [], + }; + } + + return comment; +} + +/** + * Detect v1 sentinel and fully migrate to v2 format. + * Returns the markdown with all v1 content replaced by v2. + */ +function migrateV1Content(markdown: string): string { + const result = parseComments(markdown); + + // Start with cleaned content (v1 refs already stripped by parser) + let content = result.cleanedMarkdown; + + // Re-insert markers in v2 format + for (const comment of result.comments) { + const numId = parseInt(comment.id.replace('comment-', ''), 10); + const marker = `[@${numId}]`; + const idx = content.indexOf(comment.selectedText); + if (idx !== -1) { + const insertPos = idx + comment.selectedText.length; + content = content.slice(0, insertPos) + marker + content.slice(insertPos); + } + } + + // Build annotation block + const annotations = result.comments.map(commentToAnnotation); + const block = serializeAnnotationBlock(annotations); + + return content.trimEnd() + '\n\n' + block + '\n'; +} + +/** + * If markdown contains v1 sentinel, migrate it. Otherwise return as-is. + */ +function ensureV2(markdown: string): string { + if (markdown.includes(V1_SENTINEL)) { + return migrateV1Content(markdown); + } + return markdown; +} + +/** + * Insert a `[@N]` marker after the first unmatched occurrence of selectedText + * in the content section. + */ +function insertMarker( + contentSection: string, + selectedText: string, + numId: number +): string { + const marker = `[@${numId}]`; + const escapedText = selectedText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Find first occurrence not already followed by [@N] + const pattern = new RegExp(`${escapedText}(?!\\[@\\d+\\])`); + const match = pattern.exec(contentSection); + if (match) { + const insertPos = match.index + match[0].length; + return ( + contentSection.slice(0, insertPos) + + marker + + contentSection.slice(insertPos) + ); + } + // Fallback: append to first occurrence + const idx = contentSection.indexOf(selectedText); + if (idx !== -1) { + const insertPos = idx + selectedText.length; + return ( + contentSection.slice(0, insertPos) + + marker + + contentSection.slice(insertPos) + ); + } + return contentSection; +} + +// ─── Exported API (same signatures as v1 serializer) ───────────────── + +/** + * Scan for `[@N]` markers and annotation `"id": N` to find the next ID. + */ +export function generateNextCommentId(markdown: string): string { + let max = 0; + + // Check [@N] markers + let match: RegExpExecArray | null; + const markerPattern = new RegExp(V2_MARKER_PATTERN.source, 'g'); + while ((match = markerPattern.exec(markdown)) !== null) { + const num = parseInt(match[1], 10); + if (num > max) max = num; + } + + // Check "id": N in annotation block + const idPattern = /"id":\s*(\d+)/g; + while ((match = idPattern.exec(markdown)) !== null) { + const num = parseInt(match[1], 10); + if (num > max) max = num; + } + + // Also check v1 patterns for migration scenarios + const v1Pattern = /\[\^comment-(\d+)\]/g; + while ((match = v1Pattern.exec(markdown)) !== null) { + const num = parseInt(match[1], 10); + if (num > max) max = num; + } + + return `comment-${max + 1}`; +} + +/** + * Add a comment: insert `[@N]` marker after selectedText, append to annotation block. + */ +export function addComment(markdown: string, comment: Comment): string { + // Migrate v1 if needed + const md = ensureV2(markdown); + + const [contentSection, existingAnnotations] = splitAtAnnotationBlock(md); + const numId = parseInt(comment.id.replace('comment-', ''), 10); + + // Insert marker in content + const updatedContent = insertMarker(contentSection, comment.selectedText, numId); + + // Build annotation + const annotation = commentToAnnotation(comment); + + const annotations = existingAnnotations ? [...existingAnnotations, annotation] : [annotation]; + const block = serializeAnnotationBlock(annotations); + + return updatedContent.trimEnd() + '\n\n' + block + '\n'; +} + +/** + * Add a comment using a pre-built source position map for accurate insertion. + * Falls back to `addComment()` if the map can't find the position. + */ +export function addCommentAtOffset( + markdown: string, + comment: Comment, + sourceMap: SourcePositionMap, + context?: SelectionContext +): string { + const offset = findInsertionPoint(sourceMap, comment.selectedText, context); + + if (offset === null) { + return addComment(markdown, comment); + } + + // Migrate v1 if needed + const md = ensureV2(markdown); + + const [contentSection, existingAnnotations] = splitAtAnnotationBlock(md); + const numId = parseInt(comment.id.replace('comment-', ''), 10); + const marker = `[@${numId}]`; + + // Insert marker at exact offset + const updatedContent = + contentSection.slice(0, offset) + marker + contentSection.slice(offset); + + const annotation = commentToAnnotation(comment); + const annotations = existingAnnotations ? [...existingAnnotations, annotation] : [annotation]; + const block = serializeAnnotationBlock(annotations); + + return updatedContent.trimEnd() + '\n\n' + block + '\n'; +} + +/** + * Remove a comment: remove `[@N]` marker and annotation entry. + * Removes block entirely if no annotations remain. + */ +export function removeComment(markdown: string, commentId: string): string { + const md = ensureV2(markdown); + + const [contentSection, annotations] = splitAtAnnotationBlock(md); + + if (!annotations) { + // No annotation block — just strip any markers + const numId = parseInt(commentId.replace('comment-', ''), 10); + return contentSection.split(`[@${numId}]`).join(''); + } + + const numId = parseInt(commentId.replace('comment-', ''), 10); + + // Remove inline marker + const cleanedContent = contentSection.split(`[@${numId}]`).join(''); + + // Remove annotation entry + const remaining = annotations.filter((a) => a.id !== numId); + + if (remaining.length === 0) { + return cleanedContent.trimEnd() + '\n'; + } + + const block = serializeAnnotationBlock(remaining); + return cleanedContent.trimEnd() + '\n\n' + block + '\n'; +} + +/** + * Update a comment's body in the annotation block. + */ +export function updateComment( + markdown: string, + commentId: string, + newBody: string +): string { + const md = ensureV2(markdown); + + const [contentSection, annotations] = splitAtAnnotationBlock(md); + if (!annotations) return markdown; + + const numId = parseInt(commentId.replace('comment-', ''), 10); + let found = false; + + const updated = annotations.map((a) => { + if (a.id === numId) { + found = true; + return { ...a, body: newBody }; + } + return a; + }); + + if (!found) return markdown; + + const block = serializeAnnotationBlock(updated); + return contentSection.trimEnd() + '\n\n' + block + '\n'; +} + +/** + * Resolve a comment: set `resolved: true`. + */ +export function resolveComment(markdown: string, commentId: string): string { + return updateCommentMetadata(markdown, commentId, (c) => { + c.resolved = true; + }); +} + +/** + * Generic metadata updater: parse annotation, call updater to mutate + * the Comment, then re-serialize. + */ +export function updateCommentMetadata( + markdown: string, + commentId: string, + updater: (comment: Comment) => void +): string { + const md = ensureV2(markdown); + + const [contentSection, annotations] = splitAtAnnotationBlock(md); + if (!annotations) return markdown; + + const numId = parseInt(commentId.replace('comment-', ''), 10); + let found = false; + + const updated = annotations.map((a) => { + if (a.id === numId) { + found = true; + const comment = annotationToComment(a); + updater(comment); + return commentToAnnotation(comment); + } + return a; + }); + + if (!found) return markdown; + + const block = serializeAnnotationBlock(updated); + return contentSection.trimEnd() + '\n\n' + block + '\n'; +} + +/** + * Add a reply to a comment's thread. + */ +export function addReply( + markdown: string, + commentId: string, + reply: Omit +): { markdown: string; replyId: string } { + let replyId = ''; + + const updated = updateCommentMetadata(markdown, commentId, (comment) => { + const replies = comment.replies ?? []; + let maxNum = 0; + for (const r of replies) { + const match = /^reply-(\d+)$/.exec(r.id); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxNum) maxNum = num; + } + } + replyId = `reply-${maxNum + 1}`; + replies.push({ id: replyId, ...reply }); + comment.replies = replies; + }); + + return { markdown: updated, replyId }; +} + +/** + * Toggle an emoji reaction for an author on a comment. + */ +export function toggleReaction( + markdown: string, + commentId: string, + emoji: string, + author: string +): string { + return updateCommentMetadata(markdown, commentId, (comment) => { + const reactions = comment.reactions ?? {}; + const authors = reactions[emoji] ?? []; + + const idx = authors.indexOf(author); + if (idx >= 0) { + authors.splice(idx, 1); + if (authors.length === 0) { + delete reactions[emoji]; + } else { + reactions[emoji] = authors; + } + } else { + reactions[emoji] = [...authors, author]; + } + + if (Object.keys(reactions).length > 0) { + comment.reactions = reactions; + } else { + delete comment.reactions; + } + }); +} diff --git a/packages/core/src/comments/comment-context.ts b/packages/core/src/comments/comment-context.ts new file mode 100644 index 0000000..1622442 --- /dev/null +++ b/packages/core/src/comments/comment-context.ts @@ -0,0 +1,107 @@ +/** + * Comment Context Builder + * + * Computes positional context (line number, section heading, breadcrumb) + * for a comment at a given character offset in markdown content. This + * metadata is stored in the footnote definition so AI agents reading + * the raw file can immediately understand where each comment is anchored. + */ + +import type { CommentContext } from '../types/index'; +import { splitIntoSections, type MarkdownSection } from '../../../../src/utils/section-splitter'; + +/** + * Compute positional context for a comment at a given character offset + * in the content section of markdown (above the comment separator). + */ +export function computeCommentContext( + contentMarkdown: string, + charOffset: number +): CommentContext { + const line = offsetToLine(contentMarkdown, charOffset); + const sections = splitIntoSections(contentMarkdown); + const { section, sectionLevel, breadcrumb } = findSectionContext(sections, line); + + return { + line, + section, + sectionLevel, + breadcrumb, + }; +} + +/** + * Convert a 0-based character offset to a 1-based line number. + */ +function offsetToLine(text: string, offset: number): number { + const clamped = Math.min(offset, text.length); + let line = 1; + for (let i = 0; i < clamped; i++) { + if (text[i] === '\n') { + line++; + } + } + return line; +} + +/** + * Given sections from splitIntoSections() and a 1-based line number, + * find the containing section and build the heading breadcrumb. + */ +function findSectionContext( + sections: MarkdownSection[], + line: number +): { section?: string; sectionLevel?: number; breadcrumb: string[] } { + // splitIntoSections uses 0-based line indices; our line is 1-based + const zeroLine = line - 1; + + let containingSection: MarkdownSection | undefined; + for (const s of sections) { + if (zeroLine >= s.startLine && zeroLine <= s.endLine) { + containingSection = s; + break; + } + } + + if (!containingSection || !containingSection.heading) { + return { breadcrumb: [] }; + } + + const breadcrumb = buildBreadcrumb(sections, containingSection); + + return { + section: containingSection.heading, + sectionLevel: containingSection.level, + breadcrumb, + }; +} + +/** + * Build a heading breadcrumb from the document start to the target section. + * + * Walk all sections up to and including the target. Maintain a stack: + * when encountering a heading at level N, pop all headings at level >= N, + * then push. The final stack is the breadcrumb. + */ +function buildBreadcrumb( + sections: MarkdownSection[], + target: MarkdownSection +): string[] { + const stack: Array<{ heading: string; level: number }> = []; + + for (const s of sections) { + if (s.heading && s.level !== undefined) { + // Pop headings at same or deeper level + while (stack.length > 0 && stack[stack.length - 1].level >= s.level) { + stack.pop(); + } + stack.push({ heading: s.heading, level: s.level }); + } + + if (s === target) { + break; + } + } + + return stack.map((entry) => entry.heading); +} diff --git a/packages/core/src/comments/comment-parser.ts b/packages/core/src/comments/comment-parser.ts new file mode 100644 index 0000000..58ab9c5 --- /dev/null +++ b/packages/core/src/comments/comment-parser.ts @@ -0,0 +1,164 @@ +/** + * Comment parser for extracting structured comments from markdown footnotes. + * + * Comments are stored as footnotes with `[^comment-*]` references in the markdown + * content, with metadata embedded in HTML comments following the `` + * pattern. A `` separator divides the document content from + * the comment section. + * + * @deprecated Use `annotation-parser.ts` instead. This module is kept only for + * v1 backward compatibility delegation from the annotation parser. + */ + +import type { Comment, CommentContext, CommentMetadata, CommentParseResult } from '../types/index'; + +const COMMENT_SEPARATOR = ''; +const COMMENT_REF_PATTERN = /\[\^comment-(\w+)\]/g; +const FOOTNOTE_DEF_PATTERN = /^\[\^comment-(\w+)\]:\s*\s*$/; +const BODY_CONTINUATION_PATTERN = /^ (.*)$/; + +/** + * Parse comments from markdown text. + * + * Splits markdown at the `` separator, parses footnote + * definitions from the comments section, extracts the selected text from the + * content section, and returns cleaned markdown with comment references removed. + */ +export function parseComments(markdown: string): CommentParseResult { + const separatorIndex = markdown.indexOf(COMMENT_SEPARATOR); + + if (separatorIndex === -1) { + return { cleanedMarkdown: markdown, comments: [] }; + } + + const contentPortion = markdown.substring(0, separatorIndex); + const commentsPortion = markdown.substring(separatorIndex + COMMENT_SEPARATOR.length); + + const comments = parseCommentFootnotes(commentsPortion, contentPortion); + const cleanedMarkdown = removeCommentReferences(contentPortion.trimEnd()); + + return { cleanedMarkdown, comments }; +} + +/** + * Parse comment footnote definitions from the comments section of the markdown. + */ +function parseCommentFootnotes(commentSection: string, contentSection: string): Comment[] { + const lines = commentSection.split('\n'); + const comments: Comment[] = []; + + let currentId: string | null = null; + let currentMetadata: CommentMetadata | null = null; + let currentBodyLines: string[] = []; + + for (const line of lines) { + // Try to match a footnote definition line + const defMatch = line.match(FOOTNOTE_DEF_PATTERN); + + if (defMatch) { + // Flush previous comment if any + if (currentId !== null && currentMetadata !== null) { + comments.push(buildComment(currentId, currentMetadata, currentBodyLines, contentSection)); + } + + currentId = defMatch[1]; + currentBodyLines = []; + + // Parse the JSON metadata + try { + currentMetadata = JSON.parse(defMatch[2]) as CommentMetadata; + } catch { + // Malformed JSON - skip this comment + currentId = null; + currentMetadata = null; + currentBodyLines = []; + } + continue; + } + + // Try to match a continuation line (indented with 4 spaces) + if (currentId !== null && currentMetadata !== null) { + const bodyMatch = line.match(BODY_CONTINUATION_PATTERN); + if (bodyMatch) { + currentBodyLines.push(bodyMatch[1]); + } + } + } + + // Flush the last comment + if (currentId !== null && currentMetadata !== null) { + comments.push(buildComment(currentId, currentMetadata, currentBodyLines, contentSection)); + } + + return comments; +} + +/** + * Build a Comment object from parsed footnote data. + */ +function buildComment( + id: string, + metadata: CommentMetadata, + bodyLines: string[], + contentSection: string, +): Comment { + const comment: Comment = { + id: `comment-${id}`, + selectedText: metadata.selectedText || extractSelectedText(id, contentSection), + body: bodyLines.join('\n'), + author: metadata.author, + date: metadata.date, + resolved: metadata.resolved ?? false, + }; + + // Reconstruct positional context if fields are present in metadata + if (metadata.line !== undefined) { + const context: CommentContext = { + line: metadata.line, + section: metadata.section, + sectionLevel: metadata.sectionLevel, + breadcrumb: metadata.breadcrumb ?? [], + }; + comment.context = context; + } + + // Reconstruct tags if present and non-empty + if (metadata.tags !== undefined && metadata.tags.length > 0) { + comment.tags = metadata.tags; + } + + // Reconstruct replies if present and non-empty + if (metadata.replies !== undefined && metadata.replies.length > 0) { + comment.replies = metadata.replies; + } + + // Reconstruct reactions if present and non-empty + if (metadata.reactions !== undefined && Object.keys(metadata.reactions).length > 0) { + comment.reactions = metadata.reactions; + } + + return comment; +} + +/** + * Extract the word immediately before a `[^comment-N]` reference in the content. + */ +function extractSelectedText(id: string, content: string): string { + const refPattern = new RegExp(`(\\S+)\\[\\^comment-${escapeRegExp(id)}\\]`); + const match = content.match(refPattern); + return match ? match[1] : ''; +} + +/** + * Remove all `[^comment-*]` references from the content section. + */ +function removeCommentReferences(content: string): string { + return content.replace(COMMENT_REF_PATTERN, ''); +} + +/** + * Escape special regex characters in a string. + */ +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/core/src/comments/comment-serializer.ts b/packages/core/src/comments/comment-serializer.ts new file mode 100644 index 0000000..d2cc399 --- /dev/null +++ b/packages/core/src/comments/comment-serializer.ts @@ -0,0 +1,509 @@ +/** + * Comment Serializer (v1) + * + * Generates markdown with footnote references injected and footnote bodies appended. + * Handles add, update, delete, and resolve operations on raw markdown strings. + * + * Markdown format: + * Some highlighted text[^comment-1] in context. + * + * + * [^comment-1]: + * This API endpoint needs error handling + * for the 404 case. + * + * @deprecated Use `annotation-serializer.ts` instead. This module is no longer + * imported by production code. + */ + +import type { Comment, CommentMetadata, CommentReply } from '../types/index'; +import type { SourcePositionMap, SelectionContext } from './source-position-map'; +import { findInsertionPoint } from './source-position-map'; + +const COMMENT_SEPARATOR = ''; +const COMMENT_ID_PATTERN = /\[\^comment-(\d+)\]/g; +const FOOTNOTE_DEF_PATTERN = + /^\[\^(comment-\d+)\]: /; + +/** + * Scan the markdown for `[^comment-N]` patterns and return `comment-(max+1)`. + */ +export function generateNextCommentId(markdown: string): string { + let max = 0; + let match: RegExpExecArray | null; + const pattern = new RegExp(COMMENT_ID_PATTERN.source, 'g'); + while ((match = pattern.exec(markdown)) !== null) { + const num = parseInt(match[1], 10); + if (num > max) { + max = num; + } + } + return `comment-${max + 1}`; +} + +/** + * Build the metadata JSON string for a comment footnote. + * Omits the `resolved` key when it is false/undefined to keep the format clean. + */ +function buildMetadataJson(meta: CommentMetadata): string { + const obj: Record = { + author: meta.author, + date: meta.date, + }; + if (meta.resolved) { + obj.resolved = true; + } + if (meta.selectedText) { + obj.selectedText = meta.selectedText; + } + // Positional context fields + if (meta.line !== undefined) { + obj.line = meta.line; + } + if (meta.section !== undefined) { + obj.section = meta.section; + } + if (meta.sectionLevel !== undefined) { + obj.sectionLevel = meta.sectionLevel; + } + if (meta.breadcrumb !== undefined && meta.breadcrumb.length > 0) { + obj.breadcrumb = meta.breadcrumb; + } + if (meta.tags !== undefined && meta.tags.length > 0) { + obj.tags = meta.tags; + } + if (meta.replies !== undefined && meta.replies.length > 0) { + obj.replies = meta.replies; + } + if (meta.reactions !== undefined && Object.keys(meta.reactions).length > 0) { + obj.reactions = meta.reactions; + } + return JSON.stringify(obj); +} + +/** + * Format the footnote body lines, each indented with 4 spaces. + */ +function formatFootnoteBody(body: string): string { + return body + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); +} + +/** + * Build a complete footnote definition block. + */ +function buildFootnoteBlock(comment: Comment): string { + const meta: CommentMetadata = { + author: comment.author, + date: comment.date, + }; + if (comment.resolved) { + meta.resolved = true; + } + if (comment.selectedText) { + meta.selectedText = comment.selectedText; + } + if (comment.context) { + meta.line = comment.context.line; + meta.section = comment.context.section; + meta.sectionLevel = comment.context.sectionLevel; + meta.breadcrumb = comment.context.breadcrumb; + } + if (comment.tags && comment.tags.length > 0) { + meta.tags = comment.tags; + } + if (comment.replies && comment.replies.length > 0) { + meta.replies = comment.replies; + } + if (comment.reactions && Object.keys(comment.reactions).length > 0) { + meta.reactions = comment.reactions; + } + const metaJson = buildMetadataJson(meta); + const header = `[^${comment.id}]: `; + const body = formatFootnoteBody(comment.body); + return `${header}\n${body}`; +} + +/** + * Find the selectedText in the content portion of the markdown (above the + * comments separator) and insert the footnote reference after the first + * unmatched occurrence. + * + * "Unmatched" means the occurrence is not already immediately followed by + * a `[^comment-N]` reference. + */ +function insertReference( + contentSection: string, + selectedText: string, + commentId: string +): string { + const ref = `[^${commentId}]`; + // We need to find the first occurrence of selectedText that is NOT + // already followed by [^comment-...] + const escapedText = selectedText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp( + `${escapedText}(?!\\[\\^comment-\\d+\\])`, + ); + const match = pattern.exec(contentSection); + if (match) { + const insertPos = match.index + match[0].length; + return ( + contentSection.slice(0, insertPos) + + ref + + contentSection.slice(insertPos) + ); + } + // Fallback: if no unmatched occurrence, just append to first occurrence + const idx = contentSection.indexOf(selectedText); + if (idx !== -1) { + const insertPos = idx + selectedText.length; + return ( + contentSection.slice(0, insertPos) + + ref + + contentSection.slice(insertPos) + ); + } + return contentSection; +} + +/** + * Split markdown into content (above separator) and comments section (below). + * Returns [contentSection, commentsSection | null]. + */ +function splitAtSeparator(markdown: string): [string, string | null] { + const sepIdx = markdown.indexOf(COMMENT_SEPARATOR); + if (sepIdx === -1) { + return [markdown, null]; + } + const content = markdown.slice(0, sepIdx); + const comments = markdown.slice(sepIdx + COMMENT_SEPARATOR.length); + return [content, comments]; +} + +/** + * Add a comment: insert reference after selectedText, append footnote body + * after separator. + */ +export function addComment(markdown: string, comment: Comment): string { + const [contentSection, existingComments] = splitAtSeparator(markdown); + + // Insert reference in content + const updatedContent = insertReference( + contentSection, + comment.selectedText, + comment.id + ); + + // Build footnote block + const footnoteBlock = buildFootnoteBlock(comment); + + if (existingComments !== null) { + // Separator already exists; append the new footnote + const trimmedComments = existingComments.trimEnd(); + return ( + updatedContent + + COMMENT_SEPARATOR + + (trimmedComments ? trimmedComments + '\n\n' : '\n') + + footnoteBlock + '\n' + ); + } else { + // No separator yet; add it + const trimmedContent = updatedContent.trimEnd(); + return ( + trimmedContent + + '\n\n' + + COMMENT_SEPARATOR + + '\n' + + footnoteBlock + '\n' + ); + } +} + +/** + * Add a comment using a pre-built source position map for accurate insertion. + * Falls back to `addComment()` (text-search based) if the map can't find the position. + */ +export function addCommentAtOffset( + markdown: string, + comment: Comment, + sourceMap: SourcePositionMap, + context?: SelectionContext +): string { + const offset = findInsertionPoint(sourceMap, comment.selectedText, context); + + if (offset === null) { + // Fallback to text-search based insertion + return addComment(markdown, comment); + } + + const [contentSection, existingComments] = splitAtSeparator(markdown); + + // Insert the reference at the exact offset in the content section + const ref = `[^${comment.id}]`; + const updatedContent = + contentSection.slice(0, offset) + ref + contentSection.slice(offset); + + // Build footnote block + const footnoteBlock = buildFootnoteBlock(comment); + + if (existingComments !== null) { + const trimmedComments = existingComments.trimEnd(); + return ( + updatedContent + + COMMENT_SEPARATOR + + (trimmedComments ? trimmedComments + '\n\n' : '\n') + + footnoteBlock + '\n' + ); + } else { + const trimmedContent = updatedContent.trimEnd(); + return ( + trimmedContent + + '\n\n' + + COMMENT_SEPARATOR + + '\n' + + footnoteBlock + '\n' + ); + } +} + +/** + * Parse the comments section into individual footnote blocks. + * Each block is: { id, headerLine, bodyLines[] }. + */ +interface FootnoteBlock { + id: string; + headerLine: string; + bodyLines: string[]; +} + +function parseFootnoteBlocks(commentsSection: string): FootnoteBlock[] { + const lines = commentsSection.split('\n'); + const blocks: FootnoteBlock[] = []; + let current: FootnoteBlock | null = null; + + for (const line of lines) { + const defMatch = FOOTNOTE_DEF_PATTERN.exec(line); + if (defMatch) { + if (current) { + blocks.push(current); + } + current = { + id: defMatch[1], + headerLine: line, + bodyLines: [], + }; + } else if (current && line.startsWith(' ')) { + current.bodyLines.push(line); + } else if (current && line.trim() === '') { + // Blank line between blocks -- finalize current + blocks.push(current); + current = null; + } + } + if (current) { + blocks.push(current); + } + return blocks; +} + +/** + * Serialize footnote blocks back to a comments section string. + */ +function serializeFootnoteBlocks(blocks: FootnoteBlock[]): string { + return blocks + .map((b) => [b.headerLine, ...b.bodyLines].join('\n')) + .join('\n\n'); +} + +/** + * Remove a comment: remove the inline reference and the footnote body. + * Remove the separator if no comments remain. + */ +export function removeComment(markdown: string, commentId: string): string { + const [contentSection, commentsSection] = splitAtSeparator(markdown); + + // Remove inline reference [^commentId] + const ref = `[^${commentId}]`; + const cleanedContent = contentSection.split(ref).join(''); + + if (commentsSection === null) { + return cleanedContent; + } + + // Parse and filter footnote blocks + const blocks = parseFootnoteBlocks(commentsSection); + const remaining = blocks.filter((b) => b.id !== commentId); + + if (remaining.length === 0) { + // No comments remain; remove separator and trailing whitespace + return cleanedContent.trimEnd() + '\n'; + } + + // Rebuild with separator and remaining blocks + const serialized = serializeFootnoteBlocks(remaining); + return ( + cleanedContent + + COMMENT_SEPARATOR + + '\n' + + serialized + '\n' + ); +} + +/** + * Update a comment's body: replace the body lines under the matching footnote + * while keeping the metadata header unchanged. + */ +export function updateComment( + markdown: string, + commentId: string, + newBody: string +): string { + const [contentSection, commentsSection] = splitAtSeparator(markdown); + + if (commentsSection === null) { + return markdown; + } + + const blocks = parseFootnoteBlocks(commentsSection); + const updatedBlocks = blocks.map((b) => { + if (b.id === commentId) { + return { + ...b, + bodyLines: newBody.split('\n').map((line) => ` ${line}`), + }; + } + return b; + }); + + const serialized = serializeFootnoteBlocks(updatedBlocks); + return ( + contentSection + + COMMENT_SEPARATOR + + '\n' + + serialized + '\n' + ); +} + +/** + * Resolve a comment: parse the metadata JSON, set resolved:true, re-serialize. + */ +export function resolveComment(markdown: string, commentId: string): string { + return updateCommentMetadata(markdown, commentId, (meta) => { + meta.resolved = true; + }); +} + +/** + * Generic metadata updater: parse a comment's metadata JSON, call the updater + * function to mutate it, then rebuild the header line. Preserves body and + * other comments. + */ +export function updateCommentMetadata( + markdown: string, + commentId: string, + updater: (meta: CommentMetadata) => void +): string { + const [contentSection, commentsSection] = splitAtSeparator(markdown); + + if (commentsSection === null) { + return markdown; + } + + const blocks = parseFootnoteBlocks(commentsSection); + let found = false; + + const updatedBlocks = blocks.map((b) => { + if (b.id === commentId) { + const defMatch = FOOTNOTE_DEF_PATTERN.exec(b.headerLine); + if (defMatch) { + found = true; + const meta: CommentMetadata = JSON.parse(defMatch[2]); + updater(meta); + const metaJson = buildMetadataJson(meta); + const newHeader = `[^${b.id}]: `; + return { ...b, headerLine: newHeader }; + } + } + return b; + }); + + if (!found) { + return markdown; + } + + const serialized = serializeFootnoteBlocks(updatedBlocks); + return ( + contentSection + + COMMENT_SEPARATOR + + '\n' + + serialized + '\n' + ); +} + +/** + * Add a reply to a comment. Generates a sequential reply ID scoped to the + * parent comment. Returns the updated markdown and the generated reply ID. + */ +export function addReply( + markdown: string, + commentId: string, + reply: Omit +): { markdown: string; replyId: string } { + let replyId = ''; + + const updated = updateCommentMetadata(markdown, commentId, (meta) => { + const replies = meta.replies ?? []; + // Generate sequential ID: reply-1, reply-2, ... + let maxNum = 0; + for (const r of replies) { + const match = /^reply-(\d+)$/.exec(r.id); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxNum) maxNum = num; + } + } + replyId = `reply-${maxNum + 1}`; + replies.push({ id: replyId, ...reply }); + meta.replies = replies; + }); + + return { markdown: updated, replyId }; +} + +/** + * Toggle an emoji reaction for an author on a comment. + * Adds the author if not present; removes if already present. + * Removes the emoji key entirely when no authors remain. + */ +export function toggleReaction( + markdown: string, + commentId: string, + emoji: string, + author: string +): string { + return updateCommentMetadata(markdown, commentId, (meta) => { + const reactions = meta.reactions ?? {}; + const authors = reactions[emoji] ?? []; + + const idx = authors.indexOf(author); + if (idx >= 0) { + authors.splice(idx, 1); + if (authors.length === 0) { + delete reactions[emoji]; + } else { + reactions[emoji] = authors; + } + } else { + reactions[emoji] = [...authors, author]; + } + + // Only set reactions if non-empty + if (Object.keys(reactions).length > 0) { + meta.reactions = reactions; + } else { + delete meta.reactions; + } + }); +} diff --git a/packages/core/src/comments/emoji-data.ts b/packages/core/src/comments/emoji-data.ts new file mode 100644 index 0000000..8699757 --- /dev/null +++ b/packages/core/src/comments/emoji-data.ts @@ -0,0 +1,277 @@ +/** + * Static emoji data for the comment reaction picker. + * Native emoji rendering — no npm dependencies. + */ + +export interface EmojiEntry { + char: string; + name: string; + keywords?: string[]; +} + +export interface EmojiCategory { + name: string; + emojis: EmojiEntry[]; +} + +/** + * 12 quick-access emojis shown in the top row of the picker. + */ +export const QUICK_EMOJIS: EmojiEntry[] = [ + { char: '\u{1F44D}', name: 'thumbs up', keywords: ['like', 'approve', 'yes'] }, + { char: '\u{1F44E}', name: 'thumbs down', keywords: ['dislike', 'disapprove', 'no'] }, + { char: '\u{1F604}', name: 'grinning face', keywords: ['happy', 'smile', 'laugh'] }, + { char: '\u{1F389}', name: 'party popper', keywords: ['celebrate', 'tada', 'hooray'] }, + { char: '\u{1F615}', name: 'confused face', keywords: ['confused', 'unsure'] }, + { char: '\u{2764}\u{FE0F}', name: 'red heart', keywords: ['love', 'heart'] }, + { char: '\u{1F680}', name: 'rocket', keywords: ['launch', 'ship', 'fast'] }, + { char: '\u{1F440}', name: 'eyes', keywords: ['look', 'see', 'watching'] }, + { char: '\u{2705}', name: 'check mark', keywords: ['done', 'complete', 'yes', 'approve'] }, + { char: '\u{274C}', name: 'cross mark', keywords: ['no', 'wrong', 'reject'] }, + { char: '\u{1F525}', name: 'fire', keywords: ['hot', 'lit', 'amazing'] }, + { char: '\u{2B50}', name: 'star', keywords: ['favorite', 'excellent', 'great'] }, +]; + +/** + * 8 emoji categories with ~200 emojis total. + */ +export const EMOJI_CATEGORIES: EmojiCategory[] = [ + { + name: 'Smileys & People', + emojis: [ + { char: '\u{1F600}', name: 'grinning face', keywords: ['happy', 'smile'] }, + { char: '\u{1F604}', name: 'grinning face with smiling eyes', keywords: ['happy', 'smile'] }, + { char: '\u{1F601}', name: 'beaming face', keywords: ['happy', 'grin'] }, + { char: '\u{1F606}', name: 'squinting face', keywords: ['laugh', 'happy'] }, + { char: '\u{1F605}', name: 'grinning face with sweat', keywords: ['relief'] }, + { char: '\u{1F602}', name: 'face with tears of joy', keywords: ['laugh', 'crying'] }, + { char: '\u{1F923}', name: 'rolling on the floor laughing', keywords: ['laugh', 'rofl'] }, + { char: '\u{1F60A}', name: 'smiling face with smiling eyes', keywords: ['blush', 'happy'] }, + { char: '\u{1F607}', name: 'smiling face with halo', keywords: ['angel', 'innocent'] }, + { char: '\u{1F609}', name: 'winking face', keywords: ['wink'] }, + { char: '\u{1F60C}', name: 'relieved face', keywords: ['calm', 'peaceful'] }, + { char: '\u{1F60D}', name: 'smiling face with heart-eyes', keywords: ['love', 'crush'] }, + { char: '\u{1F618}', name: 'face blowing a kiss', keywords: ['kiss', 'love'] }, + { char: '\u{1F914}', name: 'thinking face', keywords: ['think', 'hmm'] }, + { char: '\u{1F928}', name: 'face with raised eyebrow', keywords: ['skeptical', 'doubt'] }, + { char: '\u{1F610}', name: 'neutral face', keywords: ['meh', 'blank'] }, + { char: '\u{1F611}', name: 'expressionless face', keywords: ['blank'] }, + { char: '\u{1F636}', name: 'face without mouth', keywords: ['speechless', 'silence'] }, + { char: '\u{1F644}', name: 'face with rolling eyes', keywords: ['eyeroll'] }, + { char: '\u{1F615}', name: 'confused face', keywords: ['confused', 'unsure'] }, + { char: '\u{1F61F}', name: 'worried face', keywords: ['worried', 'nervous'] }, + { char: '\u{1F622}', name: 'crying face', keywords: ['sad', 'cry'] }, + { char: '\u{1F62D}', name: 'loudly crying face', keywords: ['sob', 'cry'] }, + { char: '\u{1F621}', name: 'pouting face', keywords: ['angry', 'rage'] }, + { char: '\u{1F631}', name: 'face screaming in fear', keywords: ['scream', 'horror'] }, + { char: '\u{1F4AA}', name: 'flexed biceps', keywords: ['strong', 'muscle'] }, + { char: '\u{1F44F}', name: 'clapping hands', keywords: ['clap', 'applause'] }, + { char: '\u{1F64F}', name: 'folded hands', keywords: ['pray', 'please', 'thanks'] }, + { char: '\u{1F91D}', name: 'handshake', keywords: ['agree', 'deal'] }, + { char: '\u{270C}\u{FE0F}', name: 'victory hand', keywords: ['peace'] }, + ], + }, + { + name: 'Nature', + emojis: [ + { char: '\u{1F436}', name: 'dog face', keywords: ['puppy', 'pet'] }, + { char: '\u{1F431}', name: 'cat face', keywords: ['kitty', 'pet'] }, + { char: '\u{1F42D}', name: 'mouse face', keywords: ['mouse'] }, + { char: '\u{1F43B}', name: 'bear', keywords: ['bear'] }, + { char: '\u{1F98A}', name: 'fox', keywords: ['fox'] }, + { char: '\u{1F427}', name: 'penguin', keywords: ['penguin', 'linux'] }, + { char: '\u{1F41B}', name: 'bug', keywords: ['bug', 'insect'] }, + { char: '\u{1F40D}', name: 'snake', keywords: ['snake', 'python'] }, + { char: '\u{1F422}', name: 'turtle', keywords: ['slow', 'turtle'] }, + { char: '\u{1F419}', name: 'octopus', keywords: ['octopus'] }, + { char: '\u{1F332}', name: 'evergreen tree', keywords: ['tree', 'nature'] }, + { char: '\u{1F33B}', name: 'sunflower', keywords: ['flower'] }, + { char: '\u{1F340}', name: 'four leaf clover', keywords: ['luck', 'clover'] }, + { char: '\u{1F335}', name: 'cactus', keywords: ['desert', 'plant'] }, + { char: '\u{1F30A}', name: 'water wave', keywords: ['ocean', 'wave'] }, + { char: '\u{2600}\u{FE0F}', name: 'sun', keywords: ['sunny', 'weather'] }, + { char: '\u{1F319}', name: 'crescent moon', keywords: ['moon', 'night'] }, + { char: '\u{2B50}', name: 'star', keywords: ['favorite', 'excellent'] }, + { char: '\u{26A1}', name: 'lightning', keywords: ['electric', 'zap', 'thunder'] }, + { char: '\u{1F525}', name: 'fire', keywords: ['hot', 'lit', 'flame'] }, + { char: '\u{1F4A7}', name: 'droplet', keywords: ['water', 'drop'] }, + { char: '\u{2744}\u{FE0F}', name: 'snowflake', keywords: ['cold', 'winter'] }, + { char: '\u{1F308}', name: 'rainbow', keywords: ['rainbow'] }, + ], + }, + { + name: 'Food & Drink', + emojis: [ + { char: '\u{1F34E}', name: 'red apple', keywords: ['apple', 'fruit'] }, + { char: '\u{1F34A}', name: 'tangerine', keywords: ['orange', 'fruit'] }, + { char: '\u{1F34B}', name: 'lemon', keywords: ['citrus'] }, + { char: '\u{1F34C}', name: 'banana', keywords: ['fruit'] }, + { char: '\u{1F353}', name: 'strawberry', keywords: ['fruit', 'berry'] }, + { char: '\u{1F349}', name: 'watermelon', keywords: ['fruit', 'summer'] }, + { char: '\u{1F355}', name: 'pizza', keywords: ['food'] }, + { char: '\u{1F354}', name: 'hamburger', keywords: ['burger', 'food'] }, + { char: '\u{1F37F}', name: 'popcorn', keywords: ['movie', 'snack'] }, + { char: '\u{2615}', name: 'hot beverage', keywords: ['coffee', 'tea'] }, + { char: '\u{1F37A}', name: 'beer mug', keywords: ['beer', 'drink'] }, + { char: '\u{1F377}', name: 'wine glass', keywords: ['wine', 'drink'] }, + { char: '\u{1F375}', name: 'teacup', keywords: ['tea', 'drink'] }, + { char: '\u{1F382}', name: 'birthday cake', keywords: ['cake', 'celebration'] }, + { char: '\u{1F369}', name: 'donut', keywords: ['doughnut', 'sweet'] }, + { char: '\u{1F36B}', name: 'chocolate bar', keywords: ['chocolate', 'sweet'] }, + { char: '\u{1F950}', name: 'croissant', keywords: ['bread', 'pastry'] }, + { char: '\u{1F96A}', name: 'sandwich', keywords: ['food'] }, + { char: '\u{1F32E}', name: 'taco', keywords: ['mexican', 'food'] }, + { char: '\u{1F363}', name: 'sushi', keywords: ['japanese', 'food'] }, + ], + }, + { + name: 'Activities', + emojis: [ + { char: '\u{26BD}', name: 'soccer ball', keywords: ['football', 'sport'] }, + { char: '\u{1F3C0}', name: 'basketball', keywords: ['sport'] }, + { char: '\u{1F3C8}', name: 'american football', keywords: ['sport'] }, + { char: '\u{26BE}', name: 'baseball', keywords: ['sport'] }, + { char: '\u{1F3BE}', name: 'tennis', keywords: ['sport'] }, + { char: '\u{1F3AF}', name: 'bullseye', keywords: ['target', 'dart'] }, + { char: '\u{1F3AE}', name: 'video game', keywords: ['game', 'controller'] }, + { char: '\u{265F}\u{FE0F}', name: 'chess pawn', keywords: ['chess', 'game', 'strategy'] }, + { char: '\u{1F3B2}', name: 'game die', keywords: ['dice', 'game', 'random'] }, + { char: '\u{1F3B5}', name: 'musical note', keywords: ['music', 'note'] }, + { char: '\u{1F3B6}', name: 'musical notes', keywords: ['music'] }, + { char: '\u{1F3A4}', name: 'microphone', keywords: ['sing', 'karaoke'] }, + { char: '\u{1F3AC}', name: 'clapper board', keywords: ['movie', 'film'] }, + { char: '\u{1F3A8}', name: 'artist palette', keywords: ['art', 'paint'] }, + { char: '\u{1F3AD}', name: 'performing arts', keywords: ['theater', 'drama'] }, + { char: '\u{1F3C6}', name: 'trophy', keywords: ['winner', 'award', 'prize'] }, + { char: '\u{1F3C5}', name: 'sports medal', keywords: ['medal', 'award'] }, + { char: '\u{1F947}', name: 'first place medal', keywords: ['gold', 'winner'] }, + { char: '\u{1F948}', name: 'second place medal', keywords: ['silver'] }, + { char: '\u{1F949}', name: 'third place medal', keywords: ['bronze'] }, + ], + }, + { + name: 'Travel & Places', + emojis: [ + { char: '\u{1F697}', name: 'automobile', keywords: ['car'] }, + { char: '\u{1F680}', name: 'rocket', keywords: ['launch', 'ship', 'space'] }, + { char: '\u{2708}\u{FE0F}', name: 'airplane', keywords: ['travel', 'flight'] }, + { char: '\u{1F6A2}', name: 'ship', keywords: ['boat', 'cruise'] }, + { char: '\u{1F682}', name: 'locomotive', keywords: ['train'] }, + { char: '\u{1F6B2}', name: 'bicycle', keywords: ['bike', 'cycling'] }, + { char: '\u{1F3E0}', name: 'house', keywords: ['home'] }, + { char: '\u{1F3D7}\u{FE0F}', name: 'building construction', keywords: ['construction'] }, + { char: '\u{1F3D4}\u{FE0F}', name: 'snow-capped mountain', keywords: ['mountain'] }, + { char: '\u{1F3D6}\u{FE0F}', name: 'beach', keywords: ['beach', 'vacation'] }, + { char: '\u{1F30D}', name: 'globe europe-africa', keywords: ['world', 'earth'] }, + { char: '\u{1F30E}', name: 'globe americas', keywords: ['world', 'earth'] }, + { char: '\u{1F5FA}\u{FE0F}', name: 'world map', keywords: ['map', 'travel'] }, + { char: '\u{26F0}\u{FE0F}', name: 'mountain', keywords: ['mountain'] }, + { char: '\u{1F30B}', name: 'volcano', keywords: ['eruption'] }, + ], + }, + { + name: 'Objects', + emojis: [ + { char: '\u{1F4BB}', name: 'laptop', keywords: ['computer', 'code'] }, + { char: '\u{1F4F1}', name: 'mobile phone', keywords: ['phone', 'cell'] }, + { char: '\u{2328}\u{FE0F}', name: 'keyboard', keywords: ['type', 'computer'] }, + { char: '\u{1F4A1}', name: 'light bulb', keywords: ['idea', 'bright'] }, + { char: '\u{1F50D}', name: 'magnifying glass', keywords: ['search', 'find'] }, + { char: '\u{1F512}', name: 'locked', keywords: ['lock', 'security'] }, + { char: '\u{1F513}', name: 'unlocked', keywords: ['unlock', 'open'] }, + { char: '\u{1F527}', name: 'wrench', keywords: ['tool', 'fix'] }, + { char: '\u{1F528}', name: 'hammer', keywords: ['tool', 'build'] }, + { char: '\u{2699}\u{FE0F}', name: 'gear', keywords: ['settings', 'config'] }, + { char: '\u{1F4E6}', name: 'package', keywords: ['box', 'delivery'] }, + { char: '\u{1F4CB}', name: 'clipboard', keywords: ['paste', 'list'] }, + { char: '\u{1F4DD}', name: 'memo', keywords: ['note', 'write'] }, + { char: '\u{1F4DA}', name: 'books', keywords: ['library', 'read'] }, + { char: '\u{1F4D6}', name: 'open book', keywords: ['read', 'study'] }, + { char: '\u{2709}\u{FE0F}', name: 'envelope', keywords: ['email', 'mail'] }, + { char: '\u{1F4CE}', name: 'paperclip', keywords: ['attach'] }, + { char: '\u{270F}\u{FE0F}', name: 'pencil', keywords: ['write', 'edit'] }, + { char: '\u{1F4CC}', name: 'pushpin', keywords: ['pin', 'location'] }, + { char: '\u{1F5D1}\u{FE0F}', name: 'wastebasket', keywords: ['trash', 'delete'] }, + { char: '\u{1F3F7}\u{FE0F}', name: 'label', keywords: ['tag'] }, + { char: '\u{1F4CA}', name: 'bar chart', keywords: ['chart', 'graph', 'stats'] }, + { char: '\u{1F4C8}', name: 'chart increasing', keywords: ['growth', 'up'] }, + { char: '\u{1F4C9}', name: 'chart decreasing', keywords: ['decline', 'down'] }, + { char: '\u{23F0}', name: 'alarm clock', keywords: ['time', 'clock'] }, + ], + }, + { + name: 'Symbols', + emojis: [ + { char: '\u{2764}\u{FE0F}', name: 'red heart', keywords: ['love'] }, + { char: '\u{1F49B}', name: 'yellow heart', keywords: ['love'] }, + { char: '\u{1F49A}', name: 'green heart', keywords: ['love'] }, + { char: '\u{1F499}', name: 'blue heart', keywords: ['love'] }, + { char: '\u{1F49C}', name: 'purple heart', keywords: ['love'] }, + { char: '\u{2705}', name: 'check mark', keywords: ['done', 'complete', 'yes'] }, + { char: '\u{274C}', name: 'cross mark', keywords: ['no', 'wrong', 'reject'] }, + { char: '\u{2757}', name: 'exclamation mark', keywords: ['important', 'alert'] }, + { char: '\u{2753}', name: 'question mark', keywords: ['question', 'help'] }, + { char: '\u{1F4AF}', name: 'hundred points', keywords: ['perfect', '100'] }, + { char: '\u{267B}\u{FE0F}', name: 'recycling symbol', keywords: ['recycle', 'green'] }, + { char: '\u{26A0}\u{FE0F}', name: 'warning', keywords: ['caution', 'alert'] }, + { char: '\u{1F6AB}', name: 'prohibited', keywords: ['banned', 'forbidden'] }, + { char: '\u{2139}\u{FE0F}', name: 'information', keywords: ['info', 'help'] }, + { char: '\u{1F503}', name: 'clockwise arrows', keywords: ['refresh', 'reload'] }, + { char: '\u{1F504}', name: 'counterclockwise arrows', keywords: ['undo', 'back'] }, + { char: '\u{2B06}\u{FE0F}', name: 'up arrow', keywords: ['up'] }, + { char: '\u{2B07}\u{FE0F}', name: 'down arrow', keywords: ['down'] }, + { char: '\u{27A1}\u{FE0F}', name: 'right arrow', keywords: ['right', 'next'] }, + { char: '\u{2B05}\u{FE0F}', name: 'left arrow', keywords: ['left', 'back'] }, + ], + }, + { + name: 'Flags', + emojis: [ + { char: '\u{1F3C1}', name: 'chequered flag', keywords: ['finish', 'race'] }, + { char: '\u{1F6A9}', name: 'triangular flag', keywords: ['flag', 'alert'] }, + { char: '\u{1F3F4}', name: 'black flag', keywords: ['flag'] }, + { char: '\u{1F3F3}\u{FE0F}', name: 'white flag', keywords: ['surrender', 'peace'] }, + { char: '\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}', name: 'rainbow flag', keywords: ['pride', 'lgbtq'] }, + ], + }, +]; + +/** + * Search emojis across all categories by name or keyword. + * Returns matching EmojiEntry items, deduplicated by char. + */ +export function searchEmojis(query: string): EmojiEntry[] { + if (!query) return []; + + const lowerQuery = query.toLowerCase(); + const seen = new Set(); + const results: EmojiEntry[] = []; + + // Search quick emojis first + for (const emoji of QUICK_EMOJIS) { + if (matchesQuery(emoji, lowerQuery) && !seen.has(emoji.char)) { + seen.add(emoji.char); + results.push(emoji); + } + } + + // Then search all categories + for (const cat of EMOJI_CATEGORIES) { + for (const emoji of cat.emojis) { + if (matchesQuery(emoji, lowerQuery) && !seen.has(emoji.char)) { + seen.add(emoji.char); + results.push(emoji); + } + } + } + + return results; +} + +function matchesQuery(emoji: EmojiEntry, lowerQuery: string): boolean { + if (emoji.name.toLowerCase().includes(lowerQuery)) return true; + if (emoji.keywords) { + return emoji.keywords.some((kw) => kw.toLowerCase().includes(lowerQuery)); + } + return false; +} diff --git a/packages/core/src/comments/source-position-map.ts b/packages/core/src/comments/source-position-map.ts new file mode 100644 index 0000000..511c2c0 --- /dev/null +++ b/packages/core/src/comments/source-position-map.ts @@ -0,0 +1,460 @@ +/** + * Source Position Map for Comment Anchoring + * + * Strips markdown inline formatting from source while maintaining a parallel + * array mapping each plain-text character back to its source offset. This gives + * exact insertion points for comment footnote references without needing + * markdown-it's inline token positions. + */ + +export interface SourcePositionMap { + rawSource: string; + plainText: string; + offsets: number[]; + spans: FormattingSpan[]; +} + +export interface FormattingSpan { + sourceStart: number; + sourceEnd: number; + plainStart: number; + plainEnd: number; + type: 'bold' | 'italic' | 'strikethrough' | 'code' | 'link' | 'image' | 'escape'; +} + +export interface SelectionContext { + prefix: string; + suffix: string; +} + +const V1_SENTINEL = ''; +const V2_SENTINEL_PREFIX = '` at the end of the document. - * - * v1 uses `[^comment-N]` footnote references and `` - * separator with footnote definitions. - */ - -import type { Comment, CommentContext, CommentParseResult, CommentReply } from '../types'; -import { parseComments } from './comment-parser'; - -const V1_SENTINEL = ''; -const V2_SENTINEL_PREFIX = '/; - -/** - * v2 annotation JSON shape (as stored in the markdown file). - */ -interface Annotation { - id: number; - anchor: { - text: string; - prefix?: string; - suffix?: string; - }; - body: string; - author: string; - date: string; - resolved?: boolean; - tags?: string[]; - thread?: Array<{ - author: string; - body: string; - date: string; - }>; - reactions?: Record; - context?: { - line?: number; - section?: string; - sectionLevel?: number; - breadcrumb?: string[]; - }; -} - -/** - * Detect whether a markdown string uses v1, v2, or no comment format. - */ -export function detectFormat(markdown: string): 'v1' | 'v2' | 'none' { - const hasV1 = markdown.includes(V1_SENTINEL); - const hasV2 = markdown.includes(V2_SENTINEL_PREFIX); - - if (hasV1) return 'v1'; - if (hasV2) return 'v2'; - return 'none'; -} - -/** - * Parse annotations from markdown text. - * Detects format automatically: v1 is delegated to comment-parser, - * v2 is handled natively. - */ -export function parseAnnotations(markdown: string): CommentParseResult { - const format = detectFormat(markdown); - - if (format === 'v1') { - return parseComments(markdown); - } - - if (format === 'v2') { - return parseV2(markdown); - } - - return { cleanedMarkdown: markdown, comments: [] }; -} - -/** - * Alias for backward-compatible import as `parseComments`. - */ -export { parseAnnotations as parseComments }; - -/** - * Parse v2 annotation format. - */ -function parseV2(markdown: string): CommentParseResult { - // Find the annotation block - const blockMatch = markdown.match(V2_BLOCK_PATTERN); - if (!blockMatch) { - // Has sentinel prefix but malformed — strip markers and return empty - const cleanedMarkdown = stripV2Markers(stripV2Block(markdown)); - return { cleanedMarkdown, comments: [] }; - } - - // Parse JSON array - let annotations: Annotation[]; - try { - annotations = JSON.parse(blockMatch[1]) as Annotation[]; - } catch { - console.warn('[MDView] Failed to parse v2 annotation JSON'); - const cleanedMarkdown = stripV2Markers(stripV2Block(markdown)); - return { cleanedMarkdown, comments: [] }; - } - - // Build Comment objects - const comments = annotations.map(annotationToComment); - - // Remove the annotation block and [@N] markers from content - const cleanedMarkdown = stripV2Markers(stripV2Block(markdown)); - - return { cleanedMarkdown, comments }; -} - -/** - * Remove the `` block from the end of the document. - */ -function stripV2Block(markdown: string): string { - const idx = markdown.indexOf(V2_SENTINEL_PREFIX); - if (idx === -1) return markdown; - return markdown.slice(0, idx).trimEnd(); -} - -/** - * Remove all `[@N]` markers from content. - */ -function stripV2Markers(content: string): string { - return content.replace(V2_MARKER_PATTERN, ''); -} - -/** - * Convert a v2 annotation JSON object to an internal Comment. - */ -function annotationToComment(annotation: Annotation): Comment { - const comment: Comment = { - id: `comment-${annotation.id}`, - selectedText: annotation.anchor.text, - body: annotation.body, - author: annotation.author, - date: annotation.date, - resolved: annotation.resolved ?? false, - }; - - // Anchor context - if (annotation.anchor.prefix) { - comment.anchorPrefix = annotation.anchor.prefix; - } - if (annotation.anchor.suffix) { - comment.anchorSuffix = annotation.anchor.suffix; - } - - // Positional context - if (annotation.context && annotation.context.line !== undefined) { - const ctx: CommentContext = { - line: annotation.context.line, - section: annotation.context.section, - sectionLevel: annotation.context.sectionLevel, - breadcrumb: annotation.context.breadcrumb ?? [], - }; - comment.context = ctx; - } - - // Tags - if (annotation.tags && annotation.tags.length > 0) { - comment.tags = annotation.tags as Comment['tags']; - } - - // Thread → replies - if (annotation.thread && annotation.thread.length > 0) { - comment.replies = annotation.thread.map( - (t, i): CommentReply => ({ - id: `reply-${i + 1}`, - author: t.author, - body: t.body, - date: t.date, - }) - ); - } - - // Reactions - if (annotation.reactions && Object.keys(annotation.reactions).length > 0) { - comment.reactions = annotation.reactions; - } - - return comment; -} +export * from '../../packages/core/src/comments/annotation-parser'; diff --git a/src/comments/annotation-serializer.ts b/src/comments/annotation-serializer.ts index cbc563f..8159fb0 100644 --- a/src/comments/annotation-serializer.ts +++ b/src/comments/annotation-serializer.ts @@ -1,497 +1 @@ -/** - * Annotation Serializer (v2) - * - * Generates markdown with `[@N]` inline markers and a structured JSON - * annotation block in ``. - * - * Drop-in replacement for comment-serializer: same exported function - * signatures. On v1 input, automatically migrates to v2 format. - */ - -import type { Comment, CommentReply } from '../types'; -import type { SourcePositionMap, SelectionContext } from './source-position-map'; -import { findInsertionPoint } from './source-position-map'; -import { parseComments } from './comment-parser'; - -const V1_SENTINEL = ''; -const V2_SENTINEL_PREFIX = '/; -const V2_MARKER_PATTERN = /\[@(\d+)\]/g; - -// ─── v2 annotation JSON shape ──────────────────────────────────────── - -interface Annotation { - id: number; - anchor: { text: string; prefix?: string; suffix?: string }; - body: string; - author: string; - date: string; - resolved?: boolean; - tags?: string[]; - thread?: Array<{ author: string; body: string; date: string }>; - reactions?: Record; - context?: { - line?: number; - section?: string; - sectionLevel?: number; - breadcrumb?: string[]; - }; -} - -// ─── Internal helpers ──────────────────────────────────────────────── - -/** - * Split markdown into content (above annotation block) and annotations array. - */ -function splitAtAnnotationBlock( - markdown: string -): [string, Annotation[] | null] { - const blockMatch = markdown.match(V2_BLOCK_PATTERN); - if (!blockMatch) { - return [markdown, null]; - } - - const blockStart = markdown.indexOf(V2_SENTINEL_PREFIX); - const content = markdown.slice(0, blockStart); - - try { - const annotations = JSON.parse(blockMatch[1]) as Annotation[]; - return [content, annotations]; - } catch { - return [content, null]; - } -} - -/** - * Pretty-print the annotation block with 2-space indentation. - */ -function serializeAnnotationBlock(annotations: Annotation[]): string { - const json = JSON.stringify(annotations, null, 2); - return ``; -} - -/** - * Convert an internal Comment to a v2 annotation JSON object. - */ -function commentToAnnotation(comment: Comment): Annotation { - const numId = parseInt(comment.id.replace('comment-', ''), 10); - - const anchor: Annotation['anchor'] = { text: comment.selectedText }; - if (comment.anchorPrefix) anchor.prefix = comment.anchorPrefix; - if (comment.anchorSuffix) anchor.suffix = comment.anchorSuffix; - - const annotation: Annotation = { - id: numId, - anchor, - body: comment.body, - author: comment.author, - date: comment.date, - }; - - if (comment.resolved) { - annotation.resolved = true; - } - - if (comment.tags && comment.tags.length > 0) { - annotation.tags = comment.tags; - } - - if (comment.replies && comment.replies.length > 0) { - annotation.thread = comment.replies.map((r) => ({ - author: r.author, - body: r.body, - date: r.date, - })); - } - - if (comment.reactions && Object.keys(comment.reactions).length > 0) { - annotation.reactions = comment.reactions; - } - - if (comment.context) { - const ctx: Annotation['context'] = {}; - if (comment.context.line !== undefined) ctx.line = comment.context.line; - if (comment.context.section !== undefined) - ctx.section = comment.context.section; - if (comment.context.sectionLevel !== undefined) - ctx.sectionLevel = comment.context.sectionLevel; - if (comment.context.breadcrumb && comment.context.breadcrumb.length > 0) - ctx.breadcrumb = comment.context.breadcrumb; - if (Object.keys(ctx).length > 0) annotation.context = ctx; - } - - return annotation; -} - -/** - * Convert a v2 annotation to an internal Comment. - */ -function annotationToComment(annotation: Annotation): Comment { - const comment: Comment = { - id: `comment-${annotation.id}`, - selectedText: annotation.anchor.text, - body: annotation.body, - author: annotation.author, - date: annotation.date, - resolved: annotation.resolved ?? false, - }; - - if (annotation.anchor.prefix) comment.anchorPrefix = annotation.anchor.prefix; - if (annotation.anchor.suffix) comment.anchorSuffix = annotation.anchor.suffix; - - if (annotation.tags && annotation.tags.length > 0) { - comment.tags = annotation.tags as Comment['tags']; - } - - if (annotation.thread && annotation.thread.length > 0) { - comment.replies = annotation.thread.map( - (t, i): CommentReply => ({ - id: `reply-${i + 1}`, - author: t.author, - body: t.body, - date: t.date, - }) - ); - } - - if (annotation.reactions && Object.keys(annotation.reactions).length > 0) { - comment.reactions = annotation.reactions; - } - - if (annotation.context && annotation.context.line !== undefined) { - comment.context = { - line: annotation.context.line, - section: annotation.context.section, - sectionLevel: annotation.context.sectionLevel, - breadcrumb: annotation.context.breadcrumb ?? [], - }; - } - - return comment; -} - -/** - * Detect v1 sentinel and fully migrate to v2 format. - * Returns the markdown with all v1 content replaced by v2. - */ -function migrateV1Content(markdown: string): string { - const result = parseComments(markdown); - - // Start with cleaned content (v1 refs already stripped by parser) - let content = result.cleanedMarkdown; - - // Re-insert markers in v2 format - for (const comment of result.comments) { - const numId = parseInt(comment.id.replace('comment-', ''), 10); - const marker = `[@${numId}]`; - const idx = content.indexOf(comment.selectedText); - if (idx !== -1) { - const insertPos = idx + comment.selectedText.length; - content = content.slice(0, insertPos) + marker + content.slice(insertPos); - } - } - - // Build annotation block - const annotations = result.comments.map(commentToAnnotation); - const block = serializeAnnotationBlock(annotations); - - return content.trimEnd() + '\n\n' + block + '\n'; -} - -/** - * If markdown contains v1 sentinel, migrate it. Otherwise return as-is. - */ -function ensureV2(markdown: string): string { - if (markdown.includes(V1_SENTINEL)) { - return migrateV1Content(markdown); - } - return markdown; -} - -/** - * Insert a `[@N]` marker after the first unmatched occurrence of selectedText - * in the content section. - */ -function insertMarker( - contentSection: string, - selectedText: string, - numId: number -): string { - const marker = `[@${numId}]`; - const escapedText = selectedText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // Find first occurrence not already followed by [@N] - const pattern = new RegExp(`${escapedText}(?!\\[@\\d+\\])`); - const match = pattern.exec(contentSection); - if (match) { - const insertPos = match.index + match[0].length; - return ( - contentSection.slice(0, insertPos) + - marker + - contentSection.slice(insertPos) - ); - } - // Fallback: append to first occurrence - const idx = contentSection.indexOf(selectedText); - if (idx !== -1) { - const insertPos = idx + selectedText.length; - return ( - contentSection.slice(0, insertPos) + - marker + - contentSection.slice(insertPos) - ); - } - return contentSection; -} - -// ─── Exported API (same signatures as v1 serializer) ───────────────── - -/** - * Scan for `[@N]` markers and annotation `"id": N` to find the next ID. - */ -export function generateNextCommentId(markdown: string): string { - let max = 0; - - // Check [@N] markers - let match: RegExpExecArray | null; - const markerPattern = new RegExp(V2_MARKER_PATTERN.source, 'g'); - while ((match = markerPattern.exec(markdown)) !== null) { - const num = parseInt(match[1], 10); - if (num > max) max = num; - } - - // Check "id": N in annotation block - const idPattern = /"id":\s*(\d+)/g; - while ((match = idPattern.exec(markdown)) !== null) { - const num = parseInt(match[1], 10); - if (num > max) max = num; - } - - // Also check v1 patterns for migration scenarios - const v1Pattern = /\[\^comment-(\d+)\]/g; - while ((match = v1Pattern.exec(markdown)) !== null) { - const num = parseInt(match[1], 10); - if (num > max) max = num; - } - - return `comment-${max + 1}`; -} - -/** - * Add a comment: insert `[@N]` marker after selectedText, append to annotation block. - */ -export function addComment(markdown: string, comment: Comment): string { - // Migrate v1 if needed - const md = ensureV2(markdown); - - const [contentSection, existingAnnotations] = splitAtAnnotationBlock(md); - const numId = parseInt(comment.id.replace('comment-', ''), 10); - - // Insert marker in content - const updatedContent = insertMarker(contentSection, comment.selectedText, numId); - - // Build annotation - const annotation = commentToAnnotation(comment); - - const annotations = existingAnnotations ? [...existingAnnotations, annotation] : [annotation]; - const block = serializeAnnotationBlock(annotations); - - return updatedContent.trimEnd() + '\n\n' + block + '\n'; -} - -/** - * Add a comment using a pre-built source position map for accurate insertion. - * Falls back to `addComment()` if the map can't find the position. - */ -export function addCommentAtOffset( - markdown: string, - comment: Comment, - sourceMap: SourcePositionMap, - context?: SelectionContext -): string { - const offset = findInsertionPoint(sourceMap, comment.selectedText, context); - - if (offset === null) { - return addComment(markdown, comment); - } - - // Migrate v1 if needed - const md = ensureV2(markdown); - - const [contentSection, existingAnnotations] = splitAtAnnotationBlock(md); - const numId = parseInt(comment.id.replace('comment-', ''), 10); - const marker = `[@${numId}]`; - - // Insert marker at exact offset - const updatedContent = - contentSection.slice(0, offset) + marker + contentSection.slice(offset); - - const annotation = commentToAnnotation(comment); - const annotations = existingAnnotations ? [...existingAnnotations, annotation] : [annotation]; - const block = serializeAnnotationBlock(annotations); - - return updatedContent.trimEnd() + '\n\n' + block + '\n'; -} - -/** - * Remove a comment: remove `[@N]` marker and annotation entry. - * Removes block entirely if no annotations remain. - */ -export function removeComment(markdown: string, commentId: string): string { - const md = ensureV2(markdown); - - const [contentSection, annotations] = splitAtAnnotationBlock(md); - - if (!annotations) { - // No annotation block — just strip any markers - const numId = parseInt(commentId.replace('comment-', ''), 10); - return contentSection.split(`[@${numId}]`).join(''); - } - - const numId = parseInt(commentId.replace('comment-', ''), 10); - - // Remove inline marker - const cleanedContent = contentSection.split(`[@${numId}]`).join(''); - - // Remove annotation entry - const remaining = annotations.filter((a) => a.id !== numId); - - if (remaining.length === 0) { - return cleanedContent.trimEnd() + '\n'; - } - - const block = serializeAnnotationBlock(remaining); - return cleanedContent.trimEnd() + '\n\n' + block + '\n'; -} - -/** - * Update a comment's body in the annotation block. - */ -export function updateComment( - markdown: string, - commentId: string, - newBody: string -): string { - const md = ensureV2(markdown); - - const [contentSection, annotations] = splitAtAnnotationBlock(md); - if (!annotations) return markdown; - - const numId = parseInt(commentId.replace('comment-', ''), 10); - let found = false; - - const updated = annotations.map((a) => { - if (a.id === numId) { - found = true; - return { ...a, body: newBody }; - } - return a; - }); - - if (!found) return markdown; - - const block = serializeAnnotationBlock(updated); - return contentSection.trimEnd() + '\n\n' + block + '\n'; -} - -/** - * Resolve a comment: set `resolved: true`. - */ -export function resolveComment(markdown: string, commentId: string): string { - return updateCommentMetadata(markdown, commentId, (c) => { - c.resolved = true; - }); -} - -/** - * Generic metadata updater: parse annotation, call updater to mutate - * the Comment, then re-serialize. - */ -export function updateCommentMetadata( - markdown: string, - commentId: string, - updater: (comment: Comment) => void -): string { - const md = ensureV2(markdown); - - const [contentSection, annotations] = splitAtAnnotationBlock(md); - if (!annotations) return markdown; - - const numId = parseInt(commentId.replace('comment-', ''), 10); - let found = false; - - const updated = annotations.map((a) => { - if (a.id === numId) { - found = true; - const comment = annotationToComment(a); - updater(comment); - return commentToAnnotation(comment); - } - return a; - }); - - if (!found) return markdown; - - const block = serializeAnnotationBlock(updated); - return contentSection.trimEnd() + '\n\n' + block + '\n'; -} - -/** - * Add a reply to a comment's thread. - */ -export function addReply( - markdown: string, - commentId: string, - reply: Omit -): { markdown: string; replyId: string } { - let replyId = ''; - - const updated = updateCommentMetadata(markdown, commentId, (comment) => { - const replies = comment.replies ?? []; - let maxNum = 0; - for (const r of replies) { - const match = /^reply-(\d+)$/.exec(r.id); - if (match) { - const num = parseInt(match[1], 10); - if (num > maxNum) maxNum = num; - } - } - replyId = `reply-${maxNum + 1}`; - replies.push({ id: replyId, ...reply }); - comment.replies = replies; - }); - - return { markdown: updated, replyId }; -} - -/** - * Toggle an emoji reaction for an author on a comment. - */ -export function toggleReaction( - markdown: string, - commentId: string, - emoji: string, - author: string -): string { - return updateCommentMetadata(markdown, commentId, (comment) => { - const reactions = comment.reactions ?? {}; - const authors = reactions[emoji] ?? []; - - const idx = authors.indexOf(author); - if (idx >= 0) { - authors.splice(idx, 1); - if (authors.length === 0) { - delete reactions[emoji]; - } else { - reactions[emoji] = authors; - } - } else { - reactions[emoji] = [...authors, author]; - } - - if (Object.keys(reactions).length > 0) { - comment.reactions = reactions; - } else { - delete comment.reactions; - } - }); -} +export * from '../../packages/core/src/comments/annotation-serializer'; diff --git a/src/comments/comment-context.ts b/src/comments/comment-context.ts index 901fd8e..295e89e 100644 --- a/src/comments/comment-context.ts +++ b/src/comments/comment-context.ts @@ -1,107 +1 @@ -/** - * Comment Context Builder - * - * Computes positional context (line number, section heading, breadcrumb) - * for a comment at a given character offset in markdown content. This - * metadata is stored in the footnote definition so AI agents reading - * the raw file can immediately understand where each comment is anchored. - */ - -import type { CommentContext } from '../types'; -import { splitIntoSections, type MarkdownSection } from '../utils/section-splitter'; - -/** - * Compute positional context for a comment at a given character offset - * in the content section of markdown (above the comment separator). - */ -export function computeCommentContext( - contentMarkdown: string, - charOffset: number -): CommentContext { - const line = offsetToLine(contentMarkdown, charOffset); - const sections = splitIntoSections(contentMarkdown); - const { section, sectionLevel, breadcrumb } = findSectionContext(sections, line); - - return { - line, - section, - sectionLevel, - breadcrumb, - }; -} - -/** - * Convert a 0-based character offset to a 1-based line number. - */ -function offsetToLine(text: string, offset: number): number { - const clamped = Math.min(offset, text.length); - let line = 1; - for (let i = 0; i < clamped; i++) { - if (text[i] === '\n') { - line++; - } - } - return line; -} - -/** - * Given sections from splitIntoSections() and a 1-based line number, - * find the containing section and build the heading breadcrumb. - */ -function findSectionContext( - sections: MarkdownSection[], - line: number -): { section?: string; sectionLevel?: number; breadcrumb: string[] } { - // splitIntoSections uses 0-based line indices; our line is 1-based - const zeroLine = line - 1; - - let containingSection: MarkdownSection | undefined; - for (const s of sections) { - if (zeroLine >= s.startLine && zeroLine <= s.endLine) { - containingSection = s; - break; - } - } - - if (!containingSection || !containingSection.heading) { - return { breadcrumb: [] }; - } - - const breadcrumb = buildBreadcrumb(sections, containingSection); - - return { - section: containingSection.heading, - sectionLevel: containingSection.level, - breadcrumb, - }; -} - -/** - * Build a heading breadcrumb from the document start to the target section. - * - * Walk all sections up to and including the target. Maintain a stack: - * when encountering a heading at level N, pop all headings at level >= N, - * then push. The final stack is the breadcrumb. - */ -function buildBreadcrumb( - sections: MarkdownSection[], - target: MarkdownSection -): string[] { - const stack: Array<{ heading: string; level: number }> = []; - - for (const s of sections) { - if (s.heading && s.level !== undefined) { - // Pop headings at same or deeper level - while (stack.length > 0 && stack[stack.length - 1].level >= s.level) { - stack.pop(); - } - stack.push({ heading: s.heading, level: s.level }); - } - - if (s === target) { - break; - } - } - - return stack.map((entry) => entry.heading); -} +export * from '../../packages/core/src/comments/comment-context'; diff --git a/src/comments/comment-parser.ts b/src/comments/comment-parser.ts index e6c2b95..f379fa6 100644 --- a/src/comments/comment-parser.ts +++ b/src/comments/comment-parser.ts @@ -1,164 +1 @@ -/** - * Comment parser for extracting structured comments from markdown footnotes. - * - * Comments are stored as footnotes with `[^comment-*]` references in the markdown - * content, with metadata embedded in HTML comments following the `` - * pattern. A `` separator divides the document content from - * the comment section. - * - * @deprecated Use `annotation-parser.ts` instead. This module is kept only for - * v1 backward compatibility delegation from the annotation parser. - */ - -import type { Comment, CommentContext, CommentMetadata, CommentParseResult } from '../types'; - -const COMMENT_SEPARATOR = ''; -const COMMENT_REF_PATTERN = /\[\^comment-(\w+)\]/g; -const FOOTNOTE_DEF_PATTERN = /^\[\^comment-(\w+)\]:\s*\s*$/; -const BODY_CONTINUATION_PATTERN = /^ (.*)$/; - -/** - * Parse comments from markdown text. - * - * Splits markdown at the `` separator, parses footnote - * definitions from the comments section, extracts the selected text from the - * content section, and returns cleaned markdown with comment references removed. - */ -export function parseComments(markdown: string): CommentParseResult { - const separatorIndex = markdown.indexOf(COMMENT_SEPARATOR); - - if (separatorIndex === -1) { - return { cleanedMarkdown: markdown, comments: [] }; - } - - const contentPortion = markdown.substring(0, separatorIndex); - const commentsPortion = markdown.substring(separatorIndex + COMMENT_SEPARATOR.length); - - const comments = parseCommentFootnotes(commentsPortion, contentPortion); - const cleanedMarkdown = removeCommentReferences(contentPortion.trimEnd()); - - return { cleanedMarkdown, comments }; -} - -/** - * Parse comment footnote definitions from the comments section of the markdown. - */ -function parseCommentFootnotes(commentSection: string, contentSection: string): Comment[] { - const lines = commentSection.split('\n'); - const comments: Comment[] = []; - - let currentId: string | null = null; - let currentMetadata: CommentMetadata | null = null; - let currentBodyLines: string[] = []; - - for (const line of lines) { - // Try to match a footnote definition line - const defMatch = line.match(FOOTNOTE_DEF_PATTERN); - - if (defMatch) { - // Flush previous comment if any - if (currentId !== null && currentMetadata !== null) { - comments.push(buildComment(currentId, currentMetadata, currentBodyLines, contentSection)); - } - - currentId = defMatch[1]; - currentBodyLines = []; - - // Parse the JSON metadata - try { - currentMetadata = JSON.parse(defMatch[2]) as CommentMetadata; - } catch { - // Malformed JSON - skip this comment - currentId = null; - currentMetadata = null; - currentBodyLines = []; - } - continue; - } - - // Try to match a continuation line (indented with 4 spaces) - if (currentId !== null && currentMetadata !== null) { - const bodyMatch = line.match(BODY_CONTINUATION_PATTERN); - if (bodyMatch) { - currentBodyLines.push(bodyMatch[1]); - } - } - } - - // Flush the last comment - if (currentId !== null && currentMetadata !== null) { - comments.push(buildComment(currentId, currentMetadata, currentBodyLines, contentSection)); - } - - return comments; -} - -/** - * Build a Comment object from parsed footnote data. - */ -function buildComment( - id: string, - metadata: CommentMetadata, - bodyLines: string[], - contentSection: string, -): Comment { - const comment: Comment = { - id: `comment-${id}`, - selectedText: metadata.selectedText || extractSelectedText(id, contentSection), - body: bodyLines.join('\n'), - author: metadata.author, - date: metadata.date, - resolved: metadata.resolved ?? false, - }; - - // Reconstruct positional context if fields are present in metadata - if (metadata.line !== undefined) { - const context: CommentContext = { - line: metadata.line, - section: metadata.section, - sectionLevel: metadata.sectionLevel, - breadcrumb: metadata.breadcrumb ?? [], - }; - comment.context = context; - } - - // Reconstruct tags if present and non-empty - if (metadata.tags !== undefined && metadata.tags.length > 0) { - comment.tags = metadata.tags; - } - - // Reconstruct replies if present and non-empty - if (metadata.replies !== undefined && metadata.replies.length > 0) { - comment.replies = metadata.replies; - } - - // Reconstruct reactions if present and non-empty - if (metadata.reactions !== undefined && Object.keys(metadata.reactions).length > 0) { - comment.reactions = metadata.reactions; - } - - return comment; -} - -/** - * Extract the word immediately before a `[^comment-N]` reference in the content. - */ -function extractSelectedText(id: string, content: string): string { - const refPattern = new RegExp(`(\\S+)\\[\\^comment-${escapeRegExp(id)}\\]`); - const match = content.match(refPattern); - return match ? match[1] : ''; -} - -/** - * Remove all `[^comment-*]` references from the content section. - */ -function removeCommentReferences(content: string): string { - return content.replace(COMMENT_REF_PATTERN, ''); -} - -/** - * Escape special regex characters in a string. - */ -function escapeRegExp(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} +export * from '../../packages/core/src/comments/comment-parser'; diff --git a/src/comments/comment-serializer.ts b/src/comments/comment-serializer.ts index 96a6a54..9c262f2 100644 --- a/src/comments/comment-serializer.ts +++ b/src/comments/comment-serializer.ts @@ -1,509 +1 @@ -/** - * Comment Serializer (v1) - * - * Generates markdown with footnote references injected and footnote bodies appended. - * Handles add, update, delete, and resolve operations on raw markdown strings. - * - * Markdown format: - * Some highlighted text[^comment-1] in context. - * - * - * [^comment-1]: - * This API endpoint needs error handling - * for the 404 case. - * - * @deprecated Use `annotation-serializer.ts` instead. This module is no longer - * imported by production code. - */ - -import type { Comment, CommentMetadata, CommentReply } from '../types'; -import type { SourcePositionMap, SelectionContext } from './source-position-map'; -import { findInsertionPoint } from './source-position-map'; - -const COMMENT_SEPARATOR = ''; -const COMMENT_ID_PATTERN = /\[\^comment-(\d+)\]/g; -const FOOTNOTE_DEF_PATTERN = - /^\[\^(comment-\d+)\]: /; - -/** - * Scan the markdown for `[^comment-N]` patterns and return `comment-(max+1)`. - */ -export function generateNextCommentId(markdown: string): string { - let max = 0; - let match: RegExpExecArray | null; - const pattern = new RegExp(COMMENT_ID_PATTERN.source, 'g'); - while ((match = pattern.exec(markdown)) !== null) { - const num = parseInt(match[1], 10); - if (num > max) { - max = num; - } - } - return `comment-${max + 1}`; -} - -/** - * Build the metadata JSON string for a comment footnote. - * Omits the `resolved` key when it is false/undefined to keep the format clean. - */ -function buildMetadataJson(meta: CommentMetadata): string { - const obj: Record = { - author: meta.author, - date: meta.date, - }; - if (meta.resolved) { - obj.resolved = true; - } - if (meta.selectedText) { - obj.selectedText = meta.selectedText; - } - // Positional context fields - if (meta.line !== undefined) { - obj.line = meta.line; - } - if (meta.section !== undefined) { - obj.section = meta.section; - } - if (meta.sectionLevel !== undefined) { - obj.sectionLevel = meta.sectionLevel; - } - if (meta.breadcrumb !== undefined && meta.breadcrumb.length > 0) { - obj.breadcrumb = meta.breadcrumb; - } - if (meta.tags !== undefined && meta.tags.length > 0) { - obj.tags = meta.tags; - } - if (meta.replies !== undefined && meta.replies.length > 0) { - obj.replies = meta.replies; - } - if (meta.reactions !== undefined && Object.keys(meta.reactions).length > 0) { - obj.reactions = meta.reactions; - } - return JSON.stringify(obj); -} - -/** - * Format the footnote body lines, each indented with 4 spaces. - */ -function formatFootnoteBody(body: string): string { - return body - .split('\n') - .map((line) => ` ${line}`) - .join('\n'); -} - -/** - * Build a complete footnote definition block. - */ -function buildFootnoteBlock(comment: Comment): string { - const meta: CommentMetadata = { - author: comment.author, - date: comment.date, - }; - if (comment.resolved) { - meta.resolved = true; - } - if (comment.selectedText) { - meta.selectedText = comment.selectedText; - } - if (comment.context) { - meta.line = comment.context.line; - meta.section = comment.context.section; - meta.sectionLevel = comment.context.sectionLevel; - meta.breadcrumb = comment.context.breadcrumb; - } - if (comment.tags && comment.tags.length > 0) { - meta.tags = comment.tags; - } - if (comment.replies && comment.replies.length > 0) { - meta.replies = comment.replies; - } - if (comment.reactions && Object.keys(comment.reactions).length > 0) { - meta.reactions = comment.reactions; - } - const metaJson = buildMetadataJson(meta); - const header = `[^${comment.id}]: `; - const body = formatFootnoteBody(comment.body); - return `${header}\n${body}`; -} - -/** - * Find the selectedText in the content portion of the markdown (above the - * comments separator) and insert the footnote reference after the first - * unmatched occurrence. - * - * "Unmatched" means the occurrence is not already immediately followed by - * a `[^comment-N]` reference. - */ -function insertReference( - contentSection: string, - selectedText: string, - commentId: string -): string { - const ref = `[^${commentId}]`; - // We need to find the first occurrence of selectedText that is NOT - // already followed by [^comment-...] - const escapedText = selectedText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp( - `${escapedText}(?!\\[\\^comment-\\d+\\])`, - ); - const match = pattern.exec(contentSection); - if (match) { - const insertPos = match.index + match[0].length; - return ( - contentSection.slice(0, insertPos) + - ref + - contentSection.slice(insertPos) - ); - } - // Fallback: if no unmatched occurrence, just append to first occurrence - const idx = contentSection.indexOf(selectedText); - if (idx !== -1) { - const insertPos = idx + selectedText.length; - return ( - contentSection.slice(0, insertPos) + - ref + - contentSection.slice(insertPos) - ); - } - return contentSection; -} - -/** - * Split markdown into content (above separator) and comments section (below). - * Returns [contentSection, commentsSection | null]. - */ -function splitAtSeparator(markdown: string): [string, string | null] { - const sepIdx = markdown.indexOf(COMMENT_SEPARATOR); - if (sepIdx === -1) { - return [markdown, null]; - } - const content = markdown.slice(0, sepIdx); - const comments = markdown.slice(sepIdx + COMMENT_SEPARATOR.length); - return [content, comments]; -} - -/** - * Add a comment: insert reference after selectedText, append footnote body - * after separator. - */ -export function addComment(markdown: string, comment: Comment): string { - const [contentSection, existingComments] = splitAtSeparator(markdown); - - // Insert reference in content - const updatedContent = insertReference( - contentSection, - comment.selectedText, - comment.id - ); - - // Build footnote block - const footnoteBlock = buildFootnoteBlock(comment); - - if (existingComments !== null) { - // Separator already exists; append the new footnote - const trimmedComments = existingComments.trimEnd(); - return ( - updatedContent + - COMMENT_SEPARATOR + - (trimmedComments ? trimmedComments + '\n\n' : '\n') + - footnoteBlock + '\n' - ); - } else { - // No separator yet; add it - const trimmedContent = updatedContent.trimEnd(); - return ( - trimmedContent + - '\n\n' + - COMMENT_SEPARATOR + - '\n' + - footnoteBlock + '\n' - ); - } -} - -/** - * Add a comment using a pre-built source position map for accurate insertion. - * Falls back to `addComment()` (text-search based) if the map can't find the position. - */ -export function addCommentAtOffset( - markdown: string, - comment: Comment, - sourceMap: SourcePositionMap, - context?: SelectionContext -): string { - const offset = findInsertionPoint(sourceMap, comment.selectedText, context); - - if (offset === null) { - // Fallback to text-search based insertion - return addComment(markdown, comment); - } - - const [contentSection, existingComments] = splitAtSeparator(markdown); - - // Insert the reference at the exact offset in the content section - const ref = `[^${comment.id}]`; - const updatedContent = - contentSection.slice(0, offset) + ref + contentSection.slice(offset); - - // Build footnote block - const footnoteBlock = buildFootnoteBlock(comment); - - if (existingComments !== null) { - const trimmedComments = existingComments.trimEnd(); - return ( - updatedContent + - COMMENT_SEPARATOR + - (trimmedComments ? trimmedComments + '\n\n' : '\n') + - footnoteBlock + '\n' - ); - } else { - const trimmedContent = updatedContent.trimEnd(); - return ( - trimmedContent + - '\n\n' + - COMMENT_SEPARATOR + - '\n' + - footnoteBlock + '\n' - ); - } -} - -/** - * Parse the comments section into individual footnote blocks. - * Each block is: { id, headerLine, bodyLines[] }. - */ -interface FootnoteBlock { - id: string; - headerLine: string; - bodyLines: string[]; -} - -function parseFootnoteBlocks(commentsSection: string): FootnoteBlock[] { - const lines = commentsSection.split('\n'); - const blocks: FootnoteBlock[] = []; - let current: FootnoteBlock | null = null; - - for (const line of lines) { - const defMatch = FOOTNOTE_DEF_PATTERN.exec(line); - if (defMatch) { - if (current) { - blocks.push(current); - } - current = { - id: defMatch[1], - headerLine: line, - bodyLines: [], - }; - } else if (current && line.startsWith(' ')) { - current.bodyLines.push(line); - } else if (current && line.trim() === '') { - // Blank line between blocks -- finalize current - blocks.push(current); - current = null; - } - } - if (current) { - blocks.push(current); - } - return blocks; -} - -/** - * Serialize footnote blocks back to a comments section string. - */ -function serializeFootnoteBlocks(blocks: FootnoteBlock[]): string { - return blocks - .map((b) => [b.headerLine, ...b.bodyLines].join('\n')) - .join('\n\n'); -} - -/** - * Remove a comment: remove the inline reference and the footnote body. - * Remove the separator if no comments remain. - */ -export function removeComment(markdown: string, commentId: string): string { - const [contentSection, commentsSection] = splitAtSeparator(markdown); - - // Remove inline reference [^commentId] - const ref = `[^${commentId}]`; - const cleanedContent = contentSection.split(ref).join(''); - - if (commentsSection === null) { - return cleanedContent; - } - - // Parse and filter footnote blocks - const blocks = parseFootnoteBlocks(commentsSection); - const remaining = blocks.filter((b) => b.id !== commentId); - - if (remaining.length === 0) { - // No comments remain; remove separator and trailing whitespace - return cleanedContent.trimEnd() + '\n'; - } - - // Rebuild with separator and remaining blocks - const serialized = serializeFootnoteBlocks(remaining); - return ( - cleanedContent + - COMMENT_SEPARATOR + - '\n' + - serialized + '\n' - ); -} - -/** - * Update a comment's body: replace the body lines under the matching footnote - * while keeping the metadata header unchanged. - */ -export function updateComment( - markdown: string, - commentId: string, - newBody: string -): string { - const [contentSection, commentsSection] = splitAtSeparator(markdown); - - if (commentsSection === null) { - return markdown; - } - - const blocks = parseFootnoteBlocks(commentsSection); - const updatedBlocks = blocks.map((b) => { - if (b.id === commentId) { - return { - ...b, - bodyLines: newBody.split('\n').map((line) => ` ${line}`), - }; - } - return b; - }); - - const serialized = serializeFootnoteBlocks(updatedBlocks); - return ( - contentSection + - COMMENT_SEPARATOR + - '\n' + - serialized + '\n' - ); -} - -/** - * Resolve a comment: parse the metadata JSON, set resolved:true, re-serialize. - */ -export function resolveComment(markdown: string, commentId: string): string { - return updateCommentMetadata(markdown, commentId, (meta) => { - meta.resolved = true; - }); -} - -/** - * Generic metadata updater: parse a comment's metadata JSON, call the updater - * function to mutate it, then rebuild the header line. Preserves body and - * other comments. - */ -export function updateCommentMetadata( - markdown: string, - commentId: string, - updater: (meta: CommentMetadata) => void -): string { - const [contentSection, commentsSection] = splitAtSeparator(markdown); - - if (commentsSection === null) { - return markdown; - } - - const blocks = parseFootnoteBlocks(commentsSection); - let found = false; - - const updatedBlocks = blocks.map((b) => { - if (b.id === commentId) { - const defMatch = FOOTNOTE_DEF_PATTERN.exec(b.headerLine); - if (defMatch) { - found = true; - const meta: CommentMetadata = JSON.parse(defMatch[2]); - updater(meta); - const metaJson = buildMetadataJson(meta); - const newHeader = `[^${b.id}]: `; - return { ...b, headerLine: newHeader }; - } - } - return b; - }); - - if (!found) { - return markdown; - } - - const serialized = serializeFootnoteBlocks(updatedBlocks); - return ( - contentSection + - COMMENT_SEPARATOR + - '\n' + - serialized + '\n' - ); -} - -/** - * Add a reply to a comment. Generates a sequential reply ID scoped to the - * parent comment. Returns the updated markdown and the generated reply ID. - */ -export function addReply( - markdown: string, - commentId: string, - reply: Omit -): { markdown: string; replyId: string } { - let replyId = ''; - - const updated = updateCommentMetadata(markdown, commentId, (meta) => { - const replies = meta.replies ?? []; - // Generate sequential ID: reply-1, reply-2, ... - let maxNum = 0; - for (const r of replies) { - const match = /^reply-(\d+)$/.exec(r.id); - if (match) { - const num = parseInt(match[1], 10); - if (num > maxNum) maxNum = num; - } - } - replyId = `reply-${maxNum + 1}`; - replies.push({ id: replyId, ...reply }); - meta.replies = replies; - }); - - return { markdown: updated, replyId }; -} - -/** - * Toggle an emoji reaction for an author on a comment. - * Adds the author if not present; removes if already present. - * Removes the emoji key entirely when no authors remain. - */ -export function toggleReaction( - markdown: string, - commentId: string, - emoji: string, - author: string -): string { - return updateCommentMetadata(markdown, commentId, (meta) => { - const reactions = meta.reactions ?? {}; - const authors = reactions[emoji] ?? []; - - const idx = authors.indexOf(author); - if (idx >= 0) { - authors.splice(idx, 1); - if (authors.length === 0) { - delete reactions[emoji]; - } else { - reactions[emoji] = authors; - } - } else { - reactions[emoji] = [...authors, author]; - } - - // Only set reactions if non-empty - if (Object.keys(reactions).length > 0) { - meta.reactions = reactions; - } else { - delete meta.reactions; - } - }); -} +export * from '../../packages/core/src/comments/comment-serializer'; diff --git a/src/comments/emoji-data.ts b/src/comments/emoji-data.ts index 8699757..b3e14d6 100644 --- a/src/comments/emoji-data.ts +++ b/src/comments/emoji-data.ts @@ -1,277 +1 @@ -/** - * Static emoji data for the comment reaction picker. - * Native emoji rendering — no npm dependencies. - */ - -export interface EmojiEntry { - char: string; - name: string; - keywords?: string[]; -} - -export interface EmojiCategory { - name: string; - emojis: EmojiEntry[]; -} - -/** - * 12 quick-access emojis shown in the top row of the picker. - */ -export const QUICK_EMOJIS: EmojiEntry[] = [ - { char: '\u{1F44D}', name: 'thumbs up', keywords: ['like', 'approve', 'yes'] }, - { char: '\u{1F44E}', name: 'thumbs down', keywords: ['dislike', 'disapprove', 'no'] }, - { char: '\u{1F604}', name: 'grinning face', keywords: ['happy', 'smile', 'laugh'] }, - { char: '\u{1F389}', name: 'party popper', keywords: ['celebrate', 'tada', 'hooray'] }, - { char: '\u{1F615}', name: 'confused face', keywords: ['confused', 'unsure'] }, - { char: '\u{2764}\u{FE0F}', name: 'red heart', keywords: ['love', 'heart'] }, - { char: '\u{1F680}', name: 'rocket', keywords: ['launch', 'ship', 'fast'] }, - { char: '\u{1F440}', name: 'eyes', keywords: ['look', 'see', 'watching'] }, - { char: '\u{2705}', name: 'check mark', keywords: ['done', 'complete', 'yes', 'approve'] }, - { char: '\u{274C}', name: 'cross mark', keywords: ['no', 'wrong', 'reject'] }, - { char: '\u{1F525}', name: 'fire', keywords: ['hot', 'lit', 'amazing'] }, - { char: '\u{2B50}', name: 'star', keywords: ['favorite', 'excellent', 'great'] }, -]; - -/** - * 8 emoji categories with ~200 emojis total. - */ -export const EMOJI_CATEGORIES: EmojiCategory[] = [ - { - name: 'Smileys & People', - emojis: [ - { char: '\u{1F600}', name: 'grinning face', keywords: ['happy', 'smile'] }, - { char: '\u{1F604}', name: 'grinning face with smiling eyes', keywords: ['happy', 'smile'] }, - { char: '\u{1F601}', name: 'beaming face', keywords: ['happy', 'grin'] }, - { char: '\u{1F606}', name: 'squinting face', keywords: ['laugh', 'happy'] }, - { char: '\u{1F605}', name: 'grinning face with sweat', keywords: ['relief'] }, - { char: '\u{1F602}', name: 'face with tears of joy', keywords: ['laugh', 'crying'] }, - { char: '\u{1F923}', name: 'rolling on the floor laughing', keywords: ['laugh', 'rofl'] }, - { char: '\u{1F60A}', name: 'smiling face with smiling eyes', keywords: ['blush', 'happy'] }, - { char: '\u{1F607}', name: 'smiling face with halo', keywords: ['angel', 'innocent'] }, - { char: '\u{1F609}', name: 'winking face', keywords: ['wink'] }, - { char: '\u{1F60C}', name: 'relieved face', keywords: ['calm', 'peaceful'] }, - { char: '\u{1F60D}', name: 'smiling face with heart-eyes', keywords: ['love', 'crush'] }, - { char: '\u{1F618}', name: 'face blowing a kiss', keywords: ['kiss', 'love'] }, - { char: '\u{1F914}', name: 'thinking face', keywords: ['think', 'hmm'] }, - { char: '\u{1F928}', name: 'face with raised eyebrow', keywords: ['skeptical', 'doubt'] }, - { char: '\u{1F610}', name: 'neutral face', keywords: ['meh', 'blank'] }, - { char: '\u{1F611}', name: 'expressionless face', keywords: ['blank'] }, - { char: '\u{1F636}', name: 'face without mouth', keywords: ['speechless', 'silence'] }, - { char: '\u{1F644}', name: 'face with rolling eyes', keywords: ['eyeroll'] }, - { char: '\u{1F615}', name: 'confused face', keywords: ['confused', 'unsure'] }, - { char: '\u{1F61F}', name: 'worried face', keywords: ['worried', 'nervous'] }, - { char: '\u{1F622}', name: 'crying face', keywords: ['sad', 'cry'] }, - { char: '\u{1F62D}', name: 'loudly crying face', keywords: ['sob', 'cry'] }, - { char: '\u{1F621}', name: 'pouting face', keywords: ['angry', 'rage'] }, - { char: '\u{1F631}', name: 'face screaming in fear', keywords: ['scream', 'horror'] }, - { char: '\u{1F4AA}', name: 'flexed biceps', keywords: ['strong', 'muscle'] }, - { char: '\u{1F44F}', name: 'clapping hands', keywords: ['clap', 'applause'] }, - { char: '\u{1F64F}', name: 'folded hands', keywords: ['pray', 'please', 'thanks'] }, - { char: '\u{1F91D}', name: 'handshake', keywords: ['agree', 'deal'] }, - { char: '\u{270C}\u{FE0F}', name: 'victory hand', keywords: ['peace'] }, - ], - }, - { - name: 'Nature', - emojis: [ - { char: '\u{1F436}', name: 'dog face', keywords: ['puppy', 'pet'] }, - { char: '\u{1F431}', name: 'cat face', keywords: ['kitty', 'pet'] }, - { char: '\u{1F42D}', name: 'mouse face', keywords: ['mouse'] }, - { char: '\u{1F43B}', name: 'bear', keywords: ['bear'] }, - { char: '\u{1F98A}', name: 'fox', keywords: ['fox'] }, - { char: '\u{1F427}', name: 'penguin', keywords: ['penguin', 'linux'] }, - { char: '\u{1F41B}', name: 'bug', keywords: ['bug', 'insect'] }, - { char: '\u{1F40D}', name: 'snake', keywords: ['snake', 'python'] }, - { char: '\u{1F422}', name: 'turtle', keywords: ['slow', 'turtle'] }, - { char: '\u{1F419}', name: 'octopus', keywords: ['octopus'] }, - { char: '\u{1F332}', name: 'evergreen tree', keywords: ['tree', 'nature'] }, - { char: '\u{1F33B}', name: 'sunflower', keywords: ['flower'] }, - { char: '\u{1F340}', name: 'four leaf clover', keywords: ['luck', 'clover'] }, - { char: '\u{1F335}', name: 'cactus', keywords: ['desert', 'plant'] }, - { char: '\u{1F30A}', name: 'water wave', keywords: ['ocean', 'wave'] }, - { char: '\u{2600}\u{FE0F}', name: 'sun', keywords: ['sunny', 'weather'] }, - { char: '\u{1F319}', name: 'crescent moon', keywords: ['moon', 'night'] }, - { char: '\u{2B50}', name: 'star', keywords: ['favorite', 'excellent'] }, - { char: '\u{26A1}', name: 'lightning', keywords: ['electric', 'zap', 'thunder'] }, - { char: '\u{1F525}', name: 'fire', keywords: ['hot', 'lit', 'flame'] }, - { char: '\u{1F4A7}', name: 'droplet', keywords: ['water', 'drop'] }, - { char: '\u{2744}\u{FE0F}', name: 'snowflake', keywords: ['cold', 'winter'] }, - { char: '\u{1F308}', name: 'rainbow', keywords: ['rainbow'] }, - ], - }, - { - name: 'Food & Drink', - emojis: [ - { char: '\u{1F34E}', name: 'red apple', keywords: ['apple', 'fruit'] }, - { char: '\u{1F34A}', name: 'tangerine', keywords: ['orange', 'fruit'] }, - { char: '\u{1F34B}', name: 'lemon', keywords: ['citrus'] }, - { char: '\u{1F34C}', name: 'banana', keywords: ['fruit'] }, - { char: '\u{1F353}', name: 'strawberry', keywords: ['fruit', 'berry'] }, - { char: '\u{1F349}', name: 'watermelon', keywords: ['fruit', 'summer'] }, - { char: '\u{1F355}', name: 'pizza', keywords: ['food'] }, - { char: '\u{1F354}', name: 'hamburger', keywords: ['burger', 'food'] }, - { char: '\u{1F37F}', name: 'popcorn', keywords: ['movie', 'snack'] }, - { char: '\u{2615}', name: 'hot beverage', keywords: ['coffee', 'tea'] }, - { char: '\u{1F37A}', name: 'beer mug', keywords: ['beer', 'drink'] }, - { char: '\u{1F377}', name: 'wine glass', keywords: ['wine', 'drink'] }, - { char: '\u{1F375}', name: 'teacup', keywords: ['tea', 'drink'] }, - { char: '\u{1F382}', name: 'birthday cake', keywords: ['cake', 'celebration'] }, - { char: '\u{1F369}', name: 'donut', keywords: ['doughnut', 'sweet'] }, - { char: '\u{1F36B}', name: 'chocolate bar', keywords: ['chocolate', 'sweet'] }, - { char: '\u{1F950}', name: 'croissant', keywords: ['bread', 'pastry'] }, - { char: '\u{1F96A}', name: 'sandwich', keywords: ['food'] }, - { char: '\u{1F32E}', name: 'taco', keywords: ['mexican', 'food'] }, - { char: '\u{1F363}', name: 'sushi', keywords: ['japanese', 'food'] }, - ], - }, - { - name: 'Activities', - emojis: [ - { char: '\u{26BD}', name: 'soccer ball', keywords: ['football', 'sport'] }, - { char: '\u{1F3C0}', name: 'basketball', keywords: ['sport'] }, - { char: '\u{1F3C8}', name: 'american football', keywords: ['sport'] }, - { char: '\u{26BE}', name: 'baseball', keywords: ['sport'] }, - { char: '\u{1F3BE}', name: 'tennis', keywords: ['sport'] }, - { char: '\u{1F3AF}', name: 'bullseye', keywords: ['target', 'dart'] }, - { char: '\u{1F3AE}', name: 'video game', keywords: ['game', 'controller'] }, - { char: '\u{265F}\u{FE0F}', name: 'chess pawn', keywords: ['chess', 'game', 'strategy'] }, - { char: '\u{1F3B2}', name: 'game die', keywords: ['dice', 'game', 'random'] }, - { char: '\u{1F3B5}', name: 'musical note', keywords: ['music', 'note'] }, - { char: '\u{1F3B6}', name: 'musical notes', keywords: ['music'] }, - { char: '\u{1F3A4}', name: 'microphone', keywords: ['sing', 'karaoke'] }, - { char: '\u{1F3AC}', name: 'clapper board', keywords: ['movie', 'film'] }, - { char: '\u{1F3A8}', name: 'artist palette', keywords: ['art', 'paint'] }, - { char: '\u{1F3AD}', name: 'performing arts', keywords: ['theater', 'drama'] }, - { char: '\u{1F3C6}', name: 'trophy', keywords: ['winner', 'award', 'prize'] }, - { char: '\u{1F3C5}', name: 'sports medal', keywords: ['medal', 'award'] }, - { char: '\u{1F947}', name: 'first place medal', keywords: ['gold', 'winner'] }, - { char: '\u{1F948}', name: 'second place medal', keywords: ['silver'] }, - { char: '\u{1F949}', name: 'third place medal', keywords: ['bronze'] }, - ], - }, - { - name: 'Travel & Places', - emojis: [ - { char: '\u{1F697}', name: 'automobile', keywords: ['car'] }, - { char: '\u{1F680}', name: 'rocket', keywords: ['launch', 'ship', 'space'] }, - { char: '\u{2708}\u{FE0F}', name: 'airplane', keywords: ['travel', 'flight'] }, - { char: '\u{1F6A2}', name: 'ship', keywords: ['boat', 'cruise'] }, - { char: '\u{1F682}', name: 'locomotive', keywords: ['train'] }, - { char: '\u{1F6B2}', name: 'bicycle', keywords: ['bike', 'cycling'] }, - { char: '\u{1F3E0}', name: 'house', keywords: ['home'] }, - { char: '\u{1F3D7}\u{FE0F}', name: 'building construction', keywords: ['construction'] }, - { char: '\u{1F3D4}\u{FE0F}', name: 'snow-capped mountain', keywords: ['mountain'] }, - { char: '\u{1F3D6}\u{FE0F}', name: 'beach', keywords: ['beach', 'vacation'] }, - { char: '\u{1F30D}', name: 'globe europe-africa', keywords: ['world', 'earth'] }, - { char: '\u{1F30E}', name: 'globe americas', keywords: ['world', 'earth'] }, - { char: '\u{1F5FA}\u{FE0F}', name: 'world map', keywords: ['map', 'travel'] }, - { char: '\u{26F0}\u{FE0F}', name: 'mountain', keywords: ['mountain'] }, - { char: '\u{1F30B}', name: 'volcano', keywords: ['eruption'] }, - ], - }, - { - name: 'Objects', - emojis: [ - { char: '\u{1F4BB}', name: 'laptop', keywords: ['computer', 'code'] }, - { char: '\u{1F4F1}', name: 'mobile phone', keywords: ['phone', 'cell'] }, - { char: '\u{2328}\u{FE0F}', name: 'keyboard', keywords: ['type', 'computer'] }, - { char: '\u{1F4A1}', name: 'light bulb', keywords: ['idea', 'bright'] }, - { char: '\u{1F50D}', name: 'magnifying glass', keywords: ['search', 'find'] }, - { char: '\u{1F512}', name: 'locked', keywords: ['lock', 'security'] }, - { char: '\u{1F513}', name: 'unlocked', keywords: ['unlock', 'open'] }, - { char: '\u{1F527}', name: 'wrench', keywords: ['tool', 'fix'] }, - { char: '\u{1F528}', name: 'hammer', keywords: ['tool', 'build'] }, - { char: '\u{2699}\u{FE0F}', name: 'gear', keywords: ['settings', 'config'] }, - { char: '\u{1F4E6}', name: 'package', keywords: ['box', 'delivery'] }, - { char: '\u{1F4CB}', name: 'clipboard', keywords: ['paste', 'list'] }, - { char: '\u{1F4DD}', name: 'memo', keywords: ['note', 'write'] }, - { char: '\u{1F4DA}', name: 'books', keywords: ['library', 'read'] }, - { char: '\u{1F4D6}', name: 'open book', keywords: ['read', 'study'] }, - { char: '\u{2709}\u{FE0F}', name: 'envelope', keywords: ['email', 'mail'] }, - { char: '\u{1F4CE}', name: 'paperclip', keywords: ['attach'] }, - { char: '\u{270F}\u{FE0F}', name: 'pencil', keywords: ['write', 'edit'] }, - { char: '\u{1F4CC}', name: 'pushpin', keywords: ['pin', 'location'] }, - { char: '\u{1F5D1}\u{FE0F}', name: 'wastebasket', keywords: ['trash', 'delete'] }, - { char: '\u{1F3F7}\u{FE0F}', name: 'label', keywords: ['tag'] }, - { char: '\u{1F4CA}', name: 'bar chart', keywords: ['chart', 'graph', 'stats'] }, - { char: '\u{1F4C8}', name: 'chart increasing', keywords: ['growth', 'up'] }, - { char: '\u{1F4C9}', name: 'chart decreasing', keywords: ['decline', 'down'] }, - { char: '\u{23F0}', name: 'alarm clock', keywords: ['time', 'clock'] }, - ], - }, - { - name: 'Symbols', - emojis: [ - { char: '\u{2764}\u{FE0F}', name: 'red heart', keywords: ['love'] }, - { char: '\u{1F49B}', name: 'yellow heart', keywords: ['love'] }, - { char: '\u{1F49A}', name: 'green heart', keywords: ['love'] }, - { char: '\u{1F499}', name: 'blue heart', keywords: ['love'] }, - { char: '\u{1F49C}', name: 'purple heart', keywords: ['love'] }, - { char: '\u{2705}', name: 'check mark', keywords: ['done', 'complete', 'yes'] }, - { char: '\u{274C}', name: 'cross mark', keywords: ['no', 'wrong', 'reject'] }, - { char: '\u{2757}', name: 'exclamation mark', keywords: ['important', 'alert'] }, - { char: '\u{2753}', name: 'question mark', keywords: ['question', 'help'] }, - { char: '\u{1F4AF}', name: 'hundred points', keywords: ['perfect', '100'] }, - { char: '\u{267B}\u{FE0F}', name: 'recycling symbol', keywords: ['recycle', 'green'] }, - { char: '\u{26A0}\u{FE0F}', name: 'warning', keywords: ['caution', 'alert'] }, - { char: '\u{1F6AB}', name: 'prohibited', keywords: ['banned', 'forbidden'] }, - { char: '\u{2139}\u{FE0F}', name: 'information', keywords: ['info', 'help'] }, - { char: '\u{1F503}', name: 'clockwise arrows', keywords: ['refresh', 'reload'] }, - { char: '\u{1F504}', name: 'counterclockwise arrows', keywords: ['undo', 'back'] }, - { char: '\u{2B06}\u{FE0F}', name: 'up arrow', keywords: ['up'] }, - { char: '\u{2B07}\u{FE0F}', name: 'down arrow', keywords: ['down'] }, - { char: '\u{27A1}\u{FE0F}', name: 'right arrow', keywords: ['right', 'next'] }, - { char: '\u{2B05}\u{FE0F}', name: 'left arrow', keywords: ['left', 'back'] }, - ], - }, - { - name: 'Flags', - emojis: [ - { char: '\u{1F3C1}', name: 'chequered flag', keywords: ['finish', 'race'] }, - { char: '\u{1F6A9}', name: 'triangular flag', keywords: ['flag', 'alert'] }, - { char: '\u{1F3F4}', name: 'black flag', keywords: ['flag'] }, - { char: '\u{1F3F3}\u{FE0F}', name: 'white flag', keywords: ['surrender', 'peace'] }, - { char: '\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}', name: 'rainbow flag', keywords: ['pride', 'lgbtq'] }, - ], - }, -]; - -/** - * Search emojis across all categories by name or keyword. - * Returns matching EmojiEntry items, deduplicated by char. - */ -export function searchEmojis(query: string): EmojiEntry[] { - if (!query) return []; - - const lowerQuery = query.toLowerCase(); - const seen = new Set(); - const results: EmojiEntry[] = []; - - // Search quick emojis first - for (const emoji of QUICK_EMOJIS) { - if (matchesQuery(emoji, lowerQuery) && !seen.has(emoji.char)) { - seen.add(emoji.char); - results.push(emoji); - } - } - - // Then search all categories - for (const cat of EMOJI_CATEGORIES) { - for (const emoji of cat.emojis) { - if (matchesQuery(emoji, lowerQuery) && !seen.has(emoji.char)) { - seen.add(emoji.char); - results.push(emoji); - } - } - } - - return results; -} - -function matchesQuery(emoji: EmojiEntry, lowerQuery: string): boolean { - if (emoji.name.toLowerCase().includes(lowerQuery)) return true; - if (emoji.keywords) { - return emoji.keywords.some((kw) => kw.toLowerCase().includes(lowerQuery)); - } - return false; -} +export * from '../../packages/core/src/comments/emoji-data'; diff --git a/src/comments/source-position-map.ts b/src/comments/source-position-map.ts index 511c2c0..0fd532e 100644 --- a/src/comments/source-position-map.ts +++ b/src/comments/source-position-map.ts @@ -1,460 +1 @@ -/** - * Source Position Map for Comment Anchoring - * - * Strips markdown inline formatting from source while maintaining a parallel - * array mapping each plain-text character back to its source offset. This gives - * exact insertion points for comment footnote references without needing - * markdown-it's inline token positions. - */ - -export interface SourcePositionMap { - rawSource: string; - plainText: string; - offsets: number[]; - spans: FormattingSpan[]; -} - -export interface FormattingSpan { - sourceStart: number; - sourceEnd: number; - plainStart: number; - plainEnd: number; - type: 'bold' | 'italic' | 'strikethrough' | 'code' | 'link' | 'image' | 'escape'; -} - -export interface SelectionContext { - prefix: string; - suffix: string; -} - -const V1_SENTINEL = ''; -const V2_SENTINEL_PREFIX = ' +`; + const file = createMockFileAdapter(); + const identity = createMockIdentityAdapter(); + + manager = new CommentManager({ file, identity }); + await manager.initialize( + mdWithComment, + '/test/file.md', + createMinimalPreferences() + ); + + await manager.editComment('comment-1', 'Updated note'); + + expect(file.writeFile).toHaveBeenCalledWith( + '/test/file.md', + expect.stringContaining('Updated note') + ); + }); + + it('uses FileAdapter.writeFile when resolving a comment', async () => { + const mdWithComment = `# Hello + +Test[@1] content. + + +`; + const file = createMockFileAdapter(); + const identity = createMockIdentityAdapter(); + + manager = new CommentManager({ file, identity }); + await manager.initialize( + mdWithComment, + '/test/file.md', + createMinimalPreferences() + ); + + await manager.resolveComment('comment-1'); + + expect(file.writeFile).toHaveBeenCalled(); + }); + + it('uses FileAdapter.writeFile when deleting a comment', async () => { + const mdWithComment = `# Hello + +Test[@1] content. + + +`; + const file = createMockFileAdapter(); + const identity = createMockIdentityAdapter(); + + manager = new CommentManager({ file, identity }); + await manager.initialize( + mdWithComment, + '/test/file.md', + createMinimalPreferences() + ); + + await manager.deleteComment('comment-1'); + + expect(file.writeFile).toHaveBeenCalled(); + }); + + it('reports write errors from FileAdapter', async () => { + const file = createMockFileAdapter({ + writeFile: vi.fn().mockResolvedValue({ + success: false, + error: 'Permission denied', + }), + }); + const identity = createMockIdentityAdapter(); + + manager = new CommentManager({ file, identity }); + await manager.initialize( + SAMPLE_MARKDOWN, + '/test/file.md', + createMinimalPreferences() + ); + + // Should not throw even if write fails + await expect( + manager.addComment('test', 'note') + ).resolves.not.toThrow(); + }); + }); + + describe('graceful degradation without adapters', () => { + it('initializes without any adapters', async () => { + manager = new CommentManager(); + const result = await manager.initialize( + SAMPLE_MARKDOWN, + '/test/file.md', + createMinimalPreferences() + ); + + expect(result).toBeDefined(); + expect(result.comments).toEqual([]); + }); + + it('uses empty username when no adapter and no preference set', async () => { + manager = new CommentManager(); + await manager.initialize( + SAMPLE_MARKDOWN, + '/test/file.md', + createMinimalPreferences() + ); + + // Comments created should have empty author + const comments = manager.getComments(); + expect(comments).toEqual([]); + }); + + it('addComment works without FileAdapter (skips write)', async () => { + manager = new CommentManager(); + await manager.initialize( + SAMPLE_MARKDOWN, + '/test/file.md', + createMinimalPreferences() + ); + + // Should not throw even without file adapter + await expect( + manager.addComment('test document', 'A note') + ).resolves.not.toThrow(); + + const comments = manager.getComments(); + expect(comments).toHaveLength(1); + expect(comments[0].body).toBe('A note'); + }); + + it('editComment works without FileAdapter', async () => { + const mdWithComment = `# Hello + +Test[@1] content. + + +`; + manager = new CommentManager(); + await manager.initialize( + mdWithComment, + '/test/file.md', + createMinimalPreferences() + ); + + await expect( + manager.editComment('comment-1', 'Updated') + ).resolves.not.toThrow(); + }); + + it('deleteComment works without FileAdapter', async () => { + const mdWithComment = `# Hello + +Test[@1] content. + + +`; + manager = new CommentManager(); + await manager.initialize( + mdWithComment, + '/test/file.md', + createMinimalPreferences() + ); + + await expect( + manager.deleteComment('comment-1') + ).resolves.not.toThrow(); + + expect(manager.getComments()).toHaveLength(0); + }); + + it('resolveComment works without FileAdapter', async () => { + const mdWithComment = `# Hello + +Test[@1] content. + + +`; + manager = new CommentManager(); + await manager.initialize( + mdWithComment, + '/test/file.md', + createMinimalPreferences() + ); + + await expect( + manager.resolveComment('comment-1') + ).resolves.not.toThrow(); + }); + + it('provides partial adapters — only FileAdapter', async () => { + const file = createMockFileAdapter(); + + manager = new CommentManager({ file }); + await manager.initialize( + SAMPLE_MARKDOWN, + '/test/file.md', + createMinimalPreferences() + ); + + await manager.addComment('test document', 'note'); + + // File adapter should be used for write + expect(file.writeFile).toHaveBeenCalled(); + }); + + it('provides partial adapters — only IdentityAdapter', async () => { + const identity = createMockIdentityAdapter({ + getUsername: vi.fn().mockResolvedValue('identity-only-user'), + }); + + manager = new CommentManager({ identity }); + await manager.initialize( + SAMPLE_MARKDOWN, + '/test/file.md', + createMinimalPreferences() + ); + + expect(identity.getUsername).toHaveBeenCalled(); + + // addComment should still work (no file write, but no crash) + await expect( + manager.addComment('test', 'note') + ).resolves.not.toThrow(); + }); + }); + + describe('isWriteInProgress', () => { + it('returns false initially', () => { + manager = new CommentManager(); + expect(manager.isWriteInProgress()).toBe(false); + }); + }); +}); diff --git a/packages/core/src/comments/comment-manager.ts b/packages/core/src/comments/comment-manager.ts new file mode 100644 index 0000000..c74d847 --- /dev/null +++ b/packages/core/src/comments/comment-manager.ts @@ -0,0 +1,851 @@ +/** + * Comment Manager (core) + * + * CRUD orchestrator that connects the comment parser, serializer, UI, + * text highlighter, and file-writing into a single cohesive controller. + * All comment lifecycle operations flow through this class. + * + * This core version uses FileAdapter and IdentityAdapter instead of + * chrome.runtime.sendMessage, allowing it to work in any environment + * (Chrome extension, Electron, Node.js, tests). + * + * Both adapters are optional — the manager degrades gracefully: + * - Without FileAdapter: file writes are silently skipped + * - Without IdentityAdapter: empty username is used as fallback + */ + +import type { Comment, CommentParseResult, AppState, CommentTag } from '../types/index'; +import type { FileAdapter, IdentityAdapter } from '../adapters'; +import { CommentUI } from '../../../../src/comments/comment-ui'; +import { CommentHighlighter } from '../../../../src/comments/comment-highlight'; +import { parseComments } from './annotation-parser'; +import { + addComment as serializerAddComment, + addCommentAtOffset as serializerAddCommentAtOffset, + removeComment as serializerRemoveComment, + updateComment as serializerUpdateComment, + updateCommentMetadata as serializerUpdateCommentMetadata, + resolveComment as serializerResolveComment, + addReply as serializerAddReply, + toggleReaction as serializerToggleReaction, + generateNextCommentId, +} from './annotation-serializer'; +import { buildSourceMap, findInsertionPoint } from './source-position-map'; +import type { SourcePositionMap, SelectionContext } from './source-position-map'; +import { computeCommentContext } from './comment-context'; + +export interface CommentManagerAdapters { + file?: FileAdapter; + identity?: IdentityAdapter; +} + +export class CommentManager { + private comments: Comment[] = []; + private rawMarkdown: string = ''; + private filePath: string = ''; + private writeInProgress = false; + private ui: CommentUI | null = null; + private highlighter: CommentHighlighter | null = null; + private authorName: string = ''; + private sourceMap: SourcePositionMap | null = null; + private pendingContext: SelectionContext | null = null; + + private readonly fileAdapter: FileAdapter | null; + private readonly identityAdapter: IdentityAdapter | null; + + private windowListeners: Array<{ + event: string; + handler: EventListener; + }> = []; + + private eventListeners: Array<{ + event: string; + handler: EventListener; + }> = []; + + /** Grace period (ms) after native write before clearing writeInProgress. + * Must exceed file-watcher poll interval (1000ms) + debounce (500ms) + * to prevent the auto-reload from triggering on our own write. */ + private static readonly WRITE_GUARD_DELAY = 2000; + + constructor(adapters?: CommentManagerAdapters) { + this.fileAdapter = adapters?.file ?? null; + this.identityAdapter = adapters?.identity ?? null; + } + + /** + * Parse existing comments from raw markdown, set up UI and highlights, + * and wire event listeners for user actions. + */ + async initialize( + markdown: string, + filePath: string, + preferences: AppState['preferences'] + ): Promise { + this.rawMarkdown = markdown; + this.filePath = filePath; + this.authorName = preferences.commentAuthor ?? ''; + + // If no author configured, try to get username from identity adapter + if (!this.authorName && this.identityAdapter) { + try { + const username = await this.identityAdapter.getUsername(); + if (username) { + this.authorName = username; + } + } catch { + // Identity adapter may fail — leave author empty + } + } + + // Build source position map for accurate comment insertion + this.sourceMap = buildSourceMap(markdown); + + // Parse existing comments + const result = parseComments(markdown); + this.comments = [...result.comments]; + + // Set up UI + this.ui = new CommentUI(); + this.ui.setCurrentAuthor(this.authorName); + + // Set up highlighter - highlight text first, then position cards + this.highlighter = new CommentHighlighter(); + const container = document.getElementById('mdview-container') || document.body; + for (const comment of this.comments) { + this.highlighter.highlightComment(container, comment); + } + + // Render cards and position them next to their highlights + for (const comment of this.comments) { + const card = this.ui.renderCard(comment); + document.body.appendChild(card); + this.positionCardAtHighlight(card, comment.id); + } + + // Apply cascade layout to prevent card overlap + this.repositionAllCards(); + + // Re-position cards on window resize (debounced) + let resizeTimer: ReturnType | null = null; + const resizeHandler = () => { + if (resizeTimer) clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => this.repositionAllCards(), 150); + }; + window.addEventListener('resize', resizeHandler); + this.windowListeners.push({ event: 'resize', handler: resizeHandler as EventListener }); + + // Wire up custom DOM event listeners + this.addEventHandler('mdview:comment:edit', (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.commentId) { + this.showEditForm(detail.commentId); + } + }); + + this.addEventHandler('mdview:comment:resolve', (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.commentId) { + void this.resolveComment(detail.commentId); + } + }); + + this.addEventHandler('mdview:comment:delete', (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.commentId) { + void this.deleteComment(detail.commentId); + } + }); + + this.addEventHandler('mdview:comment:focus', (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.commentId && this.highlighter) { + this.highlighter.clearActive(); + this.highlighter.setActive(detail.commentId); + const el = this.highlighter.getHighlightElement(detail.commentId); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + }); + + this.addEventHandler('mdview:comment:reply', (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.commentId) { + this.showReplyForm(detail.commentId); + } + }); + + this.addEventHandler('mdview:comment:react', (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.commentId && detail?.emoji) { + void this.toggleReaction(detail.commentId, detail.emoji); + } + }); + + this.addEventHandler('mdview:comment:reposition', () => { + this.repositionAllCards(); + }); + + this.addEventHandler('mdview:comment:react:picker', (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.commentId) { + this.showEmojiPicker(detail.commentId, detail.anchor); + } + }); + + return result; + } + + /** + * Handle an "add comment" request from the Chrome context menu. + * Captures current DOM selection context if available, shows the input form, + * and creates the comment on save. + */ + handleAddCommentRequest(selectionText: string): void { + if (!this.ui) return; + + // Remove any existing input form + document.querySelector('.mdview-comment-input')?.remove(); + + // Try to capture DOM context from current selection (may still be active) + const selection = window.getSelection(); + if (selection && !selection.isCollapsed && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + this.pendingContext = { + prefix: this.getTextBefore(range, 30), + suffix: this.getTextAfter(range, 30), + }; + } + + // Position form at selection if available, otherwise top of viewport + let top = window.scrollY + 100; + if (selection && !selection.isCollapsed && selection.rangeCount > 0) { + const rect = selection.getRangeAt(0).getBoundingClientRect(); + top = rect.top + window.scrollY; + } + + const form = this.ui.renderInputForm( + (body: string, tags: CommentTag[]) => { + if (body.trim()) { + void this.addComment(selectionText, body, tags.length > 0 ? tags : undefined); + } + form.remove(); + }, + () => form.remove() + ); + + form.style.top = `${top}px`; + document.body.appendChild(form); + + const textarea = form.querySelector('textarea'); + if (textarea) textarea.focus(); + } + + /** + * Show the edit form for an existing comment, pre-populated with its body. + */ + private showEditForm(commentId: string): void { + const comment = this.comments.find((c) => c.id === commentId); + if (!comment || !this.ui) return; + + // Find the card and replace its body with an input form + const card = document.querySelector( + `.mdview-comment-card[data-comment-id="${commentId}"]` + ); + if (!card) return; + + // Expand the card if it's minimized so the edit form is visible + if (card.classList.contains('minimized')) { + card.classList.remove('minimized'); + this.repositionAllCards(); + } + + const form = this.ui.renderInputForm( + (newBody: string, tags: CommentTag[]) => { + if (newBody.trim()) { + void this.editComment(commentId, newBody, tags); + } + // Restore the card body + form.remove(); + const bodyEl = card.querySelector('.comment-body'); + if (bodyEl) { + (bodyEl as HTMLElement).style.display = ''; + bodyEl.textContent = newBody.trim() || comment.body; + } + }, + () => { + form.remove(); + const bodyEl = card.querySelector('.comment-body'); + if (bodyEl) { + (bodyEl as HTMLElement).style.display = ''; + } + }, + comment.tags + ); + + // Pre-populate textarea + const textarea = form.querySelector('textarea'); + if (textarea) { + (textarea as HTMLTextAreaElement).value = comment.body; + } + + // Hide the card body and insert the form + const bodyEl = card.querySelector('.comment-body'); + if (bodyEl) { + (bodyEl as HTMLElement).style.display = 'none'; + } + card.appendChild(form); + + // Focus the textarea + if (textarea) (textarea as HTMLTextAreaElement).focus(); + } + + /** + * Add a new comment attached to selected text. + */ + async addComment(selectedText: string, body: string, tags?: CommentTag[]): Promise { + const nextId = generateNextCommentId(this.rawMarkdown); + + // Compute insertion offset for positional context + const offset = this.sourceMap + ? findInsertionPoint(this.sourceMap, selectedText, this.pendingContext ?? undefined) + : null; + + // Compute positional context from the offset (or fallback to text search) + const contentSection = this.getContentSection(); + const contextOffset = offset ?? contentSection.indexOf(selectedText); + const context = contextOffset >= 0 + ? computeCommentContext(contentSection, contextOffset) + : undefined; + + const comment: Comment = { + id: nextId, + selectedText, + body, + author: this.authorName, + date: new Date().toISOString(), + resolved: false, + context, + ...(tags && tags.length > 0 ? { tags } : {}), + ...(this.pendingContext?.prefix ? { anchorPrefix: this.pendingContext.prefix } : {}), + ...(this.pendingContext?.suffix ? { anchorSuffix: this.pendingContext.suffix } : {}), + }; + + // Serialize into markdown using source map for accurate placement + const updatedMarkdown = this.sourceMap + ? serializerAddCommentAtOffset(this.rawMarkdown, comment, this.sourceMap, this.pendingContext ?? undefined) + : serializerAddComment(this.rawMarkdown, comment); + this.pendingContext = null; + + // Update internal state immediately (optimistic) + this.rawMarkdown = updatedMarkdown; + this.sourceMap = buildSourceMap(updatedMarkdown); + this.comments.push(comment); + + // Patch DOM immediately (optimistic) + const container = document.getElementById('mdview-container') || document.body; + if (this.highlighter) { + this.highlighter.highlightComment(container, comment); + } + + if (this.ui) { + const card = this.ui.renderCard(comment); + document.body.appendChild(card); + this.positionCardAtHighlight(card, comment.id); + this.repositionAllCards(); + } + + // Write to file via adapter (if available) + try { + await this.writeFile(updatedMarkdown); + if (this.ui) this.ui.showToast('Comment saved'); + } catch (error) { + console.error('[MDView] Comment write failed:', error); + if (this.ui) this.ui.showToast(`Write failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Edit the body of an existing comment. + */ + async editComment(id: string, newBody: string, tags?: CommentTag[]): Promise { + let updatedMarkdown = serializerUpdateComment( + this.rawMarkdown, + id, + newBody + ); + + // Update internal state immediately. + // No source map rebuild needed — edit only changes the comments section + // (below the separator), which the source map does not cover. + const comment = this.comments.find((c) => c.id === id); + + // Persist tag changes to metadata if tags were explicitly provided and differ + if (comment && tags !== undefined) { + const oldTags = comment.tags ?? []; + const newTags = tags.length > 0 ? tags : []; + const tagsChanged = + oldTags.length !== newTags.length || + oldTags.some((t, i) => t !== newTags[i]); + + if (tagsChanged) { + updatedMarkdown = serializerUpdateCommentMetadata( + updatedMarkdown, + id, + (meta) => { + if (newTags.length > 0) { + meta.tags = newTags; + } else { + delete meta.tags; + } + } + ); + } + } + + this.rawMarkdown = updatedMarkdown; + if (comment) { + comment.body = newBody; + if (tags !== undefined) { + comment.tags = tags.length > 0 ? tags : undefined; + } + } + + // Write to file + try { + await this.writeFile(updatedMarkdown); + if (this.ui) this.ui.showToast('Comment updated'); + } catch { + if (this.ui) this.ui.showToast('Comment updated locally (file write failed)'); + } + } + + /** + * Mark a comment as resolved. + */ + async resolveComment(id: string): Promise { + const updatedMarkdown = serializerResolveComment(this.rawMarkdown, id); + + // Update internal state immediately. + // No source map rebuild needed — resolve only changes metadata in the + // comments section (below the separator). + this.rawMarkdown = updatedMarkdown; + const comment = this.comments.find((c) => c.id === id); + if (comment) { + comment.resolved = true; + } + + // Update highlight to resolved state immediately + if (this.highlighter) { + this.highlighter.setResolved(id); + } + + // Write to file + try { + await this.writeFile(updatedMarkdown); + } catch { + if (this.ui) this.ui.showToast('Resolved locally (file write failed)'); + } + } + + /** + * Delete a comment entirely. + */ + async deleteComment(id: string): Promise { + const updatedMarkdown = serializerRemoveComment(this.rawMarkdown, id); + + // Update internal state immediately + this.rawMarkdown = updatedMarkdown; + this.sourceMap = buildSourceMap(updatedMarkdown); + this.comments = this.comments.filter((c) => c.id !== id); + + // Remove highlight from DOM immediately + if (this.highlighter) { + this.highlighter.removeHighlight(id); + } + + // Remove card from DOM and reposition remaining cards + const card = document.querySelector( + `.mdview-comment-card[data-comment-id="${id}"]` + ); + if (card) card.remove(); + this.repositionAllCards(); + + // Write to file + try { + await this.writeFile(updatedMarkdown); + } catch { + if (this.ui) this.ui.showToast('Deleted locally (file write failed)'); + } + } + + /** + * Write content to the file via the FileAdapter. + * If no FileAdapter is configured, the write is silently skipped. + */ + private async writeFile(content: string): Promise { + if (!this.fileAdapter) { + // No file adapter — graceful degradation, skip write + return; + } + + this.writeInProgress = true; + try { + const result = await this.fileAdapter.writeFile(this.filePath, content); + + if (!result.success && result.error) { + throw new Error(result.error); + } + } finally { + // Keep the guard active for a grace period so the auto-reload watcher + // has time to see and ignore the file change event we just caused. + setTimeout(() => { + this.writeInProgress = false; + }, CommentManager.WRITE_GUARD_DELAY); + } + } + + /** + * Whether a file write is currently in progress. + */ + isWriteInProgress(): boolean { + return this.writeInProgress; + } + + /** + * Return a shallow copy of the current comments array. + */ + getComments(): Comment[] { + return [...this.comments]; + } + + /** + * Extract visible text preceding a Range from the DOM. + */ + private getTextBefore(range: Range, maxChars: number): string { + const container = document.getElementById('mdview-container') || document.body; + const treeWalker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); + const texts: string[] = []; + + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode as Text; + if (node === range.startContainer) { + texts.push(node.textContent?.slice(0, range.startOffset) ?? ''); + break; + } + texts.push(node.textContent ?? ''); + } + + const fullText = texts.join(''); + return fullText.slice(-maxChars); + } + + /** + * Extract visible text following a Range from the DOM. + */ + private getTextAfter(range: Range, maxChars: number): string { + const container = document.getElementById('mdview-container') || document.body; + const treeWalker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); + const texts: string[] = []; + let started = false; + + while (treeWalker.nextNode()) { + const node = treeWalker.currentNode as Text; + if (node === range.endContainer) { + texts.push(node.textContent?.slice(range.endOffset) ?? ''); + started = true; + continue; + } + if (started) { + texts.push(node.textContent ?? ''); + } + } + + const fullText = texts.join(''); + return fullText.slice(0, maxChars); + } + + /** + * Add a reply to an existing comment. + */ + async addReply(commentId: string, body: string): Promise { + const reply = { + author: this.authorName, + body, + date: new Date().toISOString(), + }; + + const { markdown: updatedMarkdown, replyId } = serializerAddReply( + this.rawMarkdown, + commentId, + reply + ); + + // Update internal state + this.rawMarkdown = updatedMarkdown; + const comment = this.comments.find((c) => c.id === commentId); + if (comment) { + const replies = comment.replies ?? []; + replies.push({ id: replyId, ...reply }); + comment.replies = replies; + } + + // Refresh the card content + this.refreshCardContent(commentId); + + // Write to file + try { + await this.writeFile(updatedMarkdown); + if (this.ui) this.ui.showToast('Reply saved'); + } catch { + if (this.ui) this.ui.showToast('Reply saved locally (file write failed)'); + } + } + + /** + * Toggle an emoji reaction on a comment. + */ + async toggleReaction(commentId: string, emoji: string): Promise { + const updatedMarkdown = serializerToggleReaction( + this.rawMarkdown, + commentId, + emoji, + this.authorName + ); + + // Update internal state + this.rawMarkdown = updatedMarkdown; + const comment = this.comments.find((c) => c.id === commentId); + if (comment) { + const reactions = comment.reactions ?? {}; + const authors = reactions[emoji] ?? []; + const idx = authors.indexOf(this.authorName); + if (idx >= 0) { + authors.splice(idx, 1); + if (authors.length === 0) { + delete reactions[emoji]; + } else { + reactions[emoji] = authors; + } + } else { + reactions[emoji] = [...authors, this.authorName]; + } + + if (Object.keys(reactions).length > 0) { + comment.reactions = reactions; + } else { + delete comment.reactions; + } + } + + // Refresh the card content + this.refreshCardContent(commentId); + + // Write to file + try { + await this.writeFile(updatedMarkdown); + } catch { + if (this.ui) this.ui.showToast('Reaction saved locally (file write failed)'); + } + } + + /** + * Show a reply form in the comment card. + */ + private showReplyForm(commentId: string): void { + if (!this.ui) return; + + const card = document.querySelector( + `.mdview-comment-card[data-comment-id="${commentId}"]` + ); + if (!card) return; + + // Remove any existing reply form + card.querySelector('.comment-reply-form')?.remove(); + + const form = this.ui.renderReplyForm( + (body: string) => { + if (body.trim()) { + void this.addReply(commentId, body); + } + form.remove(); + }, + () => form.remove() + ); + + // Insert form before the reply button or reactions + const replyBtn = card.querySelector('.comment-reply-btn'); + if (replyBtn) { + card.insertBefore(form, replyBtn); + } else { + card.appendChild(form); + } + + const textarea = form.querySelector('textarea'); + if (textarea) textarea.focus(); + } + + /** + * Show the emoji picker anchored to a button. + */ + private showEmojiPicker(commentId: string, anchor?: HTMLElement): void { + if (!this.ui) return; + + // Remove any existing picker + document.querySelector('.mdview-emoji-picker')?.remove(); + + const anchorEl = anchor || document.querySelector( + `.mdview-comment-card[data-comment-id="${commentId}"] .comment-reaction-add` + ) as HTMLElement; + + if (!anchorEl) return; + + const picker = this.ui.renderEmojiPicker( + anchorEl, + (emoji: string) => { + void this.toggleReaction(commentId, emoji); + picker.remove(); + }, + () => picker.remove() + ); + + // Position picker near the anchor + const card = anchorEl.closest('.mdview-comment-card'); + if (card) { + card.appendChild(picker); + } else { + document.body.appendChild(picker); + } + } + + /** + * Re-render the replies and reactions sections of an existing card + * without destroying the entire card. + */ + private refreshCardContent(commentId: string): void { + if (!this.ui) return; + + const comment = this.comments.find((c) => c.id === commentId); + if (!comment) return; + + const oldCard = document.querySelector( + `.mdview-comment-card[data-comment-id="${commentId}"]` + ); + if (!oldCard) return; + + // Replace the card with a freshly rendered one, preserving position and minimized state + const topStyle = (oldCard as HTMLElement).style.top; + const wasMinimized = oldCard.classList.contains('minimized'); + const newCard = this.ui.renderCard(comment); + newCard.style.top = topStyle; + if (!wasMinimized) { + newCard.classList.remove('minimized'); + } + oldCard.replaceWith(newCard); + } + + /** + * Clean up UI elements and event listeners. + */ + destroy(): void { + if (this.ui) { + this.ui.destroy(); + this.ui = null; + } + + for (const { event, handler } of this.eventListeners) { + document.removeEventListener(event, handler); + } + this.eventListeners = []; + + for (const { event, handler } of this.windowListeners) { + window.removeEventListener(event, handler); + } + this.windowListeners = []; + + this.highlighter = null; + this.sourceMap = null; + this.pendingContext = null; + this.comments = []; + + // Remove any floating cards/forms from DOM + document.querySelectorAll('.mdview-comment-card, .mdview-comment-input').forEach((el) => el.remove()); + } + + /** + * Extract the content section of markdown (above the comments separator). + */ + private getContentSection(): string { + const v1 = this.rawMarkdown.indexOf(''); + const v2 = this.rawMarkdown.indexOf(''); - const v2 = this.rawMarkdown.indexOf(''; - const V2_PREFIX = '\n[^comment-1]: \n note'; + const withComment = + '# Doc\n\nSome text[^comment-1] here.\n\n\n[^comment-1]: \n note'; vi.mocked(fs.readFileSync).mockReturnValue(existing); vi.mocked(fs.writeFileSync).mockReturnValue(undefined); @@ -209,7 +214,8 @@ describe('Native Host Message Handling', () => { it('should accept v2 annotation marker changes', () => { const existing = '# Doc\n\nSome text here.'; - const withAnnotation = '# Doc\n\nSome text[@1] here.\n\n'; + const withAnnotation = + '# Doc\n\nSome text[@1] here.\n\n'; vi.mocked(fs.readFileSync).mockReturnValue(existing); vi.mocked(fs.writeFileSync).mockReturnValue(undefined); @@ -224,7 +230,8 @@ describe('Native Host Message Handling', () => { it('should detect v2 sentinel as content boundary', () => { const existing = '# Doc\n\nSome text[@1] here.\n\n'; - const updated = '# Doc\n\nSome text[@1][@2] here.\n\n'; + const updated = + '# Doc\n\nSome text[@1][@2] here.\n\n'; vi.mocked(fs.readFileSync).mockReturnValue(existing); vi.mocked(fs.writeFileSync).mockReturnValue(undefined); @@ -238,7 +245,8 @@ describe('Native Host Message Handling', () => { }); it('should accept v1 to v2 migration write', () => { - const existing = '# Doc\n\nSome text[^comment-1] here.\n\n\n[^comment-1]: stuff'; + const existing = + '# Doc\n\nSome text[^comment-1] here.\n\n\n[^comment-1]: stuff'; const migrated = '# Doc\n\nSome text[@1] here.\n\n'; vi.mocked(fs.readFileSync).mockReturnValue(existing); vi.mocked(fs.writeFileSync).mockReturnValue(undefined); diff --git a/tests/unit/comments/source-position-map.test.ts b/tests/unit/comments/source-position-map.test.ts index 7f9bb33..58cefe7 100644 --- a/tests/unit/comments/source-position-map.test.ts +++ b/tests/unit/comments/source-position-map.test.ts @@ -6,10 +6,7 @@ */ import { describe, it, expect } from 'vitest'; -import { - buildSourceMap, - findInsertionPoint, -} from '../../../src/comments/source-position-map'; +import { buildSourceMap, findInsertionPoint } from '@mdview/core'; // ─── buildSourceMap ────────────────────────────────────────────────── diff --git a/tests/unit/core/export-controller.test.ts b/tests/unit/core/export-controller.test.ts index e36d69e..1c2f281 100644 --- a/tests/unit/core/export-controller.test.ts +++ b/tests/unit/core/export-controller.test.ts @@ -4,18 +4,35 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { describe, test, expect, beforeEach, vi, afterEach } from 'vitest'; -import { ExportController } from '../../../src/core/export-controller'; -import type { ExportProgress } from '../../../src/types'; +import { ExportController } from '@mdview/core'; +import type { ExportProgress } from '@mdview/core'; // Don't mock modules - use real implementations -vi.mock('../../../src/utils/debug-logger', () => ({ - debug: { +vi.mock('../../../packages/core/src/utils/debug-logger', () => { + const mockHelpers = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), - }, -})); + log: vi.fn(), + }; + return { + debug: mockHelpers, + createDebug: vi.fn(() => mockHelpers), + createDebugLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), + DebugLogger: vi.fn().mockImplementation(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), + }; +}); describe('ExportController', () => { let controller: ExportController; diff --git a/tests/unit/core/frontmatter-extractor.test.ts b/tests/unit/core/frontmatter-extractor.test.ts index e538724..f6e7e8e 100644 --- a/tests/unit/core/frontmatter-extractor.test.ts +++ b/tests/unit/core/frontmatter-extractor.test.ts @@ -3,10 +3,7 @@ */ import { describe, test, expect } from 'vitest'; -import { - extractFrontmatter, - renderFrontmatterHtml, -} from '../../../src/core/frontmatter-extractor'; +import { extractFrontmatter, renderFrontmatterHtml } from '@mdview/core'; describe('extractFrontmatter', () => { test('returns null frontmatter for empty string', () => { diff --git a/tests/unit/core/markdown-converter.test.ts b/tests/unit/core/markdown-converter.test.ts index 322528f..e0b9fd1 100644 --- a/tests/unit/core/markdown-converter.test.ts +++ b/tests/unit/core/markdown-converter.test.ts @@ -3,7 +3,7 @@ */ import { describe, test, expect, beforeEach } from 'vitest'; -import { MarkdownConverter } from '../../../src/core/markdown-converter'; +import { MarkdownConverter } from '@mdview/core'; import { markdownSamples, expectedHtml } from '../../helpers/fixtures'; import { assertHtmlMatches } from '../../helpers/test-utils'; @@ -316,7 +316,7 @@ describe('MarkdownConverter', () => { describe('Complex Document', () => { test('should render complex document with all features', async () => { const result = await converter.convert(markdownSamples.complex); - + // Check all features are present expect(result.html).toContain(' { expect(result.html).toContain('
'); expect(result.html).toContain(' { }); }); }); - diff --git a/tests/unit/core/mermaid-cache-race.test.ts b/tests/unit/core/mermaid-cache-race.test.ts index d9602c3..1e93cc7 100644 --- a/tests/unit/core/mermaid-cache-race.test.ts +++ b/tests/unit/core/mermaid-cache-race.test.ts @@ -8,22 +8,24 @@ */ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; -import { RenderPipeline } from '../../../src/core/render-pipeline'; +import { RenderPipeline } from '../../../packages/chrome-ext/src/core/render-pipeline'; import { createTestContainer, cleanupTestContainer, mockConsole } from '../../helpers/test-utils'; import { mockChromeRuntime, waitFor } from '../../helpers/mocks'; // The mock renderAll should simulate what the real one does: change classes on containers -const mockRenderAll = vi.fn().mockImplementation(async (container: HTMLElement) => { - // Simulate successful rendering: remove pending, add ready - const pending = container.querySelectorAll('.mermaid-container.mermaid-pending'); - pending.forEach((el) => { - el.classList.remove('mermaid-pending'); - el.classList.add('mermaid-ready'); - el.innerHTML = '
rendered
'; - }); -}); +const { mockRenderAll } = vi.hoisted(() => ({ + mockRenderAll: vi.fn().mockImplementation(async (container: HTMLElement) => { + // Simulate successful rendering: remove pending, add ready + const pending = container.querySelectorAll('.mermaid-container.mermaid-pending'); + pending.forEach((el) => { + el.classList.remove('mermaid-pending'); + el.classList.add('mermaid-ready'); + el.innerHTML = '
rendered
'; + }); + }), +})); -vi.mock('../../../src/renderers/mermaid-renderer', () => ({ +vi.mock('../../../packages/core/src/renderers/mermaid-renderer', () => ({ mermaidRenderer: { renderAll: mockRenderAll, renderAllImmediate: vi.fn().mockResolvedValue(undefined), @@ -32,7 +34,7 @@ vi.mock('../../../src/renderers/mermaid-renderer', () => ({ }, })); -vi.mock('../../../src/renderers/syntax-highlighter', () => ({ +vi.mock('../../../packages/core/src/renderers/syntax-highlighter', () => ({ syntaxHighlighter: { highlightVisible: vi.fn(), highlightAll: vi.fn(), @@ -40,7 +42,7 @@ vi.mock('../../../src/renderers/syntax-highlighter', () => ({ }, })); -vi.mock('../../../src/workers/worker-pool', () => ({ +vi.mock('../../../packages/core/src/workers/worker-pool', () => ({ workerPool: { initialize: vi.fn().mockRejectedValue(new Error('file:// not supported')), execute: vi.fn(), diff --git a/tests/unit/core/render-pipeline.test.ts b/tests/unit/core/render-pipeline.test.ts index a6fecb3..bafa304 100644 --- a/tests/unit/core/render-pipeline.test.ts +++ b/tests/unit/core/render-pipeline.test.ts @@ -3,13 +3,13 @@ */ import { describe, test, expect, beforeEach, vi, afterEach } from 'vitest'; -import { RenderPipeline } from '../../../src/core/render-pipeline'; +import { RenderPipeline } from '../../../packages/chrome-ext/src/core/render-pipeline'; import { markdownSamples } from '../../helpers/fixtures'; import { mockChromeRuntime, waitFor } from '../../helpers/mocks'; import { createTestContainer, cleanupTestContainer, mockConsole } from '../../helpers/test-utils'; -// Mock the worker pool module -vi.mock('../../../src/workers/worker-pool', () => ({ +// Mock the worker pool module (internal to core's render-pipeline) +vi.mock('../../../packages/core/src/workers/worker-pool', () => ({ workerPool: { initialize: vi.fn().mockResolvedValue(undefined), execute: vi.fn().mockResolvedValue({ @@ -36,11 +36,11 @@ describe('RenderPipeline', () => { beforeEach(() => { // Suppress console output during tests to keep run clean consoleMock = mockConsole(); - + pipeline = new RenderPipeline(); container = createTestContainer(); mockChromeRuntime(); - + // Mock window.crypto for hash generation Object.defineProperty(global, 'crypto', { value: { @@ -98,7 +98,7 @@ describe('RenderPipeline', () => { test('should sanitize HTML content', async () => { const malicious = '\n# Safe Content'; - + await pipeline.render({ container, markdown: malicious, @@ -182,7 +182,7 @@ describe('RenderPipeline', () => { test('should support multiple progress listeners', async () => { const callback1 = vi.fn(); const callback2 = vi.fn(); - + pipeline.onProgress(callback1); pipeline.onProgress(callback2); @@ -200,7 +200,7 @@ describe('RenderPipeline', () => { test('should cleanup listener on unsubscribe', async () => { const callback = vi.fn(); const unsubscribe = pipeline.onProgress(callback); - + unsubscribe(); await pipeline.render({ @@ -234,7 +234,7 @@ describe('RenderPipeline', () => { test('should trigger progressive mode for large files', async () => { const largeMarkdown = 'a'.repeat(50000); const progressStages: string[] = []; - + pipeline.onProgress((progress) => { progressStages.push(progress.stage); }); @@ -454,7 +454,7 @@ describe('RenderPipeline', () => { test('should escape HTML in error messages', async () => { // This test verifies the escapeHtml private method works const errorWithHtml = ''; - + // The pipeline should handle this safely expect(() => { (pipeline as any).escapeHtml(errorWithHtml); @@ -563,14 +563,14 @@ describe('RenderPipeline', () => { describe('Performance', () => { test('should render small files quickly', async () => { const start = Date.now(); - + await pipeline.render({ container, markdown: '# Small file\n\nSome content.', useCache: false, useWorkers: false, }); - + const duration = Date.now() - start; expect(duration).toBeLessThan(1000); // Should be much faster, but allowing margin }); @@ -589,4 +589,3 @@ describe('RenderPipeline', () => { }); }); }); - diff --git a/tests/unit/core/theme-engine.test.ts b/tests/unit/core/theme-engine.test.ts index ecf6651..43c1b1e 100644 --- a/tests/unit/core/theme-engine.test.ts +++ b/tests/unit/core/theme-engine.test.ts @@ -3,11 +3,98 @@ */ import { describe, test, expect, beforeEach, vi, afterEach } from 'vitest'; -import { ThemeEngine } from '../../../src/core/theme-engine'; +import { ThemeEngine } from '../../../packages/chrome-ext/src/core/theme-engine'; import { mockTheme } from '../../helpers/fixtures'; import { mockChromeStorage } from '../../helpers/mocks'; import { mockConsole } from '../../helpers/test-utils'; +// Hoist the mock theme so it's available when vi.mock factory runs +const { hoistedMockTheme } = vi.hoisted(() => ({ + hoistedMockTheme: { + name: 'test-theme' as const, + displayName: 'Test Theme', + variant: 'light' as const, + author: 'Test Author', + version: '1.0.0', + colors: { + background: '#ffffff', + backgroundSecondary: '#f6f8fa', + backgroundTertiary: '#f0f0f0', + foreground: '#24292e', + foregroundSecondary: '#586069', + foregroundMuted: '#6a737d', + primary: '#0366d6', + secondary: '#28a745', + accent: '#f66a0a', + heading: '#24292e', + link: '#0366d6', + linkHover: '#0256c7', + linkVisited: '#6f42c1', + codeBackground: '#f6f8fa', + codeText: '#24292e', + codeKeyword: '#d73a49', + codeString: '#032f62', + codeComment: '#6a737d', + codeFunction: '#6f42c1', + border: '#e1e4e8', + borderLight: '#eaecef', + borderHeavy: '#d1d5da', + selection: '#c8e1ff', + highlight: '#fffbdd', + shadow: 'rgba(0, 0, 0, 0.1)', + success: '#28a745', + warning: '#ffd33d', + error: '#d73a49', + info: '#0366d6', + commentHighlight: 'rgba(255, 212, 59, 0.35)', + commentHighlightResolved: 'rgba(155, 155, 155, 0.2)', + commentCardBg: '#f6f8fa', + }, + typography: { + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif', + codeFontFamily: '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', + baseFontSize: '16px', + baseLineHeight: 1.6, + h1Size: '2em', + h2Size: '1.5em', + h3Size: '1.25em', + h4Size: '1em', + h5Size: '0.875em', + h6Size: '0.85em', + fontWeightNormal: 400, + fontWeightBold: 600, + headingFontWeight: 600, + }, + spacing: { + blockMargin: '1em 0', + paragraphMargin: '0 0 1em 0', + listItemMargin: '0.25em 0', + headingMargin: '1.5em 0 0.5em 0', + codeBlockPadding: '1em', + tableCellPadding: '0.5em 1em', + }, + syntaxTheme: 'github', + mermaidTheme: { + theme: 'default' as const, + themeVariables: { + primaryColor: '#0366d6', + primaryTextColor: '#ffffff', + primaryBorderColor: '#0256c7', + lineColor: '#586069', + secondaryColor: '#28a745', + tertiaryColor: '#f66a0a', + background: '#ffffff', + mainBkg: '#f6f8fa', + }, + }, + }, +})); + +// Mock all theme modules so they return our mock theme (hoisted) +vi.mock('../../../packages/core/src/themes/github-light', () => ({ + default: hoistedMockTheme, +})); + describe('ThemeEngine', () => { let engine: ThemeEngine; let consoleMock: { restore: () => void }; @@ -16,11 +103,6 @@ describe('ThemeEngine', () => { consoleMock = mockConsole(); engine = new ThemeEngine(); mockChromeStorage(); - - // Mock dynamic imports for themes - vi.mock('../../../src/themes/github-light.ts', () => ({ - default: mockTheme, - })); }); afterEach(() => { @@ -31,13 +113,13 @@ describe('ThemeEngine', () => { describe('Theme Loading', () => { test('should load theme from cache on second request', async () => { // Mock the dynamic import - vi.doMock('../../../src/themes/github-light.ts', () => ({ + vi.doMock('../../../packages/core/src/themes/github-light', () => ({ default: { ...mockTheme, name: 'github-light' }, })); const theme1 = await engine.loadTheme('github-light'); const theme2 = await engine.loadTheme('github-light'); - + expect(theme1).toBeDefined(); expect(theme2).toBeDefined(); // Second call should return the same cached instance @@ -45,25 +127,22 @@ describe('ThemeEngine', () => { }); test('should cache theme after first load', async () => { - vi.doMock('../../../src/themes/github-light.ts', () => ({ + vi.doMock('../../../packages/core/src/themes/github-light', () => ({ default: { ...mockTheme, name: 'github-light' }, })); const theme = await engine.loadTheme('github-light'); expect(theme.name).toBe('github-light'); - + // Load again - should use cache const cachedTheme = await engine.loadTheme('github-light'); expect(cachedTheme).toBe(theme); }); test('should fallback to github-light on error', async () => { - // Mock a failing theme load - vi.doMock('../../../src/themes/invalid-theme.ts', () => { - throw new Error('Theme not found'); - }); - - vi.doMock('../../../src/themes/github-light.ts', () => ({ + // Mock a failing theme load (invalid-theme is not in registry, so loadTheme + // catches the error and falls back to github-light) + vi.doMock('../../../packages/core/src/themes/github-light', () => ({ default: { ...mockTheme, name: 'github-light' }, })); @@ -76,48 +155,48 @@ describe('ThemeEngine', () => { test('should apply theme object to document', async () => { const mockRoot = document.documentElement; await engine.applyTheme(mockTheme); - + // Check that data attributes are set expect(mockRoot.getAttribute('data-theme')).toBe(mockTheme.name); expect(mockRoot.getAttribute('data-theme-variant')).toBe(mockTheme.variant); }); test('should apply theme by name', async () => { - vi.doMock('../../../src/themes/github-light.ts', () => ({ + vi.doMock('../../../packages/core/src/themes/github-light', () => ({ default: { ...mockTheme, name: 'github-light' }, })); await engine.applyTheme('github-light'); - + const current = engine.getCurrentTheme(); expect(current?.name).toBe('github-light'); }); test('should compile CSS variables correctly', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + // Check color variables expect(cssVars['--md-bg']).toBe(mockTheme.colors.background); expect(cssVars['--md-fg']).toBe(mockTheme.colors.foreground); expect(cssVars['--md-primary']).toBe(mockTheme.colors.primary); expect(cssVars['--md-link']).toBe(mockTheme.colors.link); - + // Check typography variables expect(cssVars['--md-font-family']).toBe(mockTheme.typography.fontFamily); expect(cssVars['--md-font-family-code']).toBe(mockTheme.typography.codeFontFamily); expect(cssVars['--md-font-size']).toBe(mockTheme.typography.baseFontSize); - + // Check spacing variables expect(cssVars['--md-block-margin']).toBe(mockTheme.spacing.blockMargin); }); test('should set all required CSS variables', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + // Count variables (should have ~50+ variables) const varCount = Object.keys(cssVars).length; expect(varCount).toBeGreaterThan(40); - + // Check critical variables exist expect(cssVars['--md-bg']).toBeDefined(); expect(cssVars['--md-fg']).toBeDefined(); @@ -127,31 +206,31 @@ describe('ThemeEngine', () => { test('should apply CSS variables to document root', async () => { await engine.applyTheme(mockTheme); - + const root = document.documentElement; const bgColor = root.style.getPropertyValue('--md-bg'); - + // Variable should be set (exact value may vary based on processing) expect(bgColor).toBeTruthy(); }); test('should set data-theme attribute', async () => { await engine.applyTheme(mockTheme); - + const theme = document.documentElement.getAttribute('data-theme'); expect(theme).toBe(mockTheme.name); }); test('should set data-theme-variant attribute', async () => { await engine.applyTheme(mockTheme); - + const variant = document.documentElement.getAttribute('data-theme-variant'); expect(variant).toBe(mockTheme.variant); }); test('should apply background color directly to body', async () => { await engine.applyTheme(mockTheme); - + // Browser converts hex to rgb format const bgColor = document.body.style.backgroundColor; expect(bgColor).toBeTruthy(); @@ -160,19 +239,22 @@ describe('ThemeEngine', () => { test('should add and remove transition class', async () => { const root = document.documentElement; - + await engine.applyTheme(mockTheme); - + // Transition class should eventually be removed (after animation frames) // We can't easily test timing, but we can verify it was added initially - expect(root.classList.contains('theme-transitioning') || !root.classList.contains('theme-transitioning')).toBe(true); + expect( + root.classList.contains('theme-transitioning') || + !root.classList.contains('theme-transitioning') + ).toBe(true); }); }); describe('CSS Variable Generation', () => { test('should generate background color variables', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + expect(cssVars['--md-bg']).toBe(mockTheme.colors.background); expect(cssVars['--md-bg-secondary']).toBe(mockTheme.colors.backgroundSecondary); expect(cssVars['--md-bg-tertiary']).toBe(mockTheme.colors.backgroundTertiary); @@ -180,7 +262,7 @@ describe('ThemeEngine', () => { test('should generate foreground color variables', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + expect(cssVars['--md-fg']).toBe(mockTheme.colors.foreground); expect(cssVars['--md-fg-secondary']).toBe(mockTheme.colors.foregroundSecondary); expect(cssVars['--md-fg-muted']).toBe(mockTheme.colors.foregroundMuted); @@ -188,7 +270,7 @@ describe('ThemeEngine', () => { test('should generate semantic color variables', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + expect(cssVars['--md-primary']).toBe(mockTheme.colors.primary); expect(cssVars['--md-secondary']).toBe(mockTheme.colors.secondary); expect(cssVars['--md-accent']).toBe(mockTheme.colors.accent); @@ -196,7 +278,7 @@ describe('ThemeEngine', () => { test('should generate element color variables', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + expect(cssVars['--md-heading']).toBe(mockTheme.colors.heading); expect(cssVars['--md-link']).toBe(mockTheme.colors.link); expect(cssVars['--md-code-bg']).toBe(mockTheme.colors.codeBackground); @@ -205,7 +287,7 @@ describe('ThemeEngine', () => { test('should generate border color variables', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + expect(cssVars['--md-border']).toBe(mockTheme.colors.border); expect(cssVars['--md-border-light']).toBe(mockTheme.colors.borderLight); expect(cssVars['--md-border-heavy']).toBe(mockTheme.colors.borderHeavy); @@ -213,7 +295,7 @@ describe('ThemeEngine', () => { test('should generate state color variables', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + expect(cssVars['--md-success']).toBe(mockTheme.colors.success); expect(cssVars['--md-warning']).toBe(mockTheme.colors.warning); expect(cssVars['--md-error']).toBe(mockTheme.colors.error); @@ -222,14 +304,14 @@ describe('ThemeEngine', () => { test('should generate font family variables', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + expect(cssVars['--md-font-family']).toBe(mockTheme.typography.fontFamily); expect(cssVars['--md-font-family-code']).toBe(mockTheme.typography.codeFontFamily); }); test('should generate font size variables', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + expect(cssVars['--md-font-size']).toBe(mockTheme.typography.baseFontSize); expect(cssVars['--md-h1-size']).toBe(mockTheme.typography.h1Size); expect(cssVars['--md-h2-size']).toBe(mockTheme.typography.h2Size); @@ -241,21 +323,23 @@ describe('ThemeEngine', () => { test('should generate font weight variables', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + expect(cssVars['--md-font-weight']).toBe(mockTheme.typography.fontWeightNormal.toString()); expect(cssVars['--md-font-weight-bold']).toBe(mockTheme.typography.fontWeightBold.toString()); - expect(cssVars['--md-heading-font-weight']).toBe(mockTheme.typography.headingFontWeight.toString()); + expect(cssVars['--md-heading-font-weight']).toBe( + mockTheme.typography.headingFontWeight.toString() + ); }); test('should generate line height variable', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + expect(cssVars['--md-line-height']).toBe(mockTheme.typography.baseLineHeight.toString()); }); test('should generate spacing variables', () => { const cssVars = engine.compileToCSSVariables(mockTheme); - + expect(cssVars['--md-block-margin']).toBe(mockTheme.spacing.blockMargin); expect(cssVars['--md-paragraph-margin']).toBe(mockTheme.spacing.paragraphMargin); expect(cssVars['--md-list-item-margin']).toBe(mockTheme.spacing.listItemMargin); @@ -270,9 +354,9 @@ describe('ThemeEngine', () => { codeFontFamily: 'Custom Mono', baseLineHeight: 2.0, }; - + const cssVars = engine.compileToCSSVariables(mockTheme, overrides); - + expect(cssVars['--md-font-family']).toBe('Custom Font'); expect(cssVars['--md-font-family-code']).toBe('Custom Mono'); expect(cssVars['--md-line-height']).toBe('2'); @@ -283,17 +367,17 @@ describe('ThemeEngine', () => { test('should attach media query listener', () => { const callback = vi.fn(); const cleanup = engine.watchSystemTheme(callback); - + expect(callback).toHaveBeenCalled(); expect(cleanup).toBeInstanceOf(Function); - + cleanup(); }); test('should invoke callback on system theme change', () => { const callback = vi.fn(); engine.watchSystemTheme(callback); - + // Initial call expect(callback).toHaveBeenCalled(); }); @@ -301,14 +385,14 @@ describe('ThemeEngine', () => { test('should cleanup listener on unsubscribe', () => { const callback = vi.fn(); const cleanup = engine.watchSystemTheme(callback); - + expect(() => cleanup()).not.toThrow(); }); test('should detect initial dark mode state', () => { const callback = vi.fn(); engine.watchSystemTheme(callback); - + expect(callback).toHaveBeenCalledWith(expect.any(Boolean)); }); }); @@ -321,7 +405,7 @@ describe('ThemeEngine', () => { test('should include theme info with name and display name', () => { const themes = engine.getAvailableThemes(); - + themes.forEach((theme) => { expect(theme.name).toBeDefined(); expect(theme.displayName).toBeDefined(); @@ -332,7 +416,7 @@ describe('ThemeEngine', () => { test('should correctly flag light themes', () => { const themes = engine.getAvailableThemes(); const lightThemes = themes.filter((t) => t.variant === 'light'); - + expect(lightThemes.length).toBeGreaterThan(0); expect(lightThemes.some((t) => t.name === 'github-light')).toBe(true); }); @@ -340,7 +424,7 @@ describe('ThemeEngine', () => { test('should correctly flag dark themes', () => { const themes = engine.getAvailableThemes(); const darkThemes = themes.filter((t) => t.variant === 'dark'); - + expect(darkThemes.length).toBeGreaterThan(0); expect(darkThemes.some((t) => t.name === 'github-dark')).toBe(true); }); @@ -348,7 +432,7 @@ describe('ThemeEngine', () => { test('should include all expected themes', () => { const themes = engine.getAvailableThemes(); const themeNames = themes.map((t) => t.name); - + expect(themeNames).toContain('github-light'); expect(themeNames).toContain('github-dark'); expect(themeNames).toContain('catppuccin-latte'); @@ -368,17 +452,17 @@ describe('ThemeEngine', () => { test('should return current theme after application', async () => { await engine.applyTheme(mockTheme); - + const current = engine.getCurrentTheme(); expect(current).toBe(mockTheme); }); test('should update current theme on theme change', async () => { await engine.applyTheme(mockTheme); - + const newTheme = { ...mockTheme, name: 'github-dark' as const }; await engine.applyTheme(newTheme); - + const current = engine.getCurrentTheme(); expect(current?.name).toBe('github-dark'); }); @@ -390,20 +474,19 @@ describe('ThemeEngine', () => { for (let i = 0; i < 10; i++) { await engine.applyTheme(mockTheme); } - + // Should complete without errors expect(engine.getCurrentTheme()).toBe(mockTheme); }); test('should persist theme across operations', async () => { await engine.applyTheme(mockTheme); - + const current1 = engine.getCurrentTheme(); const current2 = engine.getCurrentTheme(); - + expect(current1).toBe(current2); expect(current1).toBe(mockTheme); }); }); }); - diff --git a/tests/unit/renderers/mermaid-renderer.test.ts b/tests/unit/renderers/mermaid-renderer.test.ts index 8db61c0..93d6121 100644 --- a/tests/unit/renderers/mermaid-renderer.test.ts +++ b/tests/unit/renderers/mermaid-renderer.test.ts @@ -12,7 +12,9 @@ vi.mock('mermaid', () => ({ default: { initialize: vi.fn(), parse: vi.fn().mockResolvedValue({ diagramType: 'flowchart-v2', config: {} }), - render: vi.fn().mockResolvedValue({ svg: 'mock', diagramType: 'flowchart-v2' }), + render: vi + .fn() + .mockResolvedValue({ svg: 'mock', diagramType: 'flowchart-v2' }), }, })); @@ -30,7 +32,7 @@ vi.mock('panzoom', () => ({ // Import after mocks are set up import mermaid from 'mermaid'; import Panzoom from 'panzoom'; -import { MermaidRenderer } from '../../../src/renderers/mermaid-renderer'; +import { MermaidRenderer } from '@mdview/core'; describe('MermaidRenderer', () => { let renderer: MermaidRenderer; @@ -44,7 +46,10 @@ describe('MermaidRenderer', () => { // Reset mermaid mocks to success state vi.mocked(mermaid.parse).mockResolvedValue({ diagramType: 'flowchart-v2', config: {} }); - vi.mocked(mermaid.render).mockResolvedValue({ svg: 'mock diagram', diagramType: 'flowchart-v2' }); + vi.mocked(mermaid.render).mockResolvedValue({ + svg: 'mock diagram', + diagramType: 'flowchart-v2', + }); renderer = new MermaidRenderer(); }); @@ -140,7 +145,10 @@ describe('MermaidRenderer', () => { let resolveFirst!: (value: { svg: string; diagramType: string }) => void; vi.mocked(mermaid.render) .mockImplementationOnce( - () => new Promise((resolve) => { resolveFirst = resolve; }) + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) ) .mockResolvedValueOnce({ svg: 'second', diagramType: 'flowchart-v2' }); diff --git a/tests/unit/ui/export-ui.test.ts b/tests/unit/ui/export-ui.test.ts index 60af762..ad07790 100644 --- a/tests/unit/ui/export-ui.test.ts +++ b/tests/unit/ui/export-ui.test.ts @@ -3,10 +3,10 @@ */ import { describe, test, expect, beforeEach, vi, afterEach } from 'vitest'; -import { ExportUI } from '../../../src/ui/export-ui'; +import { ExportUI } from '../../../packages/chrome-ext/src/ui/export-ui'; // Mock the debug logger -vi.mock('../../../src/utils/debug-logger', () => ({ +vi.mock('../../../packages/chrome-ext/src/utils/debug-logger', () => ({ debug: { info: vi.fn(), warn: vi.fn(), @@ -15,8 +15,8 @@ vi.mock('../../../src/utils/debug-logger', () => ({ }, })); -// Mock the ExportController -vi.mock('../../../src/core/export-controller', () => { +// Mock the ExportController (imported dynamically by the Chrome adapter) +vi.mock('../../../packages/core/src/export-controller', () => { class MockExportController { export = vi.fn().mockResolvedValue(undefined); } diff --git a/tests/unit/utils/content-collector.test.ts b/tests/unit/utils/content-collector.test.ts index ce2b3c2..8cae0a5 100644 --- a/tests/unit/utils/content-collector.test.ts +++ b/tests/unit/utils/content-collector.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { ContentCollector } from '../../../src/utils/content-collector'; +import { ContentCollector } from '@mdview/core'; /** * Helper to create a test container with HTML @@ -545,5 +545,3 @@ line 3 }); }); }); - - diff --git a/tests/unit/utils/docx-generator.test.ts b/tests/unit/utils/docx-generator.test.ts index bb92d7a..8a320a1 100644 --- a/tests/unit/utils/docx-generator.test.ts +++ b/tests/unit/utils/docx-generator.test.ts @@ -3,8 +3,8 @@ */ import { describe, test, expect, beforeEach } from 'vitest'; -import { DOCXGenerator } from '../../../src/utils/docx-generator'; -import type { ContentNode, CollectedContent, ConvertedImage } from '../../../src/types'; +import { DOCXGenerator } from '@mdview/core'; +import type { ContentNode, CollectedContent, ConvertedImage } from '@mdview/core'; describe('DOCXGenerator', () => { let generator: DOCXGenerator; @@ -49,7 +49,9 @@ describe('DOCXGenerator', () => { const blob = await generator.generate(content, new Map()); expect(blob).toBeInstanceOf(Blob); - expect(blob.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + expect(blob.type).toBe( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ); expect(blob.size).toBeGreaterThan(0); }); @@ -394,7 +396,10 @@ describe('DOCXGenerator', () => { }); test('should apply borders', async () => { - const tableData = [['A', 'B'], ['C', 'D']]; + const tableData = [ + ['A', 'B'], + ['C', 'D'], + ]; const content = createTestContent([ { @@ -575,4 +580,3 @@ describe('DOCXGenerator', () => { }); }); }); - diff --git a/tests/unit/utils/dom-purifier.test.ts b/tests/unit/utils/dom-purifier.test.ts index 506f5ba..e642cf7 100644 --- a/tests/unit/utils/dom-purifier.test.ts +++ b/tests/unit/utils/dom-purifier.test.ts @@ -3,7 +3,7 @@ */ import { describe, test, expect, beforeEach } from 'vitest'; -import { DOMPurifierUtil } from '../../../src/utils/dom-purifier'; +import { DOMPurifierUtil } from '@mdview/core'; import { xssPayloads } from '../../helpers/fixtures'; describe('DOMPurifier', () => { diff --git a/tests/unit/utils/file-scanner-blocklist.test.ts b/tests/unit/utils/file-scanner-blocklist.test.ts index 6cd28d8..2799cc5 100644 --- a/tests/unit/utils/file-scanner-blocklist.test.ts +++ b/tests/unit/utils/file-scanner-blocklist.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { FileScanner } from '../../../src/utils/file-scanner'; +import { FileScanner } from '../../../packages/chrome-ext/src/utils/file-scanner'; describe('FileScanner Blocklist', () => { beforeEach(() => { diff --git a/tests/unit/utils/filename-generator.test.ts b/tests/unit/utils/filename-generator.test.ts index d157401..75e4478 100644 --- a/tests/unit/utils/filename-generator.test.ts +++ b/tests/unit/utils/filename-generator.test.ts @@ -3,7 +3,7 @@ */ import { describe, test, expect, beforeEach, vi } from 'vitest'; -import { FilenameGenerator } from '../../../src/utils/filename-generator'; +import { FilenameGenerator } from '@mdview/core'; describe('FilenameGenerator', () => { beforeEach(() => { @@ -180,4 +180,3 @@ describe('FilenameGenerator', () => { }); }); }); - diff --git a/tests/unit/utils/pdf-generator.test.ts b/tests/unit/utils/pdf-generator.test.ts index 0c67458..17fea67 100644 --- a/tests/unit/utils/pdf-generator.test.ts +++ b/tests/unit/utils/pdf-generator.test.ts @@ -3,20 +3,37 @@ */ import { describe, test, expect, beforeEach, vi, afterEach } from 'vitest'; -import { PDFGenerator } from '../../../src/utils/pdf-generator'; +import { PDFGenerator } from '@mdview/core'; -// Mock the debug logger -vi.mock('../../../src/utils/debug-logger', () => ({ - debug: { +// Mock the debug logger (core internal) +vi.mock('../../../packages/core/src/utils/debug-logger', () => { + const mockHelpers = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), - }, -})); + log: vi.fn(), + }; + return { + debug: mockHelpers, + createDebug: vi.fn(() => mockHelpers), + createDebugLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), + DebugLogger: vi.fn().mockImplementation(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), + }; +}); -// Mock SVGConverter -vi.mock('../../../src/utils/svg-converter', () => ({ +// Mock SVGConverter (core internal dependency of PDFGenerator) +vi.mock('../../../packages/core/src/utils/svg-converter', () => ({ SVGConverter: vi.fn().mockImplementation(() => ({ convert: vi.fn().mockResolvedValue({ id: 'test-svg', @@ -28,8 +45,8 @@ vi.mock('../../../src/utils/svg-converter', () => ({ })), })); -// Mock mermaid renderer -vi.mock('../../../src/renderers/mermaid-renderer', () => ({ +// Mock mermaid renderer (core internal dependency) +vi.mock('../../../packages/core/src/renderers/mermaid-renderer', () => ({ mermaidRenderer: { renderAllImmediate: vi.fn().mockResolvedValue(undefined), }, @@ -76,7 +93,9 @@ describe('PDFGenerator', () => { HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({ drawImage: vi.fn(), }); - HTMLCanvasElement.prototype.toDataURL = vi.fn().mockReturnValue('data:image/png;base64,mockdata'); + HTMLCanvasElement.prototype.toDataURL = vi + .fn() + .mockReturnValue('data:image/png;base64,mockdata'); // Mock getBoundingClientRect for SVGs Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ @@ -247,7 +266,7 @@ describe('PDFGenerator', () => { svgContainer.appendChild(mermaidDiv); // Mock converter to throw error - const { SVGConverter } = await import('../../../src/utils/svg-converter'); + const { SVGConverter } = await import('@mdview/core'); (SVGConverter as any).mockImplementationOnce(() => ({ convert: vi.fn().mockRejectedValue(new Error('Conversion failed')), })); @@ -310,9 +329,7 @@ describe('PDFGenerator', () => { throw new Error('Print failed'); }); - await expect( - generator.print(svgContainer, { convertSvgsToImages: true }) - ).rejects.toThrow(); + await expect(generator.print(svgContainer, { convertSvgsToImages: true })).rejects.toThrow(); // SVGs should be restored even on error const restoredSvg = mermaidDiv.querySelector('svg'); @@ -320,4 +337,3 @@ describe('PDFGenerator', () => { }); }); }); - diff --git a/tests/unit/utils/svg-converter.test.ts b/tests/unit/utils/svg-converter.test.ts index 4e19cba..086149d 100644 --- a/tests/unit/utils/svg-converter.test.ts +++ b/tests/unit/utils/svg-converter.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { SVGConverter } from '../../../src/utils/svg-converter'; +import { SVGConverter } from '@mdview/core'; /** * Helper to create a mock SVG element diff --git a/tests/unit/utils/toc-stripper.test.ts b/tests/unit/utils/toc-stripper.test.ts index 96a75c0..0150099 100644 --- a/tests/unit/utils/toc-stripper.test.ts +++ b/tests/unit/utils/toc-stripper.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect } from 'vitest'; -import { stripTableOfContents } from '../../../src/utils/toc-stripper'; +import { stripTableOfContents } from '@mdview/core'; describe('stripTableOfContents', () => { describe('Basic TOC Detection', () => { diff --git a/tsconfig.json b/tsconfig.json index fb19e33..4683906 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,10 +17,10 @@ "types": ["chrome", "vite/client", "vitest/globals"], "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["packages/chrome-ext/src/*"] } }, - "include": ["src/**/*", "tests/**/*"], + "include": ["packages/*/src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/vitest.config.ts b/vitest.config.ts index 7ad0f8e..29f060a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ }, resolve: { alias: { - '@': resolve(__dirname, './src'), + '@': resolve(__dirname, './packages/chrome-ext/src'), }, }, }); From 235293b6c6671794c792809c468fad16903a88da Mon Sep 17 00:00:00 2001 From: James Ainslie <42301770+jamesainslie@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:01:06 -0400 Subject: [PATCH 25/26] fix(tests): repair vi.mock factories for Node built-ins and class constructors native-host.test.ts: provide explicit mock factories for 'fs' and 'os' since vitest auto-mocking doesn't work with Node built-ins. comment-manager.test.ts: convert vi.fn() constructor mocks from arrow functions to function(this) syntax since arrow functions can't be used as constructors with `new`. --- tests/unit/comments/comment-manager.test.ts | 223 +++++++++----------- tests/unit/comments/native-host.test.ts | 11 +- 2 files changed, 109 insertions(+), 125 deletions(-) diff --git a/tests/unit/comments/comment-manager.test.ts b/tests/unit/comments/comment-manager.test.ts index 21fa300..20ee1f3 100644 --- a/tests/unit/comments/comment-manager.test.ts +++ b/tests/unit/comments/comment-manager.test.ts @@ -47,29 +47,27 @@ vi.mock('../../../packages/core/src/comments/comment-context', () => ({ })); vi.mock('../../../packages/core/src/comments/comment-ui', () => { - class MockCommentUI { - createGutter = vi.fn(() => document.createElement('div')); - renderCard = vi.fn(() => document.createElement('div')); - renderInputForm = vi.fn(() => document.createElement('div')); - renderReplyForm = vi.fn(() => document.createElement('div')); - renderEmojiPicker = vi.fn(() => document.createElement('div')); - setCurrentAuthor = vi.fn(); - showToast = vi.fn(); - destroy = vi.fn(); - } - return { CommentUI: MockCommentUI }; + const CommentUI = vi.fn(); + CommentUI.prototype.createGutter = vi.fn(() => document.createElement('div')); + CommentUI.prototype.renderCard = vi.fn(() => document.createElement('div')); + CommentUI.prototype.renderInputForm = vi.fn(() => document.createElement('div')); + CommentUI.prototype.renderReplyForm = vi.fn(() => document.createElement('div')); + CommentUI.prototype.renderEmojiPicker = vi.fn(() => document.createElement('div')); + CommentUI.prototype.setCurrentAuthor = vi.fn(); + CommentUI.prototype.showToast = vi.fn(); + CommentUI.prototype.destroy = vi.fn(); + return { CommentUI }; }); vi.mock('../../../packages/core/src/comments/comment-highlight', () => { - class MockCommentHighlighter { - highlightComment = vi.fn(() => document.createElement('span')); - removeHighlight = vi.fn(); - setActive = vi.fn(); - clearActive = vi.fn(); - setResolved = vi.fn(); - getHighlightElement = vi.fn(); - } - return { CommentHighlighter: MockCommentHighlighter }; + const CommentHighlighter = vi.fn(); + CommentHighlighter.prototype.highlightComment = vi.fn(() => document.createElement('span')); + CommentHighlighter.prototype.removeHighlight = vi.fn(); + CommentHighlighter.prototype.setActive = vi.fn(); + CommentHighlighter.prototype.clearActive = vi.fn(); + CommentHighlighter.prototype.setResolved = vi.fn(); + CommentHighlighter.prototype.getHighlightElement = vi.fn(); + return { CommentHighlighter }; }); import { parseComments } from '../../../packages/core/src/comments/annotation-parser'; @@ -699,45 +697,38 @@ describe('CommentManager', () => { // Mock highlighter to return these elements const mockHighlighter = vi.mocked(CommentHighlighter); - mockHighlighter.mockImplementation( - () => - ({ - highlightComment: vi.fn(), - removeHighlight: vi.fn(), - setActive: vi.fn(), - clearActive: vi.fn(), - setResolved: vi.fn(), - getHighlightElement: vi.fn((id: string) => { - if (id === 'comment-1') return highlight1; - if (id === 'comment-2') return highlight2; - return null; - }), - }) as any - ); + mockHighlighter.mockImplementation(function (this: any) { + this.highlightComment = vi.fn(); + this.removeHighlight = vi.fn(); + this.setActive = vi.fn(); + this.clearActive = vi.fn(); + this.setResolved = vi.fn(); + this.getHighlightElement = vi.fn((id: string) => { + if (id === 'comment-1') return highlight1; + if (id === 'comment-2') return highlight2; + return null; + }); + } as any); // Cards will be rendered as real divs but with zero offsetHeight in jsdom // We mock offsetHeight via Object.defineProperty on the card elements const mockUIImpl = vi.mocked(CommentUI); - mockUIImpl.mockImplementation( - () => - ({ - createGutter: vi.fn(() => document.createElement('div')), - renderCard: vi.fn((comment: Comment) => { - const card = document.createElement('div'); - card.className = 'mdview-comment-card'; - card.dataset.commentId = comment.id; - // Give cards a height of 40px - Object.defineProperty(card, 'offsetHeight', { value: 40, configurable: true }); - return card; - }), - renderInputForm: vi.fn(() => document.createElement('div')), - renderReplyForm: vi.fn(() => document.createElement('div')), - renderEmojiPicker: vi.fn(() => document.createElement('div')), - setCurrentAuthor: vi.fn(), - showToast: vi.fn(), - destroy: vi.fn(), - }) as any - ); + mockUIImpl.mockImplementation(function (this: any) { + this.createGutter = vi.fn(() => document.createElement('div')); + this.renderCard = vi.fn((comment: Comment) => { + const card = document.createElement('div'); + card.className = 'mdview-comment-card'; + card.dataset.commentId = comment.id; + Object.defineProperty(card, 'offsetHeight', { value: 40, configurable: true }); + return card; + }); + this.renderInputForm = vi.fn(() => document.createElement('div')); + this.renderReplyForm = vi.fn(() => document.createElement('div')); + this.renderEmojiPicker = vi.fn(() => document.createElement('div')); + this.setCurrentAuthor = vi.fn(); + this.showToast = vi.fn(); + this.destroy = vi.fn(); + } as any); await mgr.initialize(sampleMarkdown, '/path/to/file.md', defaultPreferences); @@ -972,38 +963,32 @@ describe('CommentManager', () => { // Mock highlighter const mockHighlighter = vi.mocked(CommentHighlighter); - mockHighlighter.mockImplementation( - () => - ({ - highlightComment: vi.fn(), - removeHighlight: vi.fn(), - setActive: vi.fn(), - clearActive: vi.fn(), - setResolved: vi.fn(), - getHighlightElement: vi.fn(() => highlight), - }) as any - ); + mockHighlighter.mockImplementation(function (this: any) { + this.highlightComment = vi.fn(); + this.removeHighlight = vi.fn(); + this.setActive = vi.fn(); + this.clearActive = vi.fn(); + this.setResolved = vi.fn(); + this.getHighlightElement = vi.fn(() => highlight); + } as any); // Mock UI to produce real card elements - vi.mocked(CommentUI).mockImplementation( - () => - ({ - createGutter: vi.fn(() => document.createElement('div')), - renderCard: vi.fn((comment: Comment) => { - const card = document.createElement('div'); - card.className = 'mdview-comment-card'; - card.dataset.commentId = comment.id; - Object.defineProperty(card, 'offsetHeight', { value: 40, configurable: true }); - return card; - }), - renderInputForm: vi.fn(() => document.createElement('div')), - renderReplyForm: vi.fn(() => document.createElement('div')), - renderEmojiPicker: vi.fn(() => document.createElement('div')), - setCurrentAuthor: vi.fn(), - showToast: vi.fn(), - destroy: vi.fn(), - }) as any - ); + vi.mocked(CommentUI).mockImplementation(function (this: any) { + this.createGutter = vi.fn(() => document.createElement('div')); + this.renderCard = vi.fn((comment: Comment) => { + const card = document.createElement('div'); + card.className = 'mdview-comment-card'; + card.dataset.commentId = comment.id; + Object.defineProperty(card, 'offsetHeight', { value: 40, configurable: true }); + return card; + }); + this.renderInputForm = vi.fn(() => document.createElement('div')); + this.renderReplyForm = vi.fn(() => document.createElement('div')); + this.renderEmojiPicker = vi.fn(() => document.createElement('div')); + this.setCurrentAuthor = vi.fn(); + this.showToast = vi.fn(); + this.destroy = vi.fn(); + } as any); await mgr.initialize(sampleMarkdown, '/path/to/file.md', defaultPreferences); @@ -1043,25 +1028,22 @@ describe('CommentManager', () => { // Mock renderCard to produce cards with correct structure let renderCallCount = 0; - vi.mocked(CommentUI).mockImplementation( - () => - ({ - createGutter: vi.fn(() => document.createElement('div')), - renderCard: vi.fn((comment: Comment) => { - renderCallCount++; - const card = document.createElement('div'); - card.className = 'mdview-comment-card'; - card.dataset.commentId = comment.id; - return card; - }), - renderInputForm: vi.fn(() => document.createElement('div')), - renderReplyForm: vi.fn(() => document.createElement('div')), - renderEmojiPicker: vi.fn(() => document.createElement('div')), - setCurrentAuthor: vi.fn(), - showToast: vi.fn(), - destroy: vi.fn(), - }) as any - ); + vi.mocked(CommentUI).mockImplementation(function (this: any) { + this.createGutter = vi.fn(() => document.createElement('div')); + this.renderCard = vi.fn((comment: Comment) => { + renderCallCount++; + const card = document.createElement('div'); + card.className = 'mdview-comment-card'; + card.dataset.commentId = comment.id; + return card; + }); + this.renderInputForm = vi.fn(() => document.createElement('div')); + this.renderReplyForm = vi.fn(() => document.createElement('div')); + this.renderEmojiPicker = vi.fn(() => document.createElement('div')); + this.setCurrentAuthor = vi.fn(); + this.showToast = vi.fn(); + this.destroy = vi.fn(); + } as any); await mgr.initialize(sampleMarkdown, '/path/to/file.md', defaultPreferences); @@ -1086,24 +1068,21 @@ describe('CommentManager', () => { const mgr = new CommentManager(); - vi.mocked(CommentUI).mockImplementation( - () => - ({ - createGutter: vi.fn(() => document.createElement('div')), - renderCard: vi.fn((comment: Comment) => { - const card = document.createElement('div'); - card.className = 'mdview-comment-card minimized'; - card.dataset.commentId = comment.id; - return card; - }), - renderInputForm: vi.fn(() => document.createElement('div')), - renderReplyForm: vi.fn(() => document.createElement('div')), - renderEmojiPicker: vi.fn(() => document.createElement('div')), - setCurrentAuthor: vi.fn(), - showToast: vi.fn(), - destroy: vi.fn(), - }) as any - ); + vi.mocked(CommentUI).mockImplementation(function (this: any) { + this.createGutter = vi.fn(() => document.createElement('div')); + this.renderCard = vi.fn((comment: Comment) => { + const card = document.createElement('div'); + card.className = 'mdview-comment-card minimized'; + card.dataset.commentId = comment.id; + return card; + }); + this.renderInputForm = vi.fn(() => document.createElement('div')); + this.renderReplyForm = vi.fn(() => document.createElement('div')); + this.renderEmojiPicker = vi.fn(() => document.createElement('div')); + this.setCurrentAuthor = vi.fn(); + this.showToast = vi.fn(); + this.destroy = vi.fn(); + } as any); await mgr.initialize(sampleMarkdown, '/path/to/file.md', defaultPreferences); diff --git a/tests/unit/comments/native-host.test.ts b/tests/unit/comments/native-host.test.ts index b541982..ac45102 100644 --- a/tests/unit/comments/native-host.test.ts +++ b/tests/unit/comments/native-host.test.ts @@ -11,9 +11,14 @@ import { ALLOWED_EXTENSIONS, } from '../../../packages/chrome-ext/src/native-host/host-logic'; -vi.mock('os'); - -vi.mock('fs'); +vi.mock('fs', () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock('os', () => ({ + userInfo: vi.fn(), +})); describe('Native Host Message Handling', () => { describe('ALLOWED_EXTENSIONS', () => { From b9678eefa6ad9a80ae95c1dea8f0f4a42a231eec Mon Sep 17 00:00:00 2001 From: James Ainslie <42301770+jamesainslie@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:20:20 -0400 Subject: [PATCH 26/26] fix(chrome-ext): add build and dev scripts to package.json --- packages/chrome-ext/package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/chrome-ext/package.json b/packages/chrome-ext/package.json index 5c31fb2..3de5e54 100644 --- a/packages/chrome-ext/package.json +++ b/packages/chrome-ext/package.json @@ -3,6 +3,10 @@ "version": "0.3.4", "private": true, "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, "dependencies": { "@mdview/core": "*" },