diff --git a/src/renderer/components/MilkdownEditor/MilkdownEditor.css b/src/renderer/components/MilkdownEditor/MilkdownEditor.css index d9f0dc7..951cd06 100644 --- a/src/renderer/components/MilkdownEditor/MilkdownEditor.css +++ b/src/renderer/components/MilkdownEditor/MilkdownEditor.css @@ -330,3 +330,180 @@ .milkdown-content .ProseMirror:focus-visible { outline: none; } + +/* ================================= + Macro Styles + ================================= */ + +/* Base macro inline element in editor */ +.milkdown-macro { + display: inline-flex; + align-items: center; + padding: 2px 8px; + margin: 0 2px; + border-radius: 4px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 0.9em; + line-height: 1.4; + vertical-align: baseline; + cursor: default; + user-select: none; +} + +/* Known (registered) macro - teal/cyan shade */ +.milkdown-macro.macro-known { + background-color: rgba(0, 150, 136, 0.15); + border: 1px solid rgba(0, 150, 136, 0.3); + color: var(--vscode-textLink-foreground, #3794ff); +} + +/* Unknown macro - warning shade */ +.milkdown-macro.macro-unknown { + background-color: rgba(255, 152, 0, 0.15); + border: 1px solid rgba(255, 152, 0, 0.3); + color: var(--vscode-editorWarning-foreground, #cca700); +} + +/* Hover effect */ +.milkdown-macro:hover { + filter: brightness(1.1); +} + +/* Selected macro */ +.milkdown-macro.ProseMirror-selectednode { + outline: 2px solid var(--vscode-focusBorder, #007acc); + outline-offset: 1px; +} + +/* Macro icon prefix */ +.milkdown-macro::before { + margin-right: 4px; +} + +.milkdown-macro.macro-known::before { + content: "⬡"; +} + +.milkdown-macro.macro-unknown::before { + content: "⚠"; +} + +/* ================================= + Rendered Macro Styles (Preview) + ================================= */ + +/* Macro error display */ +.macro-error { + display: inline-block; + padding: 2px 6px; + background-color: rgba(255, 0, 0, 0.1); + border: 1px dashed rgba(255, 0, 0, 0.5); + border-radius: 3px; + color: var(--vscode-errorForeground, #f44); + font-family: monospace; + font-size: 0.85em; +} + +/* Gallery macro preview */ +.macro-gallery { + margin: 1em 0; + border-radius: 8px; + overflow: hidden; +} + +.macro-gallery.gallery-preview { + background-color: rgba(0, 150, 136, 0.08); + border: 1px dashed rgba(0, 150, 136, 0.3); + padding: 16px; +} + +.macro-gallery .gallery-placeholder { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + color: var(--vscode-descriptionForeground); +} + +.macro-gallery .gallery-icon { + font-size: 2em; +} + +.macro-gallery .gallery-info { + font-size: 0.9em; + font-weight: 500; +} + +.macro-gallery .gallery-caption { + font-size: 0.85em; + font-style: italic; + opacity: 0.8; +} + +/* YouTube macro preview */ +.macro-youtube { + margin: 1em 0; + border-radius: 8px; + overflow: hidden; +} + +.macro-youtube.youtube-preview .youtube-thumbnail { + position: relative; + width: 100%; + max-width: 480px; + aspect-ratio: 16 / 9; + background-size: cover; + background-position: center; + background-color: #000; + border-radius: 8px; + overflow: hidden; +} + +.macro-youtube .youtube-play-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.3); +} + +.macro-youtube .youtube-play-button { + width: 60px; + height: 42px; + background-color: rgba(255, 0, 0, 0.85); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 1.5em; +} + +.macro-youtube .youtube-title { + position: absolute; + bottom: 8px; + left: 8px; + right: 8px; + padding: 4px 8px; + background-color: rgba(0, 0, 0, 0.7); + color: white; + font-size: 0.85em; + border-radius: 4px; +} + +/* YouTube iframe container */ +.macro-youtube .youtube-container { + position: relative; + width: 100%; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ +} + +.macro-youtube .youtube-container iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; +} diff --git a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx index 519e8e0..eead493 100644 --- a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx +++ b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx @@ -16,6 +16,9 @@ import type { RemarkPlugin } from '@milkdown/kit/transformer'; import type { Root, List, ListItem } from 'mdast'; import type { Plugin } from 'unified'; import { visit } from 'unist-util-visit'; +import { macroPlugin } from '../../plugins/macroPlugin'; +// Import macros module to register all macro definitions +import '../../macros'; import './MilkdownEditor.css'; // Remark plugin to force tight lists (no blank lines between list items) @@ -217,6 +220,7 @@ const MilkdownProviderInner: React.FC = ({ }) .use(commonmark) .use(gfm) + .use(macroPlugin) .use(history) .use(listener) .use(clipboard) diff --git a/src/renderer/macros/definitions/gallery.ts b/src/renderer/macros/definitions/gallery.ts new file mode 100644 index 0000000..64825cc --- /dev/null +++ b/src/renderer/macros/definitions/gallery.ts @@ -0,0 +1,80 @@ +/** + * Gallery Macro + * + * Renders an image gallery from a linked media file or folder. + * + * Usage: [[gallery link="media/photos" columns="3" caption="My Photos"]] + * + * Parameters: + * - link (required): Path to media file or folder + * - columns: Number of columns (default: 3) + * - caption: Gallery caption + */ + +import { registerMacro } from '../registry'; +import type { MacroDefinition, MacroParams, MacroRenderContext } from '../types'; + +const galleryMacro: MacroDefinition = { + name: 'gallery', + description: 'Renders an image gallery from linked media', + + validate(params: MacroParams): string | undefined { + if (!params.link) { + return 'Gallery macro requires a "link" parameter'; + } + if (params.columns) { + const cols = parseInt(params.columns, 10); + if (isNaN(cols) || cols < 1 || cols > 6) { + return 'Gallery columns must be a number between 1 and 6'; + } + } + return undefined; + }, + + editorPreview(params: MacroParams): string { + const link = params.link || '?'; + return `📷 Gallery: ${link}`; + }, + + render(params: MacroParams, context: MacroRenderContext): string { + const { link, columns = '3', caption } = params; + const colCount = parseInt(columns, 10) || 3; + + // Build the gallery HTML + const classes = ['macro-gallery', `gallery-cols-${colCount}`]; + if (context.isPreview) { + classes.push('gallery-preview'); + } + + let html = `
`; + + // In preview mode, show a placeholder + // In production, this would load actual images + if (context.isPreview) { + html += ``; + } else { + // Production render would load images here + // For now, create a placeholder that frontend JS can hydrate + html += ``; + if (caption) { + html += ``; + } + } + + html += `
`; + return html; + }, +}; + +// Self-register +registerMacro(galleryMacro); + +export default galleryMacro; diff --git a/src/renderer/macros/definitions/index.ts b/src/renderer/macros/definitions/index.ts new file mode 100644 index 0000000..0a580dc --- /dev/null +++ b/src/renderer/macros/definitions/index.ts @@ -0,0 +1,17 @@ +/** + * Macro Definitions Index + * + * This file imports all macro definitions so they self-register. + * To add a new macro: + * 1. Create a new file in this folder (e.g., myMacro.ts) + * 2. Implement MacroDefinition interface + * 3. Call registerMacro() at the end + * 4. Import it here + */ + +// Import all macro definitions - they self-register on import +import './gallery'; +import './youtube'; + +// Add new macro imports here: +// import './myNewMacro'; diff --git a/src/renderer/macros/definitions/youtube.ts b/src/renderer/macros/definitions/youtube.ts new file mode 100644 index 0000000..68896e6 --- /dev/null +++ b/src/renderer/macros/definitions/youtube.ts @@ -0,0 +1,89 @@ +/** + * YouTube Macro + * + * Embeds a YouTube video player. + * + * Usage: [[youtube id="dQw4w9WgXcQ" title="Video Title"]] + * + * Parameters: + * - id (required): YouTube video ID + * - title: Accessible title for the iframe + * - start: Start time in seconds + * - autoplay: Whether to autoplay (default: false) + */ + +import { registerMacro } from '../registry'; +import type { MacroDefinition, MacroParams, MacroRenderContext } from '../types'; + +const youtubeMacro: MacroDefinition = { + name: 'youtube', + description: 'Embeds a YouTube video player', + + validate(params: MacroParams): string | undefined { + if (!params.id) { + return 'YouTube macro requires an "id" parameter (the video ID)'; + } + // Basic validation of YouTube ID format + if (!/^[\w-]{11}$/.test(params.id)) { + return 'Invalid YouTube video ID format'; + } + return undefined; + }, + + editorPreview(params: MacroParams): string { + const id = params.id || '?'; + const title = params.title; + return title ? `▶ YouTube: ${title}` : `▶ YouTube: ${id}`; + }, + + render(params: MacroParams, context: MacroRenderContext): string { + const { id, title = 'YouTube video', start, autoplay } = params; + + // Build embed URL with parameters + const embedParams = new URLSearchParams(); + if (start) { + embedParams.set('start', start); + } + if (autoplay === 'true') { + embedParams.set('autoplay', '1'); + } + embedParams.set('rel', '0'); // Don't show related videos + + const queryString = embedParams.toString(); + const embedUrl = `https://www.youtube.com/embed/${id}${queryString ? '?' + queryString : ''}`; + + if (context.isPreview) { + // In preview, show thumbnail with play button overlay + return ` +
+
+
+ +
+ ${title} +
+
+ `; + } + + // Production render - full iframe embed + return ` +
+
+ +
+
+ `; + }, +}; + +// Self-register +registerMacro(youtubeMacro); + +export default youtubeMacro; diff --git a/src/renderer/macros/index.ts b/src/renderer/macros/index.ts new file mode 100644 index 0000000..f7e0f6f --- /dev/null +++ b/src/renderer/macros/index.ts @@ -0,0 +1,42 @@ +/** + * Macros Module + * + * Provides a simple extension system for rendering custom content blocks + * in markdown using [[macro param="value"]] syntax. + * + * Usage: + * 1. Import this module to register all macros + * 2. Use the registry functions to render macros + * + * Adding new macros: + * 1. Create a file in ./definitions/ (e.g., myMacro.ts) + * 2. Implement MacroDefinition interface + * 3. Call registerMacro() to self-register + * 4. Import the file in ./definitions/index.ts + */ + +// Import all macro definitions so they register +import './definitions'; + +// Re-export types +export type { + MacroDefinition, + MacroParams, + MacroRenderContext, + ParsedMacro, +} from './types'; + +// Re-export registry functions +export { + registerMacro, + getMacro, + hasMacro, + getMacroNames, + getAllMacros, + clearMacros, + parseParams, + parseMacros, + renderMacro, + renderAllMacros, + getEditorPreview, +} from './registry'; diff --git a/src/renderer/macros/registry.ts b/src/renderer/macros/registry.ts new file mode 100644 index 0000000..fcece58 --- /dev/null +++ b/src/renderer/macros/registry.ts @@ -0,0 +1,207 @@ +/** + * Macro Registry + * + * Central registry for all macro definitions. + * Macros self-register using registerMacro() function. + */ + +import type { MacroDefinition, MacroParams, MacroRenderContext, ParsedMacro } from './types'; + +// Internal registry storage +const macroRegistry = new Map(); + +/** + * Register a macro definition. + * Call this from each macro definition file. + * + * @param macro - The macro definition to register + * @throws Error if a macro with the same name is already registered + */ +export function registerMacro(macro: MacroDefinition): void { + const name = macro.name.toLowerCase(); + if (macroRegistry.has(name)) { + console.warn(`Macro "${name}" is already registered. Overwriting.`); + } + macroRegistry.set(name, macro); +} + +/** + * Get a macro definition by name. + * + * @param name - The macro name (case-insensitive) + * @returns The macro definition or undefined if not found + */ +export function getMacro(name: string): MacroDefinition | undefined { + return macroRegistry.get(name.toLowerCase()); +} + +/** + * Check if a macro is registered. + * + * @param name - The macro name (case-insensitive) + */ +export function hasMacro(name: string): boolean { + return macroRegistry.has(name.toLowerCase()); +} + +/** + * Get all registered macro names. + */ +export function getMacroNames(): string[] { + return Array.from(macroRegistry.keys()); +} + +/** + * Get all registered macro definitions. + */ +export function getAllMacros(): MacroDefinition[] { + return Array.from(macroRegistry.values()); +} + +/** + * Clear all registered macros (useful for testing). + */ +export function clearMacros(): void { + macroRegistry.clear(); +} + +// Regex to match [[macroName param1="value1" param2='value2']] +// Supports both single and double quotes for values +const MACRO_REGEX = /\[\[(\w+)(?:\s+([^\]]+))?\]\]/g; + +// Regex to extract individual parameters +const PARAM_REGEX = /(\w+)=["']([^"']*?)["']/g; + +/** + * Parse parameters from a macro parameter string. + * + * @param paramString - The parameter string (e.g., 'link="file.jpg" caption="Hello"') + * @returns Parsed key-value pairs + */ +export function parseParams(paramString: string | undefined): MacroParams { + if (!paramString) return {}; + + const params: MacroParams = {}; + let match; + + while ((match = PARAM_REGEX.exec(paramString)) !== null) { + params[match[1]] = match[2]; + } + + // Reset regex lastIndex for next use + PARAM_REGEX.lastIndex = 0; + + return params; +} + +/** + * Parse all macros from a markdown string. + * + * @param markdown - The markdown content to parse + * @returns Array of parsed macros with their positions + */ +export function parseMacros(markdown: string): ParsedMacro[] { + const macros: ParsedMacro[] = []; + let match; + + while ((match = MACRO_REGEX.exec(markdown)) !== null) { + macros.push({ + name: match[1].toLowerCase(), + params: parseParams(match[2]), + rawText: match[0], + start: match.index, + end: match.index + match[0].length, + }); + } + + // Reset regex lastIndex for next use + MACRO_REGEX.lastIndex = 0; + + return macros; +} + +/** + * Render a single macro to HTML. + * + * @param macro - The parsed macro + * @param context - Render context + * @returns The rendered HTML or an error placeholder + */ +export async function renderMacro( + macro: ParsedMacro, + context: MacroRenderContext +): Promise { + const definition = getMacro(macro.name); + + if (!definition) { + return `${macro.rawText}`; + } + + // Validate if validator exists + if (definition.validate) { + const error = definition.validate(macro.params); + if (error) { + return `${macro.rawText}`; + } + } + + try { + const result = definition.render(macro.params, context); + return result instanceof Promise ? await result : result; + } catch (error) { + const message = error instanceof Error ? error.message : 'Render error'; + return `${macro.rawText}`; + } +} + +/** + * Render all macros in a markdown string to HTML. + * Returns the markdown with macros replaced by their rendered HTML. + * + * @param markdown - The markdown content + * @param context - Render context + * @returns Markdown with macros replaced by rendered HTML + */ +export async function renderAllMacros( + markdown: string, + context: MacroRenderContext +): Promise { + const macros = parseMacros(markdown); + + if (macros.length === 0) return markdown; + + // Render all macros in parallel + const rendered = await Promise.all( + macros.map(macro => renderMacro(macro, context)) + ); + + // Replace macros from end to start to preserve positions + let result = markdown; + for (let i = macros.length - 1; i >= 0; i--) { + const macro = macros[i]; + result = result.slice(0, macro.start) + rendered[i] + result.slice(macro.end); + } + + return result; +} + +/** + * Get the editor preview text for a macro. + * + * @param name - The macro name + * @param params - The macro parameters + * @returns Preview text for the editor + */ +export function getEditorPreview(name: string, params: MacroParams): string { + const definition = getMacro(name); + + if (!definition) { + return `⚠ ${name}`; + } + + if (definition.editorPreview) { + return definition.editorPreview(params); + } + + return `⬡ ${definition.name}`; +} diff --git a/src/renderer/macros/types.ts b/src/renderer/macros/types.ts new file mode 100644 index 0000000..53e6006 --- /dev/null +++ b/src/renderer/macros/types.ts @@ -0,0 +1,77 @@ +/** + * Interface for macro definitions. + * + * Macros are custom content blocks that can be inserted into markdown + * using the syntax: [[macroName param1="value1" param2="value2"]] + * + * They are rendered in the editor with a shaded background showing the raw syntax, + * and can be transformed to custom HTML during preview/export. + */ + +export interface MacroParams { + [key: string]: string; +} + +export interface MacroRenderContext { + /** The post ID if available */ + postId?: string; + /** Base path for resolving relative URLs */ + basePath?: string; + /** Whether rendering for preview (true) or final export (false) */ + isPreview: boolean; +} + +export interface MacroDefinition { + /** + * Unique name for the macro (lowercase, no spaces). + * Used in the syntax: [[name ...]] + */ + name: string; + + /** + * Human-readable description of what this macro does. + */ + description: string; + + /** + * Render the macro to HTML for preview/export. + * + * @param params - Key-value pairs from the macro syntax + * @param context - Additional context for rendering + * @returns HTML string to replace the macro with + */ + render(params: MacroParams, context: MacroRenderContext): string | Promise; + + /** + * Optional: Short preview text shown in the editor. + * If not provided, shows the macro name. + * + * @param params - Key-value pairs from the macro syntax + * @returns Preview text (not HTML, just plain text) + */ + editorPreview?(params: MacroParams): string; + + /** + * Optional: Validate macro parameters. + * + * @param params - Key-value pairs to validate + * @returns Error message if invalid, undefined if valid + */ + validate?(params: MacroParams): string | undefined; +} + +/** + * Parsed macro from markdown content + */ +export interface ParsedMacro { + /** The macro name */ + name: string; + /** Parsed parameters */ + params: MacroParams; + /** Original raw text including [[ and ]] */ + rawText: string; + /** Start position in the source text */ + start: number; + /** End position in the source text */ + end: number; +} diff --git a/src/renderer/plugins/macroPlugin.ts b/src/renderer/plugins/macroPlugin.ts new file mode 100644 index 0000000..c3309fb --- /dev/null +++ b/src/renderer/plugins/macroPlugin.ts @@ -0,0 +1,189 @@ +/** + * Milkdown Macro Plugin + * + * Provides parsing and rendering of [[macro param="value"]] syntax in Milkdown. + * Macros appear with a shaded background in the editor. + */ + +import { $node, $inputRule, $remark } from '@milkdown/kit/utils'; +import { InputRule } from '@milkdown/kit/prose/inputrules'; +import type { Node as ProseNode } from '@milkdown/kit/prose/model'; +import type { Plugin } from 'unified'; +import type { Root, Text, Parent } from 'mdast'; +import { visit } from 'unist-util-visit'; +import { getEditorPreview, parseParams, hasMacro } from '../macros/registry'; + +// Regex to match [[macroName param1="value1" param2='value2']] +const MACRO_REGEX = /\[\[(\w+)(?:\s+([^\]]+))?\]\]/g; + +/** + * Custom MDAST node type for macros + */ +interface MacroMdastNode { + type: 'macro'; + name: string; + params: Record; + raw: string; +} + +/** + * Remark plugin to parse [[macro]] syntax into macro nodes + */ +const remarkMacroParser: Plugin<[], Root> = () => { + return (tree: Root) => { + visit(tree, 'text', (node: Text, index: number | undefined, parent: Parent | undefined) => { + if (index === undefined || parent === undefined) return; + + const matches = [...node.value.matchAll(MACRO_REGEX)]; + if (matches.length === 0) return; + + // Build new nodes to replace this text node + const newNodes: (Text | MacroMdastNode)[] = []; + let lastIndex = 0; + + for (const match of matches) { + const [fullMatch, name, paramStr] = match; + const startIdx = match.index!; + + // Text before macro + if (startIdx > lastIndex) { + newNodes.push({ + type: 'text', + value: node.value.slice(lastIndex, startIdx), + }); + } + + // Macro node + newNodes.push({ + type: 'macro', + name: name.toLowerCase(), + params: parseParams(paramStr), + raw: fullMatch, + }); + + lastIndex = startIdx + fullMatch.length; + } + + // Text after last macro + if (lastIndex < node.value.length) { + newNodes.push({ + type: 'text', + value: node.value.slice(lastIndex), + }); + } + + // Replace the text node with our new nodes + parent.children.splice(index, 1, ...(newNodes as typeof parent.children)); + + // Return the index to skip newly inserted nodes + return index + newNodes.length; + }); + }; +}; + +/** + * Remark plugin registration for Milkdown + */ +export const remarkMacro = $remark('remarkMacro', () => remarkMacroParser); + +/** + * ProseMirror node schema for macros + */ +export const macroNode = $node('macro', () => ({ + group: 'inline', + inline: true, + atom: true, // Treated as a single unit, not editable as text + attrs: { + name: { default: '' }, + params: { default: {} }, + raw: { default: '' }, + }, + parseDOM: [ + { + tag: 'span[data-macro]', + getAttrs: (dom): Record => { + const element = dom as HTMLElement; + return { + name: element.dataset.macroName || '', + params: JSON.parse(element.dataset.macroParams || '{}'), + raw: element.dataset.macroRaw || '', + }; + }, + }, + ], + toDOM: (node: ProseNode) => { + const { name, params, raw } = node.attrs; + const preview = getEditorPreview(name, params); + const isKnown = hasMacro(name); + + return [ + 'span', + { + 'data-macro': 'true', + 'data-macro-name': name, + 'data-macro-params': JSON.stringify(params), + 'data-macro-raw': raw, + class: `milkdown-macro ${isKnown ? 'macro-known' : 'macro-unknown'}`, + title: raw, + contenteditable: 'false', + }, + preview, + ]; + }, + parseMarkdown: { + match: (node) => node.type === 'macro', + runner: (state, node, type) => { + const macroNode = node as unknown as MacroMdastNode; + state.addNode(type, { + name: macroNode.name, + params: macroNode.params, + raw: macroNode.raw, + }); + }, + }, + toMarkdown: { + match: (node) => node.type.name === 'macro', + runner: (state, node) => { + // Output the original raw macro syntax + state.addNode('text', undefined, node.attrs.raw); + }, + }, +})); + +/** + * Input rule to convert typed [[macro...]] to macro node + * Triggers when user types ]] to close a macro + */ +export const macroInputRule = $inputRule(() => { + // Match [[macroName param="value"]] when user types the closing ]] + return new InputRule( + /\[\[(\w+)(?:\s+([^\]]+))?\]\]$/, + (state, match, start, end) => { + const [fullMatch, name, paramStr] = match; + const macroType = state.schema.nodes.macro; + + if (!macroType) return null; + + const params = parseParams(paramStr); + const macroNodeInstance = macroType.create({ + name: name.toLowerCase(), + params, + raw: fullMatch, + }); + + return state.tr.replaceWith(start, end, macroNodeInstance); + } + ); +}); + +/** + * Export all macro plugin components + * Use with: editor.use(macroPlugin) + */ +export const macroPlugin = [ + remarkMacro, + macroNode, + macroInputRule, +].flat(); + +export default macroPlugin; diff --git a/tests/renderer/macros/registry.test.ts b/tests/renderer/macros/registry.test.ts new file mode 100644 index 0000000..ce6624c --- /dev/null +++ b/tests/renderer/macros/registry.test.ts @@ -0,0 +1,384 @@ +/** + * Tests for the Macro Registry + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + registerMacro, + getMacro, + hasMacro, + getMacroNames, + getAllMacros, + clearMacros, + parseParams, + parseMacros, + renderMacro, + renderAllMacros, + getEditorPreview, +} from '../../../src/renderer/macros/registry'; +import type { MacroDefinition, MacroParams, MacroRenderContext, ParsedMacro } from '../../../src/renderer/macros/types'; + +// Helper to create a test macro +function createTestMacro(overrides: Partial = {}): MacroDefinition { + return { + name: 'testmacro', + description: 'A test macro', + render: (params: MacroParams) => `
Test: ${params.value || 'no value'}
`, + ...overrides, + }; +} + +describe('Macro Registry', () => { + beforeEach(() => { + clearMacros(); + }); + + describe('registerMacro', () => { + it('should register a macro successfully', () => { + const macro = createTestMacro({ name: 'mymacro' }); + registerMacro(macro); + + expect(hasMacro('mymacro')).toBe(true); + expect(getMacro('mymacro')).toBe(macro); + }); + + it('should store macro names as lowercase', () => { + const macro = createTestMacro({ name: 'MyMacro' }); + registerMacro(macro); + + expect(hasMacro('mymacro')).toBe(true); + expect(hasMacro('MyMacro')).toBe(true); + expect(hasMacro('MYMACRO')).toBe(true); + }); + + it('should overwrite existing macro with warning', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const macro1 = createTestMacro({ name: 'dupe', description: 'First' }); + const macro2 = createTestMacro({ name: 'dupe', description: 'Second' }); + + registerMacro(macro1); + registerMacro(macro2); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already registered')); + expect(getMacro('dupe')?.description).toBe('Second'); + + consoleSpy.mockRestore(); + }); + }); + + describe('getMacro', () => { + it('should return undefined for unregistered macro', () => { + expect(getMacro('nonexistent')).toBeUndefined(); + }); + + it('should return macro case-insensitively', () => { + const macro = createTestMacro({ name: 'gallery' }); + registerMacro(macro); + + expect(getMacro('gallery')).toBe(macro); + expect(getMacro('Gallery')).toBe(macro); + expect(getMacro('GALLERY')).toBe(macro); + }); + }); + + describe('getMacroNames', () => { + it('should return empty array when no macros registered', () => { + expect(getMacroNames()).toEqual([]); + }); + + it('should return all registered macro names', () => { + registerMacro(createTestMacro({ name: 'alpha' })); + registerMacro(createTestMacro({ name: 'beta' })); + registerMacro(createTestMacro({ name: 'gamma' })); + + const names = getMacroNames(); + expect(names).toHaveLength(3); + expect(names).toContain('alpha'); + expect(names).toContain('beta'); + expect(names).toContain('gamma'); + }); + }); + + describe('getAllMacros', () => { + it('should return all registered macro definitions', () => { + const macro1 = createTestMacro({ name: 'one' }); + const macro2 = createTestMacro({ name: 'two' }); + + registerMacro(macro1); + registerMacro(macro2); + + const macros = getAllMacros(); + expect(macros).toHaveLength(2); + expect(macros).toContain(macro1); + expect(macros).toContain(macro2); + }); + }); +}); + +describe('parseParams', () => { + it('should parse double-quoted parameters', () => { + const result = parseParams('foo="bar" baz="qux"'); + expect(result).toEqual({ foo: 'bar', baz: 'qux' }); + }); + + it('should parse single-quoted parameters', () => { + const result = parseParams("foo='bar' baz='qux'"); + expect(result).toEqual({ foo: 'bar', baz: 'qux' }); + }); + + it('should parse mixed quotes', () => { + const result = parseParams('foo="bar" baz=\'qux\''); + expect(result).toEqual({ foo: 'bar', baz: 'qux' }); + }); + + it('should handle empty string', () => { + expect(parseParams('')).toEqual({}); + }); + + it('should handle undefined', () => { + expect(parseParams(undefined)).toEqual({}); + }); + + it('should handle parameters with spaces in values', () => { + const result = parseParams('title="Hello World" caption="Nice photo"'); + expect(result).toEqual({ title: 'Hello World', caption: 'Nice photo' }); + }); + + it('should handle parameters with special characters', () => { + const result = parseParams('url="https://example.com/path?a=1&b=2"'); + expect(result).toEqual({ url: 'https://example.com/path?a=1&b=2' }); + }); +}); + +describe('parseMacros', () => { + it('should parse a single macro without params', () => { + const result = parseMacros('Hello [[gallery]] world'); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('gallery'); + expect(result[0].params).toEqual({}); + expect(result[0].rawText).toBe('[[gallery]]'); + }); + + it('should parse a macro with parameters', () => { + const result = parseMacros('[[gallery link="photos" columns="3"]]'); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('gallery'); + expect(result[0].params).toEqual({ link: 'photos', columns: '3' }); + }); + + it('should parse multiple macros', () => { + const result = parseMacros('Intro [[gallery]] middle [[youtube id="abc"]] end'); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('gallery'); + expect(result[1].name).toBe('youtube'); + expect(result[1].params).toEqual({ id: 'abc' }); + }); + + it('should capture start and end positions', () => { + const text = 'Hello [[test]] world'; + const result = parseMacros(text); + + expect(result[0].start).toBe(6); + expect(result[0].end).toBe(14); + expect(text.slice(result[0].start, result[0].end)).toBe('[[test]]'); + }); + + it('should return empty array for text without macros', () => { + expect(parseMacros('Just regular text')).toEqual([]); + expect(parseMacros('')).toEqual([]); + }); + + it('should handle macro names case-insensitively', () => { + const result = parseMacros('[[MyMacro]]'); + expect(result[0].name).toBe('mymacro'); + }); +}); + +describe('renderMacro', () => { + const context: MacroRenderContext = { isPreview: true }; + + beforeEach(() => { + clearMacros(); + }); + + it('should render a registered macro', async () => { + registerMacro({ + name: 'hello', + description: 'Says hello', + render: (params) => `Hello, ${params.name || 'world'}!`, + }); + + const macro: ParsedMacro = { + name: 'hello', + params: { name: 'Alice' }, + rawText: '[[hello name="Alice"]]', + start: 0, + end: 22, + }; + + const result = await renderMacro(macro, context); + expect(result).toBe('Hello, Alice!'); + }); + + it('should return error span for unknown macro', async () => { + const macro: ParsedMacro = { + name: 'unknown', + params: {}, + rawText: '[[unknown]]', + start: 0, + end: 11, + }; + + const result = await renderMacro(macro, context); + expect(result).toContain('macro-error'); + expect(result).toContain('Unknown macro'); + expect(result).toContain('[[unknown]]'); + }); + + it('should handle async render functions', async () => { + registerMacro({ + name: 'async', + description: 'Async macro', + render: async (params) => { + await new Promise(resolve => setTimeout(resolve, 10)); + return `
Async: ${params.val}
`; + }, + }); + + const macro: ParsedMacro = { + name: 'async', + params: { val: 'test' }, + rawText: '[[async val="test"]]', + start: 0, + end: 20, + }; + + const result = await renderMacro(macro, context); + expect(result).toBe('
Async: test
'); + }); + + it('should return error span when validation fails', async () => { + registerMacro({ + name: 'validated', + description: 'Validated macro', + validate: (params) => params.required ? undefined : 'Missing required param', + render: () => '
OK
', + }); + + const macro: ParsedMacro = { + name: 'validated', + params: {}, + rawText: '[[validated]]', + start: 0, + end: 13, + }; + + const result = await renderMacro(macro, context); + expect(result).toContain('macro-error'); + expect(result).toContain('Missing required param'); + }); + + it('should return error span when render throws', async () => { + registerMacro({ + name: 'broken', + description: 'Broken macro', + render: () => { throw new Error('Render failed'); }, + }); + + const macro: ParsedMacro = { + name: 'broken', + params: {}, + rawText: '[[broken]]', + start: 0, + end: 10, + }; + + const result = await renderMacro(macro, context); + expect(result).toContain('macro-error'); + expect(result).toContain('Render failed'); + }); +}); + +describe('renderAllMacros', () => { + const context: MacroRenderContext = { isPreview: true }; + + beforeEach(() => { + clearMacros(); + }); + + it('should replace all macros in text', async () => { + registerMacro({ + name: 'bold', + description: 'Makes text bold', + render: (params) => `${params.text}`, + }); + + const input = 'Hello [[bold text="world"]]!'; + const result = await renderAllMacros(input, context); + + expect(result).toBe('Hello world!'); + }); + + it('should handle multiple macros', async () => { + registerMacro({ + name: 'a', + description: 'Macro A', + render: () => 'AAA', + }); + registerMacro({ + name: 'b', + description: 'Macro B', + render: () => 'BBB', + }); + + const input = '[[a]] and [[b]]'; + const result = await renderAllMacros(input, context); + + expect(result).toBe('AAA and BBB'); + }); + + it('should return unchanged text when no macros', async () => { + const input = 'Just plain text'; + const result = await renderAllMacros(input, context); + + expect(result).toBe(input); + }); +}); + +describe('getEditorPreview', () => { + beforeEach(() => { + clearMacros(); + }); + + it('should return warning for unknown macro', () => { + const result = getEditorPreview('unknown', {}); + expect(result).toBe('⚠ unknown'); + }); + + it('should return custom preview from macro definition', () => { + registerMacro({ + name: 'gallery', + description: 'Gallery', + render: () => '', + editorPreview: (params) => `📷 ${params.title || 'Gallery'}`, + }); + + const result = getEditorPreview('gallery', { title: 'My Photos' }); + expect(result).toBe('📷 My Photos'); + }); + + it('should return default preview when editorPreview not defined', () => { + registerMacro({ + name: 'simple', + description: 'Simple macro', + render: () => '', + }); + + const result = getEditorPreview('simple', {}); + expect(result).toBe('⬡ simple'); + }); +});