feat: recognize macros

This commit is contained in:
2026-02-13 16:16:43 +01:00
parent 55f37f4dfa
commit 1aa44e675d
5 changed files with 919 additions and 0 deletions

View File

@@ -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, string>) => 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<string, MacroConfig> {
const map = new Map<string, MacroConfig>();
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<string, string>
): { 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 };
}

View File

@@ -6,6 +6,7 @@ import { getDatabase } from '../database';
import { posts, media, tags } from '../database/schema'; import { posts, media, tags } from '../database/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import type { WxrData, WxrPost, WxrMedia, WxrSiteInfo, WxrCategory, WxrTag } from './WxrParser'; 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 PostAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate';
export type MediaAnalysisStatus = 'new' | 'update' | 'conflict' | 'content-duplicate' | 'missing'; 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 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<string, string>;
/** 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, string>) => string | undefined;
}
export interface ImportAnalysisReport { export interface ImportAnalysisReport {
sourceFile: string; sourceFile: string;
site: WxrSiteInfo; site: WxrSiteInfo;
@@ -79,27 +129,69 @@ export interface ImportAnalysisReport {
}; };
categories: AnalyzedCategory[]; categories: AnalyzedCategory[];
tags: AnalyzedTag[]; tags: AnalyzedTag[];
macros: MacroAnalysisSummary;
} }
export class ImportAnalysisEngine { export class ImportAnalysisEngine {
private currentProjectId: string = ''; private currentProjectId: string = '';
private turndown: TurndownService; private turndown: TurndownService;
private macroDefinitions: Map<string, MacroDefinitionLike> = new Map();
// Progress callback for reporting analysis steps // Progress callback for reporting analysis steps
onProgress?: (step: string, detail?: string) => void; 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 (?<!\[) and negative lookahead (?!\]) to exclude [[...]]
private static readonly SHORTCODE_REGEX = /(?<!\[)\[(\w+)([^\]]*?)(?:\s*\/)?\](?!\])/g;
// Regex to extract individual parameters from shortcode
private static readonly PARAM_REGEX = /(\w+)=["']([^"']*?)["']/g;
constructor() { constructor() {
this.turndown = new TurndownService({ this.turndown = new TurndownService({
headingStyle: 'atx', headingStyle: 'atx',
codeBlockStyle: 'fenced', codeBlockStyle: 'fenced',
bulletListMarker: '-', bulletListMarker: '-',
}); });
// Load macro definitions from shared config
this.loadMacroConfigsFromShared();
}
/**
* Load macro definitions from the shared macro config.
* Called automatically in constructor.
*/
private loadMacroConfigsFromShared(): void {
try {
const configs = getMacroConfigMap();
// Convert MacroConfig to MacroDefinitionLike
for (const [name, config] of configs) {
this.macroDefinitions.set(name, {
name: config.name,
validate: config.validate,
});
}
} catch (error) {
// Config not available - macros will be marked as unmapped
console.warn('Could not load macro configs:', error);
}
} }
setProjectContext(projectId: string): void { setProjectContext(projectId: string): void {
this.currentProjectId = projectId; this.currentProjectId = projectId;
} }
/**
* Set macro definitions for mapping and validation.
* This overrides the auto-loaded shared config. Useful for testing.
* @param definitions Map of macro name (lowercase) to definition
*/
setMacroDefinitions(definitions: Map<string, MacroDefinitionLike>): void {
this.macroDefinitions = definitions;
}
async analyzeWxr(wxrData: WxrData, sourceFile: string, uploadsFolder?: string): Promise<ImportAnalysisReport> { async analyzeWxr(wxrData: WxrData, sourceFile: string, uploadsFolder?: string): Promise<ImportAnalysisReport> {
const db = getDatabase().getLocal(); const db = getDatabase().getLocal();
@@ -194,6 +286,11 @@ export class ImportAnalysisEngine {
existsInProject: existingTagNames.has(tag.name.toLowerCase()), 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 { return {
sourceFile, sourceFile,
site: wxrData.site, site: wxrData.site,
@@ -203,6 +300,7 @@ export class ImportAnalysisEngine {
media: this.summarizeMediaAnalysis(analyzedMedia), media: this.summarizeMediaAnalysis(analyzedMedia),
categories: analyzedCategories, categories: analyzedCategories,
tags: analyzedTags, tags: analyzedTags,
macros: macroAnalysis,
}; };
} }
@@ -348,4 +446,154 @@ export class ImportAnalysisEngine {
private calculateChecksum(content: string): string { private calculateChecksum(content: string): string {
return crypto.createHash('md5').update(content).digest('hex'); 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<string, {
name: string;
totalCount: number;
usages: Map<string, { params: Record<string, string>; count: number }>;
postSlugs: Set<string>;
}>();
// 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<string, string> }> {
const shortcodes: Array<{ name: string; params: Record<string, string> }> = [];
// 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<string, string> {
const params: Record<string, string> = {};
// 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, string>): string {
const sorted = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
return JSON.stringify(sorted);
}
} }

View File

@@ -666,3 +666,208 @@
margin: 0; margin: 0;
font-size: 13px; 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;
}

View File

@@ -11,6 +11,7 @@ interface AnalysisReport {
media: MediaSection; media: MediaSection;
categories: TaxonomyItem[]; categories: TaxonomyItem[];
tags: TaxonomyItem[]; tags: TaxonomyItem[];
macros: MacroAnalysisSummary;
} }
interface ItemSection { interface ItemSection {
@@ -75,6 +76,35 @@ interface TaxonomyItem {
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import 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<string, string>;
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 { interface ImportAnalysisViewProps {
definitionId: string; definitionId: string;
} }
@@ -368,6 +398,14 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
onMappingUpdated={handleSingleMappingUpdated} onMappingUpdated={handleSingleMappingUpdated}
/> />
)} )}
{report.macros && report.macros.total > 0 && (
<MacrosSection
macros={report.macros}
expanded={expandedSections['macros'] ?? false}
onToggle={() => toggleSection('macros')}
/>
)}
</> </>
)} )}
</div> </div>
@@ -1015,3 +1053,104 @@ const TaxonomyPill: React.FC<{
</span> </span>
); );
}; };
/**
* 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<Set<string>>(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 (
<div className="import-detail-section">
<h3 onClick={onToggle}>
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>&#9654;</span>
Macros ({macros.total})
<span className="macro-summary-counts">
<span className="mapped-count">{macros.mappedCount} mapped</span>
{macros.unmappedCount > 0 && (
<span className="unmapped-count">{macros.unmappedCount} unmapped</span>
)}
</span>
</h3>
{expanded && (
<div className="macros-list">
{macros.discovered.map(macro => (
<div key={macro.name} className={`macro-item ${macro.mapped ? 'mapped' : 'unmapped'}`}>
<div className="macro-header" onClick={() => toggleMacro(macro.name)}>
<span className={`toggle-icon small ${expandedMacros.has(macro.name) ? 'open' : ''}`}>&#9654;</span>
<span className="macro-name">[{macro.name}]</span>
<span className={`macro-status-badge ${macro.mapped ? 'mapped' : 'unmapped'}`}>
{macro.mapped ? 'Mapped' : 'Unknown'}
</span>
<span className="macro-count">{macro.totalCount} uses</span>
</div>
{expandedMacros.has(macro.name) && (
<div className="macro-usages">
<div className="macro-usages-header">
<span className="macro-posts-label">Used in: {macro.postSlugs.slice(0, 5).join(', ')}{macro.postSlugs.length > 5 ? `, +${macro.postSlugs.length - 5} more` : ''}</span>
</div>
<div className="macro-usages-list">
{macro.usages.map((usage, idx) => (
<div
key={idx}
className={`macro-usage ${getValidationClass(usage.validationStatus)}`}
title={usage.validationError || ''}
>
<div className="macro-usage-params">
{Object.keys(usage.params).length === 0 ? (
<span className="no-params">(no parameters)</span>
) : (
Object.entries(usage.params).map(([key, value]) => (
<span key={key} className="macro-param">
<span className="param-key">{key}</span>=<span className="param-value">"{value}"</span>
</span>
))
)}
</div>
<div className="macro-usage-meta">
<span className={`validation-badge ${usage.validationStatus}`}>
{usage.validationStatus === 'valid' && '✓'}
{usage.validationStatus === 'invalid' && '✗'}
{usage.validationStatus === 'unknown' && '?'}
</span>
<span className="usage-count">×{usage.count}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
function getValidationClass(status: MacroValidationStatus): string {
switch (status) {
case 'valid': return 'valid';
case 'invalid': return 'invalid';
case 'unknown': return 'unknown';
}
}

View File

@@ -506,6 +506,226 @@ describe('ImportAnalysisEngine', () => {
expect(report.posts.contentDuplicates).toBe(0); 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: '<p>Hello world</p>[youtube id="dQw4w9WgXcQ"]<p>More text</p>',
})],
});
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, { name: string; validate?: (params: Record<string, string>) => 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, { name: string; validate?: (params: Record<string, string>) => 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, { name: string; validate?: (params: Record<string, string>) => 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');
});
});
}); });
/** /**