diff --git a/src/main/config/macroConfig.ts b/src/main/config/macroConfig.ts new file mode 100644 index 0000000..a26ba03 --- /dev/null +++ b/src/main/config/macroConfig.ts @@ -0,0 +1,107 @@ +/** + * Shared Macro Configuration + * + * This file defines the macro validation rules that can be used by both + * renderer and main process (e.g., for import analysis validation). + * + * The full macro implementations (with render functions) live in: + * src/renderer/macros/definitions/ + */ + +export interface MacroConfig { + /** The macro name (lowercase) */ + name: string; + /** Human-readable description */ + description: string; + /** Required parameters that must be present */ + requiredParams?: string[]; + /** Optional parameter validation function */ + validate?: (params: Record) => string | undefined; +} + +/** + * Registry of known macro configurations. + * Add new macros here to enable validation during import analysis. + */ +export const macroConfigs: MacroConfig[] = [ + { + name: 'youtube', + description: 'Embeds a YouTube video player', + requiredParams: ['id'], + validate: (params) => { + if (!params.id) { + return 'YouTube macro requires an "id" parameter (the video ID)'; + } + // Basic validation of YouTube ID format (11 characters) + if (!/^[\w-]{11}$/.test(params.id)) { + return 'Invalid YouTube video ID format'; + } + return undefined; + }, + }, + { + name: 'gallery', + description: 'Renders an image gallery from linked media files', + validate: (params) => { + 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; + }, + }, +]; + +/** + * Get a map of macro configs by name for quick lookup. + */ +export function getMacroConfigMap(): Map { + const map = new Map(); + for (const config of macroConfigs) { + map.set(config.name.toLowerCase(), config); + } + return map; +} + +/** + * Validate macro parameters against known configurations. + * + * @param macroName - The macro name + * @param params - The macro parameters + * @returns Error message if invalid, undefined if valid or macro is unknown + */ +export function validateMacroParams( + macroName: string, + params: Record +): { valid: boolean; error?: string; known: boolean } { + const config = getMacroConfigMap().get(macroName.toLowerCase()); + + if (!config) { + return { valid: false, known: false }; + } + + // Check required parameters + if (config.requiredParams) { + for (const param of config.requiredParams) { + if (!params[param]) { + return { + valid: false, + known: true, + error: `Missing required parameter: ${param}` + }; + } + } + } + + // Run custom validation if provided + if (config.validate) { + const error = config.validate(params); + if (error) { + return { valid: false, known: true, error }; + } + } + + return { valid: true, known: true }; +} diff --git a/src/main/engine/ImportAnalysisEngine.ts b/src/main/engine/ImportAnalysisEngine.ts index cd90832..dccbd97 100644 --- a/src/main/engine/ImportAnalysisEngine.ts +++ b/src/main/engine/ImportAnalysisEngine.ts @@ -6,6 +6,7 @@ import { getDatabase } from '../database'; import { posts, media, tags } from '../database/schema'; import { eq } from 'drizzle-orm'; import type { WxrData, WxrPost, WxrMedia, WxrSiteInfo, WxrCategory, WxrTag } from './WxrParser'; +import { getMacroConfigMap, type MacroConfig } from '../config/macroConfig'; export type PostAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate'; export type MediaAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate' | 'missing'; @@ -48,6 +49,55 @@ export interface AnalyzedTag { mappedTo?: string; // When set, indicates this item should be mapped to the given name on import } +/** Validation status for a macro usage */ +export type MacroValidationStatus = 'valid' | 'invalid' | 'unknown'; + +/** A single unique usage pattern of a macro */ +export interface MacroUsage { + /** The parameters used in this particular usage */ + params: Record; + /** How many times this exact parameter combination was used */ + count: number; + /** Whether this usage is valid according to our macro definition */ + validationStatus: MacroValidationStatus; + /** Error message if validation failed */ + validationError?: string; + /** Serialized params for deduplication */ + paramsKey: string; +} + +/** A discovered macro from the import content */ +export interface DiscoveredMacro { + /** The macro name (lowercase) */ + name: string; + /** Whether this macro maps to an internal definition */ + mapped: boolean; + /** Total number of times this macro appears across all content */ + totalCount: number; + /** Unique usages with different parameters */ + usages: MacroUsage[]; + /** Slugs of posts/pages where this macro is used */ + postSlugs: string[]; +} + +/** Summary of macro analysis */ +export interface MacroAnalysisSummary { + /** Total unique macros discovered */ + total: number; + /** Number of macros that map to internal definitions */ + mappedCount: number; + /** Number of macros that don't map to internal definitions */ + unmappedCount: number; + /** All discovered macros with their usages */ + discovered: DiscoveredMacro[]; +} + +/** Minimal interface for macro definition validation */ +export interface MacroDefinitionLike { + name: string; + validate?: (params: Record) => string | undefined; +} + export interface ImportAnalysisReport { sourceFile: string; site: WxrSiteInfo; @@ -79,27 +129,69 @@ export interface ImportAnalysisReport { }; categories: AnalyzedCategory[]; tags: AnalyzedTag[]; + macros: MacroAnalysisSummary; } export class ImportAnalysisEngine { private currentProjectId: string = ''; private turndown: TurndownService; + private macroDefinitions: Map = new Map(); // Progress callback for reporting analysis steps onProgress?: (step: string, detail?: string) => void; + // Regex to match WordPress shortcodes: [macroname param="val" param2='val2'] + // This matches single brackets (NOT double brackets like our internal format) + // Uses negative lookbehind (?): void { + this.macroDefinitions = definitions; + } + async analyzeWxr(wxrData: WxrData, sourceFile: string, uploadsFolder?: string): Promise { const db = getDatabase().getLocal(); @@ -194,6 +286,11 @@ export class ImportAnalysisEngine { existsInProject: existingTagNames.has(tag.name.toLowerCase()), })); + this.onProgress?.('Discovering macros...'); + + // Analyze macros from posts and pages content + const macroAnalysis = this.analyzeMacros([...wxrData.posts, ...wxrData.pages]); + return { sourceFile, site: wxrData.site, @@ -203,6 +300,7 @@ export class ImportAnalysisEngine { media: this.summarizeMediaAnalysis(analyzedMedia), categories: analyzedCategories, tags: analyzedTags, + macros: macroAnalysis, }; } @@ -348,4 +446,154 @@ export class ImportAnalysisEngine { private calculateChecksum(content: string): string { return crypto.createHash('md5').update(content).digest('hex'); } + + /** + * Analyze macros (WordPress shortcodes) from post/page content. + * Discovers all shortcodes, aggregates their usages, and validates against definitions. + */ + private analyzeMacros(posts: WxrPost[]): MacroAnalysisSummary { + // Map of macro name -> discovered macro data + const macroMap = new Map; count: number }>; + postSlugs: Set; + }>(); + + // Process each post/page + for (const post of posts) { + if (!post.content) continue; + + const shortcodes = this.parseShortcodes(post.content); + + for (const shortcode of shortcodes) { + const name = shortcode.name.toLowerCase(); + + let macroData = macroMap.get(name); + if (!macroData) { + macroData = { + name, + totalCount: 0, + usages: new Map(), + postSlugs: new Set(), + }; + macroMap.set(name, macroData); + } + + macroData.totalCount++; + macroData.postSlugs.add(post.slug); + + // Create a key for this parameter combination + const paramsKey = this.serializeParams(shortcode.params); + const existingUsage = macroData.usages.get(paramsKey); + if (existingUsage) { + existingUsage.count++; + } else { + macroData.usages.set(paramsKey, { params: shortcode.params, count: 1 }); + } + } + } + + // Convert to final format with validation + const discovered: DiscoveredMacro[] = []; + + for (const macroData of macroMap.values()) { + const definition = this.macroDefinitions.get(macroData.name); + const mapped = definition !== undefined; + + const usages: MacroUsage[] = []; + for (const [paramsKey, usage] of macroData.usages) { + let validationStatus: MacroValidationStatus = 'unknown'; + let validationError: string | undefined; + + if (mapped && definition) { + if (definition.validate) { + const error = definition.validate(usage.params); + if (error) { + validationStatus = 'invalid'; + validationError = error; + } else { + validationStatus = 'valid'; + } + } else { + // Macro is mapped but has no validation - consider valid + validationStatus = 'valid'; + } + } + + usages.push({ + params: usage.params, + count: usage.count, + validationStatus, + validationError, + paramsKey, + }); + } + + discovered.push({ + name: macroData.name, + mapped, + totalCount: macroData.totalCount, + usages, + postSlugs: Array.from(macroData.postSlugs), + }); + } + + // Sort discovered macros by name + discovered.sort((a, b) => a.name.localeCompare(b.name)); + + return { + total: discovered.length, + mappedCount: discovered.filter(m => m.mapped).length, + unmappedCount: discovered.filter(m => !m.mapped).length, + discovered, + }; + } + + /** + * Parse WordPress shortcodes from content. + * Returns array of { name, params } for each shortcode found. + */ + private parseShortcodes(content: string): Array<{ name: string; params: Record }> { + const shortcodes: Array<{ name: string; params: Record }> = []; + + // Reset regex lastIndex + ImportAnalysisEngine.SHORTCODE_REGEX.lastIndex = 0; + + let match; + while ((match = ImportAnalysisEngine.SHORTCODE_REGEX.exec(content)) !== null) { + const name = match[1]; + const paramString = match[2] || ''; + const params = this.parseShortcodeParams(paramString); + + shortcodes.push({ name, params }); + } + + return shortcodes; + } + + /** + * Parse parameters from a shortcode parameter string. + */ + private parseShortcodeParams(paramString: string): Record { + const params: Record = {}; + + // Reset regex lastIndex + ImportAnalysisEngine.PARAM_REGEX.lastIndex = 0; + + let match; + while ((match = ImportAnalysisEngine.PARAM_REGEX.exec(paramString)) !== null) { + params[match[1]] = match[2]; + } + + return params; + } + + /** + * Serialize params to a stable string for deduplication. + */ + private serializeParams(params: Record): string { + const sorted = Object.entries(params).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify(sorted); + } } diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css index 392c50c..ce0b146 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.css @@ -666,3 +666,208 @@ margin: 0; font-size: 13px; } + +/* ================================= + MACROS SECTION STYLES + ================================= */ + +.macro-summary-counts { + display: inline-flex; + gap: 8px; + margin-left: auto; + font-size: 11px; + font-weight: normal; +} + +.macro-summary-counts .mapped-count { + color: var(--vscode-charts-green, #89d185); +} + +.macro-summary-counts .unmapped-count { + color: var(--vscode-charts-orange, #cca700); +} + +.macros-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; +} + +.macro-item { + background: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 6px; + overflow: hidden; +} + +.macro-item.mapped { + border-left: 3px solid var(--vscode-charts-green, #89d185); +} + +.macro-item.unmapped { + border-left: 3px solid var(--vscode-charts-orange, #cca700); +} + +.macro-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + cursor: pointer; + transition: background 0.15s ease; +} + +.macro-header:hover { + background: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.04)); +} + +.toggle-icon.small { + font-size: 8px; + width: 10px; +} + +.macro-name { + font-weight: 600; + font-family: var(--vscode-editor-font-family, monospace); + color: var(--vscode-foreground); +} + +.macro-status-badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.macro-status-badge.mapped { + background: rgba(137, 209, 133, 0.15); + color: var(--vscode-charts-green, #89d185); +} + +.macro-status-badge.unmapped { + background: rgba(204, 167, 0, 0.15); + color: var(--vscode-charts-orange, #cca700); +} + +.macro-count { + margin-left: auto; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.macro-usages { + padding: 0 12px 12px 30px; +} + +.macro-usages-header { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--vscode-input-border, rgba(255, 255, 255, 0.1)); +} + +.macro-posts-label { + font-style: italic; +} + +.macro-usages-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.macro-usage { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-radius: 4px; + border-left: 3px solid transparent; + background: var(--vscode-sideBar-background); +} + +.macro-usage.valid { + border-left-color: var(--vscode-charts-green, #89d185); +} + +.macro-usage.invalid { + border-left-color: var(--vscode-errorForeground, #f85149); + background: rgba(248, 81, 73, 0.08); +} + +.macro-usage.unknown { + border-left-color: var(--vscode-descriptionForeground); +} + +.macro-usage-params { + display: flex; + flex-wrap: wrap; + gap: 6px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; +} + +.no-params { + color: var(--vscode-descriptionForeground); + font-style: italic; + font-family: inherit; +} + +.macro-param { + display: inline-flex; + align-items: center; + gap: 1px; +} + +.param-key { + color: var(--vscode-symbolIcon-propertyForeground, #9cdcfe); +} + +.param-value { + color: var(--vscode-symbolIcon-stringForeground, #ce9178); +} + +.macro-usage-meta { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.validation-badge { + width: 18px; + height: 18px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; +} + +.validation-badge.valid { + background: rgba(137, 209, 133, 0.2); + color: var(--vscode-charts-green, #89d185); +} + +.validation-badge.invalid { + background: rgba(248, 81, 73, 0.2); + color: var(--vscode-errorForeground, #f85149); +} + +.validation-badge.unknown { + background: rgba(128, 128, 128, 0.2); + color: var(--vscode-descriptionForeground); +} + +.usage-count { + font-size: 11px; + color: var(--vscode-descriptionForeground); + min-width: 24px; + text-align: right; +} diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx index d846b6a..8d60f13 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx @@ -11,6 +11,7 @@ interface AnalysisReport { media: MediaSection; categories: TaxonomyItem[]; tags: TaxonomyItem[]; + macros: MacroAnalysisSummary; } interface ItemSection { @@ -75,6 +76,35 @@ interface TaxonomyItem { mappedTo?: string; // When set, indicates this item should be mapped to the given name on import } +/** Validation status for a macro usage */ +type MacroValidationStatus = 'valid' | 'invalid' | 'unknown'; + +/** A single unique usage pattern of a macro */ +interface MacroUsage { + params: Record; + count: number; + validationStatus: MacroValidationStatus; + validationError?: string; + paramsKey: string; +} + +/** A discovered macro from the import content */ +interface DiscoveredMacro { + name: string; + mapped: boolean; + totalCount: number; + usages: MacroUsage[]; + postSlugs: string[]; +} + +/** Summary of macro analysis */ +interface MacroAnalysisSummary { + total: number; + mappedCount: number; + unmappedCount: number; + discovered: DiscoveredMacro[]; +} + interface ImportAnalysisViewProps { definitionId: string; } @@ -368,6 +398,14 @@ export const ImportAnalysisView: React.FC = ({ definiti onMappingUpdated={handleSingleMappingUpdated} /> )} + + {report.macros && report.macros.total > 0 && ( + toggleSection('macros')} + /> + )} )} @@ -1015,3 +1053,104 @@ const TaxonomyPill: React.FC<{ ); }; + +/** + * MacrosSection - Shows discovered macros from import content + * Displays macro names, their different usages (parameters), and validation status + */ +const MacrosSection: React.FC<{ + macros: MacroAnalysisSummary; + expanded: boolean; + onToggle: () => void; +}> = ({ macros, expanded, onToggle }) => { + const [expandedMacros, setExpandedMacros] = useState>(new Set()); + + const toggleMacro = (name: string) => { + setExpandedMacros(prev => { + const next = new Set(prev); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + }; + + return ( +
+

+ + Macros ({macros.total}) + + {macros.mappedCount} mapped + {macros.unmappedCount > 0 && ( + {macros.unmappedCount} unmapped + )} + +

+ {expanded && ( +
+ {macros.discovered.map(macro => ( +
+
toggleMacro(macro.name)}> + + [{macro.name}] + + {macro.mapped ? 'Mapped' : 'Unknown'} + + {macro.totalCount} uses +
+ + {expandedMacros.has(macro.name) && ( +
+
+ Used in: {macro.postSlugs.slice(0, 5).join(', ')}{macro.postSlugs.length > 5 ? `, +${macro.postSlugs.length - 5} more` : ''} +
+ +
+ {macro.usages.map((usage, idx) => ( +
+
+ {Object.keys(usage.params).length === 0 ? ( + (no parameters) + ) : ( + Object.entries(usage.params).map(([key, value]) => ( + + {key}="{value}" + + )) + )} +
+
+ + {usage.validationStatus === 'valid' && '✓'} + {usage.validationStatus === 'invalid' && '✗'} + {usage.validationStatus === 'unknown' && '?'} + + ×{usage.count} +
+
+ ))} +
+
+ )} +
+ ))} +
+ )} +
+ ); +}; + +function getValidationClass(status: MacroValidationStatus): string { + switch (status) { + case 'valid': return 'valid'; + case 'invalid': return 'invalid'; + case 'unknown': return 'unknown'; + } +} diff --git a/tests/engine/ImportAnalysisEngine.test.ts b/tests/engine/ImportAnalysisEngine.test.ts index 40e851e..8ac3920 100644 --- a/tests/engine/ImportAnalysisEngine.test.ts +++ b/tests/engine/ImportAnalysisEngine.test.ts @@ -506,6 +506,226 @@ describe('ImportAnalysisEngine', () => { expect(report.posts.contentDuplicates).toBe(0); }); }); + + describe('analyzeWxr - macro discovery', () => { + it('should discover macros from post content using WordPress shortcode format', async () => { + setupDbReturns([], [], []); + + const wxrData = createWxrData({ + posts: [createWxrPost({ + content: '

Hello world

[youtube id="dQw4w9WgXcQ"]

More text

', + })], + }); + + const report = await engine.analyzeWxr(wxrData, '/test.xml'); + + expect(report.macros).toBeDefined(); + expect(report.macros.discovered).toContainEqual( + expect.objectContaining({ name: 'youtube' }) + ); + }); + + it('should discover macros with multiple parameters', async () => { + setupDbReturns([], [], []); + + const wxrData = createWxrData({ + posts: [createWxrPost({ + content: '[gallery columns="4" caption="My Photos"]', + })], + }); + + const report = await engine.analyzeWxr(wxrData, '/test.xml'); + + const galleryMacro = report.macros.discovered.find(m => m.name === 'gallery'); + expect(galleryMacro).toBeDefined(); + expect(galleryMacro!.usages).toContainEqual( + expect.objectContaining({ + params: { columns: '4', caption: 'My Photos' }, + }) + ); + }); + + it('should aggregate different usages of the same macro', async () => { + setupDbReturns([], [], []); + + const wxrData = createWxrData({ + posts: [ + createWxrPost({ + slug: 'post-1', + content: '[youtube id="video1"][youtube id="video2" title="My Video"]', + }), + createWxrPost({ + slug: 'post-2', + content: '[youtube id="video1"]', // Same as first usage in post-1 + }), + ], + }); + + const report = await engine.analyzeWxr(wxrData, '/test.xml'); + + const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube'); + expect(youtubeMacro).toBeDefined(); + // Should have 2 unique usages (video1 and video2) + expect(youtubeMacro!.usages.length).toBe(2); + expect(youtubeMacro!.totalCount).toBe(3); // 3 total occurrences + }); + + it('should discover macros from pages as well as posts', async () => { + setupDbReturns([], [], []); + + const wxrData = createWxrData({ + posts: [createWxrPost({ content: '[gallery columns="3"]' })], + pages: [createWxrPost({ postType: 'page', content: '[youtube id="abc123def4g"]' })], + }); + + const report = await engine.analyzeWxr(wxrData, '/test.xml'); + + const macroNames = report.macros.discovered.map(m => m.name); + expect(macroNames).toContain('gallery'); + expect(macroNames).toContain('youtube'); + }); + + it('should mark macro as mapped when internal definition exists', async () => { + setupDbReturns([], [], []); + + // Register a mock macro for testing + const mockMacros = new Map) => string | undefined }>(); + mockMacros.set('youtube', { + name: 'youtube', + validate: (params) => params.id ? undefined : 'Missing id parameter', + }); + mockMacros.set('gallery', { name: 'gallery' }); + engine.setMacroDefinitions(mockMacros); + + const wxrData = createWxrData({ + posts: [createWxrPost({ + content: '[youtube id="test123test"][unknown_macro param="val"]', + })], + }); + + const report = await engine.analyzeWxr(wxrData, '/test.xml'); + + const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube'); + const unknownMacro = report.macros.discovered.find(m => m.name === 'unknown_macro'); + + expect(youtubeMacro?.mapped).toBe(true); + expect(unknownMacro?.mapped).toBe(false); + }); + + it('should validate macro parameters against definitions', async () => { + setupDbReturns([], [], []); + + const mockMacros = new Map) => string | undefined }>(); + mockMacros.set('youtube', { + name: 'youtube', + validate: (params) => params.id ? undefined : 'Missing id parameter', + }); + engine.setMacroDefinitions(mockMacros); + + const wxrData = createWxrData({ + posts: [createWxrPost({ + content: '[youtube id="validid1234"][youtube]', // One valid, one invalid + })], + }); + + const report = await engine.analyzeWxr(wxrData, '/test.xml'); + + const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube'); + expect(youtubeMacro).toBeDefined(); + + const validUsage = youtubeMacro!.usages.find(u => u.params.id === 'validid1234'); + const invalidUsage = youtubeMacro!.usages.find(u => Object.keys(u.params).length === 0); + + expect(validUsage?.validationStatus).toBe('valid'); + expect(invalidUsage?.validationStatus).toBe('invalid'); + expect(invalidUsage?.validationError).toBe('Missing id parameter'); + }); + + it('should provide summary counts for macros', async () => { + setupDbReturns([], [], []); + + const mockMacros = new Map) => string | undefined }>(); + mockMacros.set('youtube', { name: 'youtube' }); + engine.setMacroDefinitions(mockMacros); + + const wxrData = createWxrData({ + posts: [createWxrPost({ + content: '[youtube id="vid1"][gallery][custom_macro]', + })], + }); + + const report = await engine.analyzeWxr(wxrData, '/test.xml'); + + expect(report.macros.total).toBe(3); + expect(report.macros.mappedCount).toBe(1); // Only youtube is mapped + expect(report.macros.unmappedCount).toBe(2); // gallery and custom_macro not mapped + }); + + it('should track which posts contain each macro', async () => { + setupDbReturns([], [], []); + + const wxrData = createWxrData({ + posts: [ + createWxrPost({ slug: 'post-a', title: 'Post A', content: '[youtube id="vid1"]' }), + createWxrPost({ slug: 'post-b', title: 'Post B', content: '[youtube id="vid2"]' }), + createWxrPost({ slug: 'post-c', title: 'Post C', content: '[gallery]' }), + ], + }); + + const report = await engine.analyzeWxr(wxrData, '/test.xml'); + + const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube'); + expect(youtubeMacro?.postSlugs).toContain('post-a'); + expect(youtubeMacro?.postSlugs).toContain('post-b'); + expect(youtubeMacro?.postSlugs).not.toContain('post-c'); + }); + + it('should handle self-closing shortcodes', async () => { + setupDbReturns([], [], []); + + const wxrData = createWxrData({ + posts: [createWxrPost({ + content: '[gallery /][youtube id="test" /]', + })], + }); + + const report = await engine.analyzeWxr(wxrData, '/test.xml'); + + expect(report.macros.discovered.length).toBe(2); + }); + + it('should handle shortcodes with single-quoted parameters', async () => { + setupDbReturns([], [], []); + + const wxrData = createWxrData({ + posts: [createWxrPost({ + content: "[youtube id='singlequoted']", + })], + }); + + const report = await engine.analyzeWxr(wxrData, '/test.xml'); + + const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube'); + expect(youtubeMacro?.usages[0].params.id).toBe('singlequoted'); + }); + + it('should not detect our internal macro format as WordPress shortcodes', async () => { + setupDbReturns([], [], []); + + const wxrData = createWxrData({ + posts: [createWxrPost({ + content: '[[youtube id="internal"]] and [youtube id="wordpress"]', + })], + }); + + const report = await engine.analyzeWxr(wxrData, '/test.xml'); + + // Should only find the WordPress shortcode, not our internal one + expect(report.macros.discovered.length).toBe(1); + const youtubeMacro = report.macros.discovered.find(m => m.name === 'youtube'); + expect(youtubeMacro?.usages[0].params.id).toBe('wordpress'); + }); + }); }); /**