feat: recognize macros
This commit is contained in:
107
src/main/config/macroConfig.ts
Normal file
107
src/main/config/macroConfig.ts
Normal 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 };
|
||||
}
|
||||
@@ -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<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 {
|
||||
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<string, MacroDefinitionLike> = 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 (?<!\[) 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() {
|
||||
this.turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
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 {
|
||||
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> {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
definitionId: string;
|
||||
}
|
||||
@@ -368,6 +398,14 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
|
||||
onMappingUpdated={handleSingleMappingUpdated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{report.macros && report.macros.total > 0 && (
|
||||
<MacrosSection
|
||||
macros={report.macros}
|
||||
expanded={expandedSections['macros'] ?? false}
|
||||
onToggle={() => toggleSection('macros')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1015,3 +1053,104 @@ const TaxonomyPill: React.FC<{
|
||||
</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' : ''}`}>▶</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' : ''}`}>▶</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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '<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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user