Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/cli/shared/template-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,18 @@ export class TemplateProcessor {
collectionPath: string,
options: TemplateProcessingOptions,
): Promise<void> {
// Load user configuration if project paths are available
// Use the provided project config or load it if not available
let userConfig = null;
if (options.projectPaths?.configFile && fs.existsSync(options.projectPaths.configFile)) {
if (options.projectConfig?.user) {
// Use the already loaded and merged project config
userConfig = options.projectConfig.user;
} else if (options.projectPaths?.configFile && fs.existsSync(options.projectPaths.configFile)) {
// Fallback: load config directly if not provided
const configDiscovery = new ConfigDiscovery();
const config = await configDiscovery.loadProjectConfig(options.projectPaths.configFile);
const config = await configDiscovery.loadProjectConfig(
options.projectPaths.configFile,
options.systemRoot,
);
userConfig = config?.user;
}

Expand Down
3 changes: 3 additions & 0 deletions src/core/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export const SystemConfigSchema = z.object({
output_format: z.enum(['png', 'svg']),
theme: z.enum(['default', 'dark', 'forest', 'neutral']).optional(),
timeout: z.number(),
scale: z.number().optional(),
backgroundColor: z.string().optional(),
fontFamily: z.string().optional(),
})
.optional(),
testing: z
Expand Down
182 changes: 121 additions & 61 deletions src/shared/mermaid-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type { SystemConfig } from '../core/schemas.js';
export interface MermaidBlock {
name: string;
code: string;
attributes: string; // Layout attributes like "{align=center, width=80%}"
startIndex: number;
endIndex: number;
}
Expand All @@ -16,6 +15,9 @@ export interface MermaidConfig {
output_format: 'png' | 'svg';
theme?: 'default' | 'dark' | 'forest' | 'neutral';
timeout: number;
scale?: number; // Scale factor for high-DPI images (default: 2 for PNG, 1 for SVG)
backgroundColor?: string; // Background color (default: 'white')
fontFamily?: string; // Font family for SVG text (default: 'arial,sans-serif')
}

export interface DiagramGenerationResult {
Expand Down Expand Up @@ -43,9 +45,12 @@ export class MermaidProcessor {
output_format: 'png',
theme: 'default',
timeout: 30,
scale: 2, // 2x scale for crisp images in presentations
backgroundColor: 'white',
fontFamily: 'arial,sans-serif', // Web-safe font for SVG compatibility
};

const mermaidConfig = systemConfig.mermaid || defaultConfig;
const mermaidConfig = { ...defaultConfig, ...systemConfig.mermaid };
return new MermaidProcessor(mermaidConfig);
}

Expand All @@ -69,54 +74,21 @@ export class MermaidProcessor {
}
}

/**
* Parse layout attributes from attribute string
* Supports: {align=center, width=80%, layout=horizontal}
*/
private parseLayoutAttributes(attributeString: string): {
width?: number;
height?: number;
layout?: string;
} {
const options: { width?: number; height?: number; layout?: string } = {};

if (!attributeString) return options;

// Remove braces and split by commas
const cleaned = attributeString.replace(/[{}]/g, '').trim();
const attributes = cleaned.split(',').map((attr) => attr.trim());

for (const attr of attributes) {
const [key, value] = attr.split('=').map((s) => s.trim());

if (key === 'layout') {
options.layout = value;
} else if (key === 'width' && value.includes('px')) {
options.width = parseInt(value.replace('px', ''));
} else if (key === 'height' && value.includes('px')) {
options.height = parseInt(value.replace('px', ''));
}
}

return options;
}

/**
* Extract Mermaid blocks from markdown content
* Supports syntax: ```mermaid:diagram-name {align=center, width=80%, layout=horizontal}
* Supports syntax: ```mermaid:diagram-name
*/
extractMermaidBlocks(markdown: string): MermaidBlock[] {
const blocks: MermaidBlock[] = [];

// Match mermaid:name with optional attributes code blocks
const regex = /```mermaid:([\w-]+)(\s*\{[^}]*\})?\s*\n([\s\S]*?)\n```/g;
// Match mermaid:name code blocks
const regex = /```mermaid:([\w-]+)\s*\n([\s\S]*?)\n```/g;
let match;

while ((match = regex.exec(markdown)) !== null) {
blocks.push({
name: match[1],
attributes: match[2]?.trim() || '', // Optional attributes like {align=center, width=80%}
code: match[3],
code: match[2],
startIndex: match.index,
endIndex: match.index + match[0].length,
});
Expand All @@ -128,11 +100,7 @@ export class MermaidProcessor {
/**
* Generate a diagram from Mermaid code using Mermaid CLI
*/
async generateDiagram(
mermaidCode: string,
outputPath: string,
options?: { width?: number; height?: number; layout?: string },
): Promise<DiagramGenerationResult> {
async generateDiagram(mermaidCode: string, outputPath: string): Promise<DiagramGenerationResult> {
const isAvailable = await MermaidProcessor.detectMermaidCLI();

if (!isAvailable) {
Expand All @@ -158,18 +126,38 @@ export class MermaidProcessor {
// Build Mermaid CLI command
const theme = this.config.theme || 'default';
const timeout = this.config.timeout * 1000;
const scale = this.config.scale || (this.config.output_format === 'png' ? 2 : 1);
const backgroundColor = this.config.backgroundColor || 'white';

// Add quality parameters
let qualityParams = '';
qualityParams += ` -s ${scale}`; // Scale factor for high-DPI
qualityParams += ` -b ${backgroundColor}`; // Background color

// Add configuration file for SVG fonts if needed
if (this.config.output_format === 'svg' && this.config.fontFamily) {
const configContent = {
fontFamily: this.config.fontFamily,
theme: {
primaryColor: '#000000',
primaryTextColor: '#000000',
fontFamily: this.config.fontFamily,
},
};

const configFile = path.join(tempDir, `mermaid-config-${Date.now()}.json`);
fs.writeFileSync(configFile, JSON.stringify(configContent));
qualityParams += ` -c "${configFile}"`;

// Add size constraints based on layout hints
let sizeParams = '';
if (options?.layout === 'horizontal' || options?.width) {
const width = options?.width || 1200; // Max width for horizontal layouts
sizeParams += ` -w ${width}`;
} else if (options?.layout === 'layered' || options?.height) {
const height = options?.height || 800; // Max height for vertical layouts
sizeParams += ` -H ${height}`;
// Clean up config file after execution
setTimeout(() => {
if (fs.existsSync(configFile)) {
fs.unlinkSync(configFile);
}
}, 5000);
}

const command = `npx @mermaid-js/mermaid-cli -i "${tempInputFile}" -o "${outputPath}" -t ${theme}${sizeParams}`;
const command = `npx @mermaid-js/mermaid-cli -i "${tempInputFile}" -o "${outputPath}" -t ${theme}${qualityParams}`;

execSync(command, {
timeout,
Expand Down Expand Up @@ -202,9 +190,51 @@ export class MermaidProcessor {
}
}

/**
* Check if intermediate file content has changed
*/
private hasIntermediateContentChanged(intermediateFile: string, newContent: string): boolean {
if (!fs.existsSync(intermediateFile)) {
return true; // File doesn't exist, so content has "changed"
}

const existingContent = fs.readFileSync(intermediateFile, 'utf8');
return existingContent !== newContent;
}

/**
* Check if image file needs regeneration based on timestamps
*/
private needsImageRegeneration(imageFile: string, intermediateFile: string): boolean {
if (!fs.existsSync(imageFile)) {
return true; // Image doesn't exist, need to generate
}

if (!fs.existsSync(intermediateFile)) {
return true; // Source doesn't exist, need to generate
}

try {
const imageStats = fs.statSync(imageFile);
const intermediateStats = fs.statSync(intermediateFile);

// Handle mocked fs in tests where mtime might be undefined
if (!imageStats.mtime || !intermediateStats.mtime) {
return true; // Default to regenerating if we can't get timestamps
}

// Regenerate if intermediate file is newer than image
return intermediateStats.mtime > imageStats.mtime;
} catch {
// If we can't get stats, default to regenerating
return true;
}
}

/**
* Process markdown content by extracting Mermaid blocks, generating diagrams,
* and replacing blocks with image references including layout attributes
* and replacing blocks with image references.
* Implements caching based on content changes and file timestamps.
*/
async processMarkdown(
markdown: string,
Expand Down Expand Up @@ -248,15 +278,45 @@ export class MermaidProcessor {
? path.relative(intermediateDir, outputPath).replace(/\\/g, '/')
: `assets/${outputFileName}`;

// Save intermediate Mermaid source file for debugging
let shouldGenerateImage = true;

// Save intermediate Mermaid source file with caching logic
if (intermediateDir) {
const mermaidSourcePath = path.join(intermediateDir, `${block.name}.mmd`);
fs.writeFileSync(mermaidSourcePath, block.code);

// Check if content has changed
const contentChanged = this.hasIntermediateContentChanged(mermaidSourcePath, block.code);

if (contentChanged) {
// Content changed, update the intermediate file
fs.writeFileSync(mermaidSourcePath, block.code);
console.info(`📝 Updated intermediate file: ${block.name}.mmd`);
} else {
console.info(`⏭️ Skipped intermediate file (unchanged): ${block.name}.mmd`);
}

// Check if image needs regeneration based on timestamps
shouldGenerateImage = this.needsImageRegeneration(outputPath, mermaidSourcePath);

if (!shouldGenerateImage) {
console.info(`⚡ Skipped image generation (up to date): ${outputFileName}`);
}
} else {
// No intermediate directory, always generate
shouldGenerateImage = true;
}

// Parse layout attributes from block attributes
const layoutOptions = this.parseLayoutAttributes(block.attributes);
const result = await this.generateDiagram(block.code, outputPath, layoutOptions);
let result;
if (shouldGenerateImage) {
result = await this.generateDiagram(block.code, outputPath);

if (result.success) {
console.info(`🎨 Generated diagram: ${outputFileName}`);
}
} else {
// Skip generation, assume success since file exists
result = { success: true, outputPath };
}

if (result.success) {
diagrams.push({
Expand All @@ -265,8 +325,8 @@ export class MermaidProcessor {
relativePath,
});

// Replace Mermaid block with image reference including layout attributes
const imageMarkdown = `![${block.name}](${relativePath})${block.attributes}`;
// Replace Mermaid block with image reference
const imageMarkdown = `![${block.name}](${relativePath})`;
processedMarkdown =
processedMarkdown.slice(0, block.startIndex) +
imageMarkdown +
Expand Down
Loading