diff --git a/README.md b/README.md index a4d95ba..c2ee68c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Tests](https://img.shields.io/badge/Tests-145%20passing-brightgreen)](https://github.com/nickhart/markdown-workflow/actions/workflows/ci.yml) [![Node.js](https://img.shields.io/badge/Node.js-20+-brightgreen)](https://nodejs.org/) [![pnpm](https://img.shields.io/badge/pnpm-10+-blue)](https://pnpm.io/) +[![Turbo](https://img.shields.io/badge/Turbo-2+-red)](https://turbo.build/) [![TypeScript](https://img.shields.io/badge/TypeScript-5+-blue)](https://www.typescriptlang.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -58,12 +59,14 @@ And if you're using Git to track your repository (recommended!) you can commit c ### Presentation Workflow (New!) - **Create presentations:** `wf create presentation "My Presentation Title"` -- **Mermaid diagrams:** Automatic processing of `mermaid:name` code blocks +- **Multi-engine diagrams:** Automatic processing of Mermaid, PlantUML, and Graphviz code blocks - **Rich formatting:** Convert to PPTX with embedded diagrams - **Status tracking:** Draft → review → published - **Asset management:** Auto-generated images in `assets/` directory -Example Mermaid block: +Example diagram blocks: + +**Mermaid (flowcharts, sequence diagrams):** ````markdown ```mermaid:architecture {align=center, width=90%} @@ -73,6 +76,32 @@ graph TB ``` ```` +**PlantUML (UML diagrams):** + +````markdown +```plantuml:class-diagram +@startuml +class User { + +name: string + +email: string + +login() +} +@enduml +``` +```` + +**Graphviz (network graphs, decision trees):** + +````markdown +```graphviz:network {layout=dot} +digraph { + rankdir=LR; + A -> B -> C; + A -> D -> C; +} +``` +```` + ### Template System - **Inheritance:** Project templates override system defaults @@ -83,24 +112,32 @@ graph TB ### Processor System - **Modular Processing:** Each workflow specifies which processors to use -- **Job Applications:** Clean documents with no special processing -- **Presentations:** Rich diagrams with Mermaid, PlantUML support +- **Job Applications:** Clean documents with emoji processing only +- **Presentations:** Rich diagrams with multiple diagram engines and emoji support - **Extensible:** Add custom processors for your specific needs Available processors: -- 🧩 **Mermaid** - Generate diagrams from code blocks -- 🌱 **PlantUML** - Create UML diagrams and flowcharts -- šŸ˜€ **Emoji** - Convert shortcodes to Unicode emoji -- šŸ”Œ **Custom** - Build your own processors +- 🧩 **Mermaid** - Generate flowcharts, sequence diagrams, and more from code blocks +- 🌱 **PlantUML** - Create UML diagrams, activity diagrams, and flowcharts +- šŸ“Š **Graphviz** - Generate professional network graphs, decision trees, and complex diagrams +- šŸ˜€ **Emoji** - Convert shortcodes to Unicode emoji (`:rocket:` → šŸš€) +- šŸ”Œ **Custom** - Build your own processors for specialized content ## šŸš€ Quick Start ### Prerequisites - Node.js 20+ -- pnpm (recommended) or npm +- pnpm (for package management) - pandoc (for document formatting) +- turbo (for monorepo build orchestration) + +**Optional (for diagram processing):** + +- graphviz (for Graphviz processor) +- plantuml (for PlantUML processor) +- @mermaid-js/mermaid-cli (for Mermaid processor - installed automatically) ### Installation @@ -110,7 +147,7 @@ Available processors: git clone https://github.com/yourusername/markdown-workflow.git cd markdown-workflow pnpm install -pnpm cli:build +turbo cli:build # CLI is now available at dist/cli/index.js # Use with: node dist/cli/index.js @@ -234,6 +271,86 @@ wf format job collection_id --format pdf wf format job collection_id --format html ``` +### Diagram Processing + +The system supports three powerful diagram engines for different use cases: + +#### 🧩 Mermaid - Interactive Diagrams + +Best for: flowcharts, sequence diagrams, Gantt charts, mindmaps + +````markdown +```mermaid:system-flow +graph TB + A[User Request] --> B{Auth Check} + B -->|Valid| C[Process Request] + B -->|Invalid| D[Return Error] + C --> E[Response] +``` +```` + +```` + +**Supported diagram types:** flowchart, sequence, gantt, pie, journey, gitgraph, mindmap, timeline + +#### 🌱 PlantUML - Professional UML + +Best for: class diagrams, activity diagrams, use case diagrams, component diagrams + +```markdown +```plantuml:auth-sequence +@startuml +User -> AuthService: login(credentials) +AuthService -> Database: validateUser() +Database --> AuthService: userInfo +AuthService --> User: token +@enduml +```` + +```` + +**Supported diagram types:** class, sequence, usecase, activity, component, state, object, deployment + +#### šŸ“Š Graphviz - Technical Graphs + +Best for: network topologies, decision trees, dependency graphs, org charts + +```markdown +```graphviz:dependency-graph {layout=dot} +digraph Dependencies { + rankdir=LR; + node [shape=box]; + + Frontend -> API; + API -> Database; + API -> Cache; + Frontend -> CDN; +} +```` + +```` + +**Layout engines:** dot (hierarchical), neato (spring), fdp (force-directed), circo (circular), twopi (radial) + +### Emoji Processing + +The emoji processor converts shortcodes like `:rocket:` to Unicode emoji šŸš€. It uses GitHub's standard emoji names with convenient aliases for frequently used emojis. + +**Examples:** + +```markdown +Looking forward to working at DoorDash :takeout_box:! +This project is :fire: and I'm :thumbsup: about it! +More info available here :information_source: +```` + +**Naming Convention:** + +- **GitHub Standard Names** (preferred): `:rocket:`, `:fire:`, `:thumbsup:`, `:takeout_box:` +- **Convenient Aliases**: `:thumbs_up:` (alias for `:thumbsup:`), `:info:` (alias for `:information_source:`) + +The processor supports 100+ emojis covering tech, food, emotions, and professional contexts. For the complete list, see the [GitHub Emoji API](https://api.github.com/emojis). + ### Migration & Utilities ```bash @@ -306,25 +423,24 @@ rejected rejected rejected rejected declined ```bash pnpm install # Install dependencies -pnpm cli:build # Build CLI (cached with TurboRepo) -pnpm test # Run unit tests -pnpm test:e2e:snapshots # Run E2E snapshot tests +turbo cli:build # Build CLI (cached with TurboRepo) +turbo test # Run unit tests +turbo test:e2e:snapshots # Run E2E snapshot tests ``` ### Quality Assurance Commands ```bash # Quick validation (essential checks only) -pnpm preflight # Build + unit tests + lint + format check +turbo preflight # Build + unit tests + lint + format check # Comprehensive validation (includes E2E tests) -pnpm preflight:full # Build + unit tests + lint + format check + E2E snapshots -turbo preflight:full # Same as above, runs via Turbo +turbo preflight:full # Build + unit tests + lint + format check + E2E snapshots # Individual quality checks -pnpm lint # ESLint code quality -pnpm format:check # Prettier formatting check -pnpm format # Auto-fix formatting issues +turbo lint # ESLint code quality +turbo format:check # Prettier formatting check +turbo format # Auto-fix formatting issues ``` ### Testing @@ -396,7 +512,7 @@ Get AI-powered code reviews on your pull requests using Claude: 1. Fork the repository 2. Create a feature branch 3. Add tests for new functionality -4. Run quality checks: `pnpm preflight:full` (includes all tests, linting, and formatting) +4. Run quality checks: `turbo preflight:full` (includes all tests, linting, and formatting) 5. Submit a pull request 6. _Optional_: Request AI review with `/claude-review` for additional feedback diff --git a/example/.DS_Store b/example/.DS_Store index 2a8035a..5b8faf1 100644 Binary files a/example/.DS_Store and b/example/.DS_Store differ diff --git a/example/job/submitted/test_corp_senior_developer_20250806/cover_letter_your_name.md b/example/job/submitted/test_corp_senior_developer_20250806/cover_letter_your_name.md index 03b3f0f..7eed9bb 100644 --- a/example/job/submitted/test_corp_senior_developer_20250806/cover_letter_your_name.md +++ b/example/job/submitted/test_corp_senior_developer_20250806/cover_letter_your_name.md @@ -62,3 +62,8 @@ Thank you for considering my application. I am excited about the possibility of _Attachments: Resume, Portfolio_ _Contact: [your.email@example.com](mailto:your.email@example.com) | (555) 123-4567_ _LinkedIn: [linkedin.com/in/yourname](https://linkedin.com/in/yourname) | GitHub: [github.com/yourusername](https://github.com/yourusername/)_ + + +Looking forward to helping DoorDash deliver amazing experiences :takeout_box: to customers everywhere! + + diff --git a/example/presentation/draft/test_mermaid_presentation_20250731/assets/architecture.png b/example/presentation/draft/test_mermaid_presentation_20250731/assets/architecture.png index fa80a66..9ef48aa 100644 Binary files a/example/presentation/draft/test_mermaid_presentation_20250731/assets/architecture.png and b/example/presentation/draft/test_mermaid_presentation_20250731/assets/architecture.png differ diff --git a/example/presentation/draft/test_mermaid_presentation_20250731/assets/solution-overview.png b/example/presentation/draft/test_mermaid_presentation_20250731/assets/solution-overview.png index 894c44c..9f38253 100644 Binary files a/example/presentation/draft/test_mermaid_presentation_20250731/assets/solution-overview.png and b/example/presentation/draft/test_mermaid_presentation_20250731/assets/solution-overview.png differ diff --git a/package.json b/package.json index a919b9f..093dd1d 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "type": "module", - "packageManager": "pnpm@10.12.1", + "packageManager": "pnpm@10.14.0", "engines": { "node": ">=20", "pnpm": ">=10" diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh index c73d20a..821a926 100755 --- a/scripts/test-e2e.sh +++ b/scripts/test-e2e.sh @@ -24,12 +24,12 @@ log_info() { log_success() { echo -e "${GREEN}[PASS]${NC} $1" - ((TESTS_PASSED++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) } log_error() { echo -e "${RED}[FAIL]${NC} $1" - ((TESTS_FAILED++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) } log_warning() { @@ -42,7 +42,7 @@ run_test() { local expected_exit_code="${3:-0}" local expected_output="$4" - ((TESTS_RUN++)) + TESTS_RUN=$((TESTS_RUN + 1)) log_info "Running test: $test_name" # Capture both stdout and stderr, and exit code @@ -89,12 +89,25 @@ setup_test_env() { mkdir -p "$PROJECT_TEST_DIR" mkdir -p "$NON_PROJECT_DIR" - # Build the project - log_info "Building project..." - npm run build > /dev/null 2>&1 + # Build the CLI + log_info "Building CLI..." + if ! pnpm cli:build > /dev/null 2>&1; then + log_error "Failed to build CLI" + exit 1 + fi + + # Create minimal system directory structure (avoid copying huge node_modules, .git, etc.) + log_info "Creating minimal system directory structure..." + mkdir -p "$SYSTEM_TEST_DIR/markdown-workflow" + + # Copy only essential files and directories needed for E2E tests + cp package.json "$SYSTEM_TEST_DIR/markdown-workflow/" + cp -r dist "$SYSTEM_TEST_DIR/markdown-workflow/" 2>/dev/null || true + cp -r workflows "$SYSTEM_TEST_DIR/markdown-workflow/" 2>/dev/null || true + cp -r test-configs "$SYSTEM_TEST_DIR/markdown-workflow/" 2>/dev/null || true + cp -r scripts "$SYSTEM_TEST_DIR/markdown-workflow/" 2>/dev/null || true - # Create symlink to simulate being in system directory - cp -r . "$SYSTEM_TEST_DIR/markdown-workflow" + log_info "System directory structure created (avoided copying node_modules, .git, etc.)" log_info "Test environment ready:" log_info " System dir: $SYSTEM_TEST_DIR/markdown-workflow" @@ -113,20 +126,20 @@ test_cli_commands() { log_info "=== Testing CLI Command Availability ===" # Test that CLI is built and available - run_test "CLI help command" "node dist/cli/index.js --help" 0 "Usage:" + run_test "CLI help command" "node '$PWD/dist/cli/index.js' --help" 0 "Usage:" } # Test CLI commands work correctly test_cli_functionality() { log_info "=== Testing Basic CLI Functionality ===" - # Test that CLI commands work from any directory (including system directory) - run_test "Init can run from system directory" \ - "cd '$SYSTEM_TEST_DIR/markdown-workflow' && node dist/cli/index.js init" \ + # Test that CLI commands work from any directory (using absolute path to CLI) + run_test "Init can run from different directory" \ + "cd '$SYSTEM_TEST_DIR' && node '$PWD/dist/cli/index.js' init" \ 0 "Project initialized successfully" # Clean up the init we just created - rm -rf "$SYSTEM_TEST_DIR/markdown-workflow/.markdown-workflow" + rm -rf "$SYSTEM_TEST_DIR/.markdown-workflow" } # Test init command functionality @@ -151,10 +164,6 @@ test_init_command() { "test -d '$PROJECT_TEST_DIR/.markdown-workflow/workflows'" \ 0 - run_test "Collections directory created" \ - "test -d '$PROJECT_TEST_DIR/.markdown-workflow/collections'" \ - 0 - # Test init in existing project without force should fail run_test "Init in existing project should fail" \ "cd '$PROJECT_TEST_DIR' && node '$PWD/dist/cli/index.js' init" \ @@ -173,11 +182,11 @@ test_project_context_commands() { # Test commands that should fail outside project run_test "List outside project should fail" \ "cd '$NON_PROJECT_DIR' && node '$PWD/dist/cli/index.js' list job" \ - 1 "Project root not found" + 1 "Not in a markdown-workflow project" run_test "Create outside project should fail" \ "cd '$NON_PROJECT_DIR' && node '$PWD/dist/cli/index.js' create job 'Test Company' 'Test Role'" \ - 1 "Project root not found" + 1 "Not in a markdown-workflow project" # Test commands that should work inside project run_test "List inside project should work" \ @@ -192,7 +201,7 @@ test_workflow_operations() { # Test creating a collection run_test "Create job collection" \ "cd '$PROJECT_TEST_DIR' && node '$PWD/dist/cli/index.js' create job 'Test Company' 'Software Engineer'" \ - 0 "Created collection" + 0 "Collection created successfully" # Test listing collections run_test "List job collections" \ @@ -246,6 +255,9 @@ main() { echo "Tests Failed: $TESTS_FAILED" echo + # Explicit cleanup + cleanup_test_env + if [ "$TESTS_FAILED" -eq 0 ]; then log_success "All tests passed! šŸŽ‰" exit 0 diff --git a/src/core/schemas.ts b/src/core/schemas.ts index b8114c6..ccb719f 100644 --- a/src/core/schemas.ts +++ b/src/core/schemas.ts @@ -139,6 +139,17 @@ export const SystemConfigSchema = z.object({ fontFamily: z.string().optional(), }) .optional(), + graphviz: z + .object({ + output_format: z.enum(['png', 'svg', 'pdf', 'jpeg']), + layout_engine: z.enum(['dot', 'neato', 'fdp', 'sfdp', 'twopi', 'circo']), + timeout: z.number(), + dpi: z.number().optional(), + theme: z.enum(['default', 'dark', 'light']).optional(), + backgroundColor: z.string().optional(), + fontFamily: z.string().optional(), + }) + .optional(), testing: z .object({ // Date/Time overrides diff --git a/src/core/workflow-engine.ts b/src/core/workflow-engine.ts index cccb92a..d269bca 100644 --- a/src/core/workflow-engine.ts +++ b/src/core/workflow-engine.ts @@ -9,6 +9,7 @@ import { type ProjectConfig, type WorkflowAction, type WorkflowStatic, + type WorkflowTemplate, } from './schemas.js'; import { Collection, type CollectionMetadata } from './types.js'; import { getCurrentISODate, formatDate, getCurrentDate } from '../shared/date-utils.js'; @@ -351,13 +352,83 @@ export class WorkflowEngine { if (filesToConvert.length === 0) { throw new Error(`No files found for requested artifacts: ${requestedArtifacts.join(', ')}`); } + } else { + // Default behavior: convert all workflow templates except notes/personal templates + const templateToFileMap = await this.getTemplateArtifactMap(workflow, collection); + + // Get all template names from workflow, excluding personal/note templates + const excludedTemplates = ['notes']; // Templates that shouldn't be converted by default + const mainDocumentTemplates = workflow.workflow.templates + .map((template) => template.name) + .filter((name) => !excludedTemplates.includes(name)); + + const defaultFiles = new Set(); + + for (const templateName of mainDocumentTemplates) { + const files = templateToFileMap.get(templateName); + if (files) { + files.forEach((file) => defaultFiles.add(file)); + } + } + + filesToConvert = markdownFiles.filter((file) => defaultFiles.has(file)); + + if (filesToConvert.length === 0) { + console.log( + `ā„¹ļø No main document artifacts found to convert (${mainDocumentTemplates.join(', ')})`, + ); + return; + } + + console.log(`šŸ“„ Converting main documents: ${filesToConvert.join(', ')}`); } // Convert the filtered files for (const file of filesToConvert) { const inputPath = path.join(collection.path, file); const baseName = path.basename(file, '.md'); - const outputPath = path.join(outputDir, `${baseName}.${formatType}`); + + // Generate output filename using template output pattern with variable substitution + let outputFileName = `${baseName}.${formatType}`; // fallback to simple naming + + // Try to generate smart output filename using template patterns + try { + // Find matching template by examining the artifact filename + let matchingTemplate: WorkflowTemplate | null = null; + + // Simple matching: find template whose name appears in the filename + for (const template of workflow.workflow.templates) { + if (file.includes(template.name)) { + matchingTemplate = template; + break; + } + } + + if (matchingTemplate && matchingTemplate.output) { + console.log( + `šŸ”§ Using template '${matchingTemplate.name}' output pattern: ${matchingTemplate.output}`, + ); + + // Build template variables (same as template processing) + const templateVariables = this.buildTemplateVariables(collection); + + // Apply variable substitution to template output pattern + const processedFileName = Mustache.render(matchingTemplate.output, templateVariables); + + // Replace .md extension with target format + outputFileName = processedFileName.replace(/\.md$/, `.${formatType}`); + console.log(`šŸŽÆ Generated output filename: ${outputFileName}`); + } else { + console.log(`āš ļø No template found for ${file}, using fallback naming`); + } + } catch (error) { + console.warn( + `Warning: Could not determine template for ${file}, using fallback naming:`, + error, + ); + } + + const outputPath = path.join(outputDir, outputFileName); try { const formatTypeStr = String(formatType); @@ -563,19 +634,30 @@ export class WorkflowEngine { await this.ensureProjectConfigLoaded(); const templateMap = new Map(); + console.log(`šŸ” Template-to-artifact mapping debug:`); + console.log( + `šŸ“‹ Available templates: ${workflow.workflow.templates.map((t) => `${t.name} (output: ${t.output})`).join(', ')}`, + ); + console.log(`šŸ“ Collection artifacts: ${collection.artifacts.join(', ')}`); + // For each template in the workflow, resolve its output filename for (const template of workflow.workflow.templates) { + console.log(`\nšŸ” Processing template: ${template.name}`); + console.log(` Template output pattern: ${template.output}`); + try { // Find all collection artifacts that match this template pattern const matchingFiles = collection.artifacts.filter((artifact) => { - // Simple approach: check if the artifact filename starts with the template name - // This handles cases like "resume" -> "resume_nicholas_hart.md" + console.log(` šŸ” Checking artifact: ${artifact}`); + // Pattern 1: Template name prefix (e.g., "resume_*.md") if (artifact.startsWith(`${template.name}_`) && artifact.endsWith('.md')) { + console.log(` āœ… MATCH: Prefix pattern (${template.name}_*)`); return true; } - // For dynamic templates (like notes with prefixes), we need pattern matching + // Pattern 2: Prefix templates (e.g., "{{prefix}}_notes.md") if (template.output.includes('{{prefix}}')) { + console.log(` šŸ” Trying prefix pattern matching...`); // Match any file that could have been generated from this template // Example: "{{prefix}}_notes.md" should match "recruiter_notes.md" const basePattern = template.output.replace('{{prefix}}', '(.+)'); @@ -587,14 +669,21 @@ export class WorkflowEngine { .replace('\\(\\.\\+\\)', '(.+)') + '$', ); - return regex.test(artifact); - } catch { + if (regex.test(artifact)) { + console.log(` āœ… MATCH: Prefix template pattern`); + return true; + } else { + console.log(` āŒ No match: Prefix template pattern`); + } + } catch (error) { + console.log(` āŒ Error in prefix pattern: ${error}`); return false; } } - // Fallback: try to match the exact template output pattern + // Pattern 3: Template output pattern with variable substitution if (template.output && !template.output.includes('{{prefix}}')) { + console.log(` šŸ” Trying output pattern matching...`); // For templates with user variables, try to match flexibly let pattern = template.output; @@ -608,26 +697,93 @@ export class WorkflowEngine { pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('\\(\\.\\+\\)', '(.+)') + '$', ); - return regex.test(artifact); - } catch { + if (regex.test(artifact)) { + console.log(` āœ… MATCH: Output pattern (${template.output})`); + return true; + } else { + console.log(` āŒ No match: Output pattern (${template.output})`); + } + } catch (error) { + console.log(` āŒ Error in output pattern: ${error}`); return false; } } + // Pattern 4: Exact template name match (fallback for legacy compatibility) + if (artifact === `${template.name}.md`) { + console.log(` āœ… MATCH: Exact template name (${template.name}.md)`); + return true; + } + + console.log(` āŒ NO MATCHES: All patterns failed`); return false; }); if (matchingFiles.length > 0) { + console.log(` āœ… Template '${template.name}' → [${matchingFiles.join(', ')}]`); templateMap.set(template.name, matchingFiles); + } else { + console.log(` āŒ Template '${template.name}' → No matching artifacts`); } } catch (error) { console.warn(`Warning: Could not resolve output for template ${template.name}:`, error); } } + console.log(`\nšŸ“„ Final template-to-artifact mapping:`); + for (const [templateName, artifacts] of templateMap.entries()) { + console.log(` ${templateName} → [${artifacts.join(', ')}]`); + } + return templateMap; } + /** + * Build template variables for variable substitution + * Includes user config, collection metadata, and sanitized versions + */ + private buildTemplateVariables(collection: Collection) { + // Get user config + const userConfig = this.projectConfig?.user || this.getDefaultUserConfig(); + + // Extract title from collection metadata or collection ID + let title = ''; + if (collection.metadata.title && typeof collection.metadata.title === 'string') { + title = collection.metadata.title; + } else { + // Derive title from collection ID (e.g., "cloud_schedules_retrospective_20250801" -> "cloud_schedules_retrospective") + const collectionId = collection.metadata.collection_id; + const idParts = collectionId.split('_'); + const datePart = idParts[idParts.length - 1]; + if (/^\d{8}$/.test(datePart)) { + // Remove date part if it's 8 digits (YYYYMMDD format) + title = idParts.slice(0, -1).join(' '); + } else { + title = collectionId; + } + } + + return { + date: formatDate( + getCurrentDate(this.projectConfig || undefined), + 'LONG_DATE', + this.projectConfig || undefined, + ), + user: { + ...userConfig, + // Add sanitized versions for filenames + name_sanitized: sanitizeForFilename(userConfig.name), + preferred_name_sanitized: sanitizeForFilename(userConfig.preferred_name), + }, + // Collection-specific variables + title: title, + title_sanitized: sanitizeForFilename(title), + collection_id: collection.metadata.collection_id, + company: typeof collection.metadata.company === 'string' ? collection.metadata.company : '', + role: typeof collection.metadata.role === 'string' ? collection.metadata.role : '', + }; + } + /** * Get default user configuration */ diff --git a/src/shared/converters/base-converter.ts b/src/shared/converters/base-converter.ts index 2510526..d887601 100644 --- a/src/shared/converters/base-converter.ts +++ b/src/shared/converters/base-converter.ts @@ -160,6 +160,20 @@ export abstract class BaseConverter { }; } + // If no blocks were actually processed, don't create intermediate files + if (result.blocksProcessed === 0) { + return { + success: true, + // No processedFile - use original input file + artifacts: result.artifacts, + }; + } + + // Ensure intermediate directory exists before writing + if (!fs.existsSync(context.intermediateDir)) { + fs.mkdirSync(context.intermediateDir, { recursive: true }); + } + // Write processed content to temporary file const processedFileName = path.basename(context.inputFile, path.extname(context.inputFile)) + '_processed.md'; diff --git a/src/shared/processors/emoji-processor.ts b/src/shared/processors/emoji-processor.ts index 3657ae5..bdcd452 100644 --- a/src/shared/processors/emoji-processor.ts +++ b/src/shared/processors/emoji-processor.ts @@ -1,6 +1,11 @@ /** * Emoji processor * Converts emoji shortcodes like :rocket: to Unicode emoji šŸš€ + * + * Uses GitHub's standard emoji shortcode names as the primary mapping, + * with additional convenient aliases for frequently used emojis. + * + * Standard names source: https://api.github.com/emojis */ import * as fs from 'fs'; @@ -11,23 +16,28 @@ import { ProcessingResult, } from './base-processor.js'; -// Common emoji mappings +// GitHub standard emoji mappings with convenient aliases +// Priority: GitHub standard names first, then convenient aliases for frequently used emojis const EMOJI_MAP: Record = { + // === BASIC EMOJI (GitHub standard) === ':rocket:': 'šŸš€', ':star:': '⭐', ':fire:': 'šŸ”„', ':heart:': 'ā¤ļø', ':thumbsup:': 'šŸ‘', - ':thumbs_up:': 'šŸ‘', ':thumbsdown:': 'šŸ‘Ž', - ':thumbs_down:': 'šŸ‘Ž', ':warning:': 'āš ļø', + ':information_source:': 'ā„¹ļø', + ':gear:': 'āš™ļø', + + // === CONVENIENT ALIASES === + ':thumbs_up:': 'šŸ‘', // alias for :thumbsup: + ':thumbs_down:': 'šŸ‘Ž', // alias for :thumbsdown: + ':info:': 'ā„¹ļø', // alias for :information_source: ':check:': 'āœ…', ':white_check_mark:': 'āœ…', ':x:': 'āŒ', - ':info:': 'ā„¹ļø', ':lightbulb:': 'šŸ’”', - ':gear:': 'āš™ļø', ':folder:': 'šŸ“‚', ':file:': 'šŸ“„', ':link:': 'šŸ”—', @@ -77,6 +87,7 @@ const EMOJI_MAP: Record = { ':store:': 'šŸŖ', ':restaurant:': 'šŸ½ļø', ':pizza:': 'šŸ•', + ':takeout_box:': '🄔', ':coffee:': 'ā˜•', ':beer:': 'šŸŗ', ':wine:': 'šŸ·', @@ -154,11 +165,11 @@ const EMOJI_MAP: Record = { ':heavy_minus_sign:': 'āž–', ':smile:': '😊', ':electric_plug:': 'šŸ”Œ', - ':bulb:': 'šŸ’”', // Also mapped as :lightbulb: - ':mag:': 'šŸ”', // Also mapped as :search: - ':information_source:': 'ā„¹ļø', // Also mapped as :info: ':question:': 'ā“', ':exclamation:': 'ā—', + // === ADDITIONAL ALIASES FOR TESTING === + ':bulb:': 'šŸ’”', // alias for :lightbulb: + ':mag:': 'šŸ”', // alias for :search: }; export class EmojiProcessor extends BaseProcessor { diff --git a/src/shared/processors/graphviz-processor.ts b/src/shared/processors/graphviz-processor.ts new file mode 100644 index 0000000..7b579c9 --- /dev/null +++ b/src/shared/processors/graphviz-processor.ts @@ -0,0 +1,412 @@ +/** + * Graphviz processor + * Processes Graphviz DOT blocks and generates diagram images + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + BaseProcessor, + ProcessorBlock, + ProcessingContext, + ProcessingResult, +} from './base-processor.js'; + +export interface GraphvizConfig { + output_format: 'png' | 'svg' | 'pdf' | 'jpeg'; + layout_engine: 'dot' | 'neato' | 'fdp' | 'sfdp' | 'twopi' | 'circo'; + timeout: number; + dpi?: number; // For high-resolution output + theme?: 'default' | 'dark' | 'light'; + backgroundColor?: string; + fontFamily?: string; + [key: string]: unknown; // Make it compatible with ProcessorConfig +} + +export interface GraphvizGenerationResult { + success: boolean; + outputPath?: string; + error?: string; +} + +/** + * Graphviz diagram processor + * Processes Graphviz DOT blocks and generates diagram images + */ +export class GraphvizProcessor extends BaseProcessor { + readonly name = 'graphviz'; + readonly description = 'Process Graphviz DOT diagrams and generate images'; + readonly version = '1.0.0'; + readonly intermediateExtension = '.dot'; + readonly supportedInputExtensions = ['.md', '.markdown']; + readonly outputExtensions = ['.png', '.svg', '.pdf', '.jpeg']; + readonly supportedOutputFormats = ['png', 'svg', 'pdf', 'jpeg']; + + private graphvizConfig: GraphvizConfig; + + constructor(config: GraphvizConfig) { + super(config); + this.graphvizConfig = config; + } + + /** + * Create processor from system configuration + */ + static fromSystemConfig(systemConfig: { graphviz?: Partial }): GraphvizProcessor { + // Provide default configuration if graphviz config is missing + const defaultConfig: GraphvizConfig = { + output_format: 'png', + layout_engine: 'dot', + timeout: 30, + dpi: 96, + theme: 'default', + backgroundColor: 'white', + fontFamily: 'arial,sans-serif', + }; + + const graphvizConfig = { ...defaultConfig, ...systemConfig.graphviz }; + return new GraphvizProcessor(graphvizConfig); + } + + /** + * Check if Graphviz CLI is available + */ + static async detectGraphvizCLI(): Promise { + try { + // Try to run the Graphviz CLI version command + execSync('dot -V', { + stdio: 'pipe', + timeout: 10000, + encoding: 'utf8', + }); + return true; + } catch (error) { + try { + // Alternative: check if graphviz is in PATH + execSync('which dot', { + stdio: 'pipe', + timeout: 5000, + encoding: 'utf8', + }); + return true; + } catch { + console.warn( + `āš ļø Graphviz CLI detection failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + } + } + + /** + * Check if content contains Graphviz blocks + */ + canProcess(content: string): boolean { + const regex = /```graphviz:[\w-]+.*?\n[\s\S]*?\n```/g; + return regex.test(content); + } + + /** + * Detect and extract Graphviz blocks from markdown content + * Supports syntax: ```graphviz:name {params...} + */ + detectBlocks(markdown: string): ProcessorBlock[] { + const blocks: ProcessorBlock[] = []; + + // Match graphviz:name {params} code blocks + const regex = /```graphviz:([\w-]+)(?:\s*\{([^}]*)\})?\s*\n([\s\S]*?)\n```/g; + let match; + + while ((match = regex.exec(markdown)) !== null) { + const [, name, params, content] = match; + blocks.push({ + name, + content, + startIndex: match.index, + endIndex: match.index + match[0].length, + metadata: { + params: params ? this.parseParams(params) : {}, + }, + }); + } + + return blocks; + } + + /** + * Parse parameters from the graphviz block header + * Format: {layout=neato, theme=dark, dpi=300} + */ + private parseParams(paramsString: string): Record { + const params: Record = {}; + + // Remove spaces and split by comma + const pairs = paramsString.replace(/\s/g, '').split(','); + + for (const pair of pairs) { + const [key, value] = pair.split('='); + if (key && value) { + params[key.trim()] = value.trim(); + } + } + + return params; + } + + /** + * Process content to generate Graphviz diagrams + */ + async process(content: string, context: ProcessingContext): Promise { + const blocks = this.detectBlocks(content); + + if (blocks.length === 0) { + return { + success: true, + processedContent: content, + artifacts: [], + blocksProcessed: 0, + }; + } + + // Check if Graphviz CLI is available + if (!(await GraphvizProcessor.detectGraphvizCLI())) { + console.warn('āš ļø Graphviz CLI not found. Diagrams will not be rendered.'); + console.warn(' Install Graphviz: https://graphviz.org/download/'); + + // Return content with warning comments + let processedContent = content; + for (const block of blocks) { + const warning = `\n`; + processedContent = processedContent.replace( + content.substring(block.startIndex, block.endIndex), + content.substring(block.startIndex, block.endIndex) + warning, + ); + } + + return { + success: true, + processedContent, + artifacts: [], + blocksProcessed: blocks.length, + }; + } + + this.ensureDirectories(context); + + let processedContent = content; + const artifacts: Array<{ + name: string; + path: string; + relativePath: string; + type: 'asset' | 'intermediate' | 'output'; + }> = []; + + // Process each block + for (const block of blocks) { + try { + const result = await this.generateDiagram(block, context); + + if (result.success && result.outputPath) { + // Add image reference to markdown + const imageRef = `\n![${block.name}](${this.getRelativePath(result.outputPath, context)})`; + processedContent = processedContent.replace( + content.substring(block.startIndex, block.endIndex), + content.substring(block.startIndex, block.endIndex) + imageRef, + ); + + // Add to artifacts + artifacts.push({ + name: path.basename(result.outputPath), + path: result.outputPath, + relativePath: this.getRelativePath(result.outputPath, context), + type: 'asset', + }); + + console.info(`šŸŽØ Generated Graphviz diagram: ${path.basename(result.outputPath)}`); + } else { + console.warn(`āš ļø Failed to generate diagram for ${block.name}: ${result.error}`); + } + } catch (error) { + console.error(`āŒ Error processing Graphviz block ${block.name}:`, error); + } + } + + // Write intermediate file + const intermediateFile = this.getIntermediateFilePath('processed', context); + const contentChanged = this.hasContentChanged(intermediateFile, processedContent); + + if (contentChanged) { + fs.writeFileSync(intermediateFile, processedContent, 'utf8'); + console.info(`šŸ“ Updated Graphviz intermediate file: ${path.basename(intermediateFile)}`); + } + + artifacts.push({ + name: path.basename(intermediateFile), + path: intermediateFile, + relativePath: this.getRelativePath(intermediateFile, context), + type: 'intermediate', + }); + + return { + success: true, + processedContent, + artifacts, + blocksProcessed: blocks.length, + }; + } + + /** + * Generate diagram image from DOT code + */ + private async generateDiagram( + block: ProcessorBlock, + context: ProcessingContext, + ): Promise { + try { + // Merge block-specific params with processor config + const config = { ...this.graphvizConfig }; + if (block.metadata?.params) { + const params = block.metadata.params as Record; + if ( + params.layout && + ['dot', 'neato', 'fdp', 'sfdp', 'twopi', 'circo'].includes(params.layout) + ) { + config.layout_engine = params.layout as GraphvizConfig['layout_engine']; + } + if (params.theme && ['default', 'dark', 'light'].includes(params.theme)) { + config.theme = params.theme as GraphvizConfig['theme']; + } + if (params.dpi) { + config.dpi = parseInt(params.dpi, 10); + } + } + + // Apply theme-specific styling + const styledContent = this.applyTheme(block.content, config); + + // Write DOT file + const dotFile = path.join(context.intermediateDir, `${block.name}.dot`); + fs.writeFileSync(dotFile, styledContent, 'utf8'); + + // Generate output file + const outputFile = path.join(context.assetsDir, `${block.name}.${config.output_format}`); + + // Build Graphviz command + const args = [`-T${config.output_format}`, `-K${config.layout_engine}`, `-o${outputFile}`]; + + // Add DPI for raster formats + if (['png', 'jpeg'].includes(config.output_format) && config.dpi) { + args.push(`-Gdpi=${config.dpi}`); + } + + // Add input file + args.push(dotFile); + + // Execute Graphviz + execSync(`dot ${args.join(' ')}`, { + cwd: context.intermediateDir, + stdio: 'pipe', + timeout: config.timeout * 1000, + }); + + if (fs.existsSync(outputFile)) { + return { + success: true, + outputPath: outputFile, + }; + } else { + return { + success: false, + error: 'Output file not created', + }; + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + + /** + * Apply theme and styling to DOT content + */ + private applyTheme(dotContent: string, config: GraphvizConfig): string { + let styledContent = dotContent; + + // Add global styling based on theme + const globalStyles: string[] = []; + + if (config.backgroundColor) { + globalStyles.push(`bgcolor="${config.backgroundColor}"`); + } + + if (config.fontFamily) { + globalStyles.push(`fontname="${config.fontFamily}"`); + } + + // Apply theme-specific styling + switch (config.theme) { + case 'dark': + globalStyles.push('bgcolor="#2d3748"', 'color="#e2e8f0"', 'fontcolor="#e2e8f0"'); + break; + case 'light': + globalStyles.push('bgcolor="#f7fafc"', 'color="#2d3748"', 'fontcolor="#2d3748"'); + break; + default: + // Use default colors + break; + } + + // Insert global styles at the beginning of the graph + if (globalStyles.length > 0) { + const graphMatch = styledContent.match(/^(digraph|graph)\s+(\w+)\s*\{/); + if (graphMatch) { + const insertPos = styledContent.indexOf('{') + 1; + const globalStyleString = globalStyles.map((style) => ` ${style};`).join('\n'); + styledContent = + styledContent.slice(0, insertPos) + + '\n' + + globalStyleString + + '\n' + + styledContent.slice(insertPos); + } + } + + return styledContent; + } + + /** + * Validate DOT syntax + */ + private validateDOT(dotCode: string): { valid: boolean; error?: string } { + try { + // Use Graphviz CLI to validate syntax + execSync(`echo '${dotCode}' | dot -Tsvg -o /dev/null`, { + stdio: 'pipe', + timeout: 10000, + }); + return { valid: true }; + } catch (error) { + return { + valid: false, + error: `Invalid DOT syntax: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Get supported layout engines + */ + getSupportedLayoutEngines(): string[] { + return ['dot', 'neato', 'fdp', 'sfdp', 'twopi', 'circo']; + } + + /** + * Get supported output formats + */ + getSupportedOutputFormats(): string[] { + return this.supportedOutputFormats; + } +} diff --git a/src/shared/processors/index.ts b/src/shared/processors/index.ts index 1c7e8cc..632de08 100644 --- a/src/shared/processors/index.ts +++ b/src/shared/processors/index.ts @@ -15,11 +15,13 @@ export type { export { MermaidProcessor } from '../mermaid-processor.js'; export { EmojiProcessor } from './emoji-processor.js'; export { PlantUMLProcessor } from './plantuml-processor.js'; +export { GraphvizProcessor } from './graphviz-processor.js'; import { defaultProcessorRegistry } from './base-processor.js'; import { MermaidProcessor } from '../mermaid-processor.js'; import { EmojiProcessor } from './emoji-processor.js'; import { PlantUMLProcessor } from './plantuml-processor.js'; +import { GraphvizProcessor } from './graphviz-processor.js'; // Convenience function to register default processors export function registerDefaultProcessors() { @@ -38,21 +40,34 @@ export function registerDefaultProcessors() { timeout: 30, }; + const graphvizConfig = { + output_format: 'png' as const, + layout_engine: 'dot' as const, + timeout: 30, + dpi: 96, + theme: 'default' as const, + backgroundColor: 'white', + fontFamily: 'arial,sans-serif', + }; + const mermaidProcessor = new MermaidProcessor(mermaidConfig); const emojiProcessor = new EmojiProcessor({}); const plantUMLProcessor = PlantUMLProcessor.create(plantUMLConfig); + const graphvizProcessor = new GraphvizProcessor(graphvizConfig); // Register processors in order (emoji first, then diagrams) defaultProcessorRegistry.register(emojiProcessor); defaultProcessorRegistry.register(mermaidProcessor); defaultProcessorRegistry.register(plantUMLProcessor); + defaultProcessorRegistry.register(graphvizProcessor); // Set processing order - defaultProcessorRegistry.setProcessorOrder(['emoji', 'mermaid', 'plantuml']); + defaultProcessorRegistry.setProcessorOrder(['emoji', 'mermaid', 'plantuml', 'graphviz']); return { emoji: emojiProcessor, mermaid: mermaidProcessor, plantuml: plantUMLProcessor, + graphviz: graphvizProcessor, }; } diff --git a/tests/unit/shared/converters/presentation-converter.test.ts b/tests/unit/shared/converters/presentation-converter.test.ts index 63367db..60ea5b0 100644 --- a/tests/unit/shared/converters/presentation-converter.test.ts +++ b/tests/unit/shared/converters/presentation-converter.test.ts @@ -40,7 +40,7 @@ describe('PresentationConverter', () => { success: true, processedContent: 'processed content', artifacts: [], - blocksProcessed: 0, + blocksProcessed: 1, // Process at least one block to trigger file creation }); }); @@ -122,6 +122,7 @@ describe('PresentationConverter', () => { inputFile: '/test/input.md', outputFile: '/test/output.pptx', format: 'pptx' as const, + enabledProcessors: ['mermaid'], // Enable processors to trigger file processing collectionPath: '/test/collection', assetsDir: '/test/assets', intermediateDir: '/test/intermediate', @@ -140,6 +141,14 @@ describe('PresentationConverter', () => { return false; }); + // Mock the processor registry to return processed content with processed file path + jest.spyOn(mockProcessorRegistry, 'processContent').mockResolvedValue({ + success: true, + processedContent: 'processed content', + artifacts: [], + blocksProcessed: 1, + }); + await converter.convert(context); // Should call pandoc with reference document and processed file @@ -163,6 +172,7 @@ describe('PresentationConverter', () => { inputFile: '/test/input.md', outputFile: '/test/output.pptx', format: 'pptx' as const, + enabledProcessors: ['mermaid'], // Enable processors to trigger file processing collectionPath: '/test', assetsDir: '/test/assets', intermediateDir: '/test/intermediate', @@ -196,6 +206,7 @@ describe('PresentationConverter', () => { inputFile: '/test/input.md', outputFile: '/test/output.html', format: 'html' as const, + enabledProcessors: ['mermaid'], // Enable processors to trigger file processing collectionPath: '/test', assetsDir: '/test/assets', intermediateDir: '/test/intermediate', @@ -273,6 +284,7 @@ flowchart TD inputFile: '/test/input.md', outputFile: '/test/output.pdf', format: 'pdf' as const, + enabledProcessors: ['mermaid'], // Enable processors to trigger file processing collectionPath: '/test', assetsDir: '/test/assets', intermediateDir: '/test/intermediate', @@ -309,6 +321,7 @@ flowchart TD inputFile: '/test/input.md', outputFile: '/test/output.html', format: 'html' as const, + enabledProcessors: ['mermaid'], // Enable processors to trigger file processing collectionPath: '/test', assetsDir: '/test/assets', intermediateDir: '/test/intermediate', diff --git a/tests/unit/shared/processors/graphviz-processor.test.ts b/tests/unit/shared/processors/graphviz-processor.test.ts new file mode 100644 index 0000000..8dbb38c --- /dev/null +++ b/tests/unit/shared/processors/graphviz-processor.test.ts @@ -0,0 +1,464 @@ +import { jest } from '@jest/globals'; +import { + GraphvizProcessor, + GraphvizConfig, +} from '../../../../src/shared/processors/graphviz-processor.js'; +import type { ProcessingContext } from '../../../../src/shared/processors/base-processor.js'; + +// Mock external dependencies +jest.mock('child_process'); +jest.mock('fs'); + +import { execSync } from 'child_process'; +import * as fs from 'fs'; + +const mockExecSync = jest.mocked(execSync); +const mockFs = jest.mocked(fs); + +describe('GraphvizProcessor', () => { + let processor: GraphvizProcessor; + let context: ProcessingContext; + + const defaultConfig: GraphvizConfig = { + output_format: 'png', + layout_engine: 'dot', + timeout: 30, + dpi: 96, + theme: 'default', + backgroundColor: 'white', + fontFamily: 'arial,sans-serif', + }; + + beforeEach(() => { + processor = new GraphvizProcessor(defaultConfig); + context = { + collectionPath: '/test/collection', + assetsDir: '/test/assets', + intermediateDir: '/test/intermediate', + outputFormat: 'md', + }; + + // Reset mocks + jest.clearAllMocks(); + mockFs.existsSync.mockReturnValue(true); + mockFs.mkdirSync.mockImplementation(() => undefined); + mockFs.writeFileSync.mockImplementation(() => undefined); + mockFs.readFileSync.mockReturnValue('test content'); + }); + + describe('constructor', () => { + it('should create processor with correct properties', () => { + expect(processor.name).toBe('graphviz'); + expect(processor.description).toBe('Process Graphviz DOT diagrams and generate images'); + expect(processor.version).toBe('1.0.0'); + expect(processor.supportedInputExtensions).toEqual(['.md', '.markdown']); + expect(processor.supportedOutputFormats).toEqual(['png', 'svg', 'pdf', 'jpeg']); + }); + + it('should accept configuration', () => { + const customConfig: GraphvizConfig = { + output_format: 'svg', + layout_engine: 'neato', + timeout: 60, + dpi: 300, + theme: 'dark', + backgroundColor: 'black', + fontFamily: 'monospace', + }; + + const customProcessor = new GraphvizProcessor(customConfig); + expect(customProcessor).toBeInstanceOf(GraphvizProcessor); + }); + }); + + describe('fromSystemConfig', () => { + it('should create processor with system config', () => { + const systemConfig = { + graphviz: { + output_format: 'svg', + layout_engine: 'neato', + timeout: 45, + }, + }; + + const processor = GraphvizProcessor.fromSystemConfig(systemConfig); + expect(processor).toBeInstanceOf(GraphvizProcessor); + }); + + it('should use defaults when system config is missing', () => { + const processor = GraphvizProcessor.fromSystemConfig({}); + expect(processor).toBeInstanceOf(GraphvizProcessor); + }); + }); + + describe('detectGraphvizCLI', () => { + it('should detect Graphviz CLI when available', async () => { + mockExecSync.mockReturnValue('dot - graphviz version 2.44.1' as ReturnType); + + const result = await GraphvizProcessor.detectGraphvizCLI(); + expect(result).toBe(true); + expect(mockExecSync).toHaveBeenCalledWith('dot -V', expect.any(Object)); + }); + + it('should fallback to which command when dot -V fails', async () => { + mockExecSync + .mockImplementationOnce(() => { + throw new Error('Command not found'); + }) + .mockReturnValue('/usr/bin/dot' as ReturnType); + + const result = await GraphvizProcessor.detectGraphvizCLI(); + expect(result).toBe(true); + expect(mockExecSync).toHaveBeenCalledTimes(2); + }); + + it('should return false when Graphviz CLI is not available', async () => { + mockExecSync.mockImplementation(() => { + throw new Error('Command not found'); + }); + + const result = await GraphvizProcessor.detectGraphvizCLI(); + expect(result).toBe(false); + }); + }); + + describe('canProcess', () => { + it('should detect Graphviz blocks', () => { + const content = ` +# Test Document + +\`\`\`graphviz:simple-flow +digraph G { + A -> B; +} +\`\`\` + +More content here. +`; + + expect(processor.canProcess(content)).toBe(true); + }); + + it('should not detect non-Graphviz blocks', () => { + const content = ` +# Test Document + +\`\`\`mermaid:flow +graph TD + A --> B +\`\`\` + +More content here. +`; + + expect(processor.canProcess(content)).toBe(false); + }); + + it('should detect Graphviz blocks with parameters', () => { + const content = ` +\`\`\`graphviz:complex {layout=neato, theme=dark} +digraph G { + A -> B; +} +\`\`\` +`; + + expect(processor.canProcess(content)).toBe(true); + }); + }); + + describe('detectBlocks', () => { + it('should extract basic Graphviz blocks', () => { + const content = ` +\`\`\`graphviz:simple +digraph G { + A -> B; +} +\`\`\` +`; + + const blocks = processor.detectBlocks(content); + expect(blocks).toHaveLength(1); + expect(blocks[0].name).toBe('simple'); + expect(blocks[0].content.trim()).toBe('digraph G {\n A -> B;\n}'); + }); + + it('should extract blocks with parameters', () => { + const content = ` +\`\`\`graphviz:flowchart {layout=neato, theme=dark, dpi=300} +digraph G { + A -> B; +} +\`\`\` +`; + + const blocks = processor.detectBlocks(content); + expect(blocks).toHaveLength(1); + expect(blocks[0].name).toBe('flowchart'); + expect(blocks[0].metadata?.params).toEqual({ + layout: 'neato', + theme: 'dark', + dpi: '300', + }); + }); + + it('should handle multiple blocks', () => { + const content = ` +\`\`\`graphviz:first +digraph G1 { A -> B; } +\`\`\` + +\`\`\`graphviz:second +digraph G2 { B -> C; } +\`\`\` +`; + + const blocks = processor.detectBlocks(content); + expect(blocks).toHaveLength(2); + expect(blocks[0].name).toBe('first'); + expect(blocks[1].name).toBe('second'); + }); + + it('should handle blocks without parameters', () => { + const content = ` +\`\`\`graphviz:no-params +digraph G { A -> B; } +\`\`\` +`; + + const blocks = processor.detectBlocks(content); + expect(blocks).toHaveLength(1); + expect(blocks[0].metadata?.params).toEqual({}); + }); + }); + + describe('process', () => { + it('should process content with Graphviz blocks when CLI is available', async () => { + // Mock CLI detection + jest.spyOn(GraphvizProcessor, 'detectGraphvizCLI').mockResolvedValue(true); + + // Mock successful diagram generation - need to mock the private method + const generateDiagramSpy = jest + .spyOn( + processor as unknown as { generateDiagram: () => Promise }, + 'generateDiagram', + ) + .mockResolvedValue({ + success: true, + outputPath: '/test/assets/test.png', + }); + + const content = ` +\`\`\`graphviz:test +digraph G { A -> B; } +\`\`\` +`; + + const result = await processor.process(content, context); + + expect(result.success).toBe(true); + expect(result.blocksProcessed).toBe(1); + expect(result.processedContent).toContain('![test]'); + expect(generateDiagramSpy).toHaveBeenCalled(); + }); + + it('should handle missing Graphviz CLI gracefully', async () => { + // Mock CLI detection failure + jest.spyOn(GraphvizProcessor, 'detectGraphvizCLI').mockResolvedValue(false); + + const content = ` +\`\`\`graphviz:test +digraph G { A -> B; } +\`\`\` +`; + + const result = await processor.process(content, context); + + expect(result.success).toBe(true); + expect(result.blocksProcessed).toBe(1); + expect(result.processedContent).toContain( + '', + ); + }); + + it('should return unchanged content when no blocks found', async () => { + const content = '# No Graphviz blocks here'; + + const result = await processor.process(content, context); + + expect(result.success).toBe(true); + expect(result.blocksProcessed).toBe(0); + expect(result.processedContent).toBe(content); + }); + + it('should handle processing errors gracefully', async () => { + // Mock CLI detection success but processing failure + jest.spyOn(GraphvizProcessor, 'detectGraphvizCLI').mockResolvedValue(true); + mockFs.existsSync.mockReturnValue(false); // Simulate file creation failure + + const content = ` +\`\`\`graphviz:test +digraph G { A -> B; } +\`\`\` +`; + + const result = await processor.process(content, context); + + expect(result.success).toBe(true); + expect(result.blocksProcessed).toBe(1); + }); + }); + + describe('parameter parsing', () => { + it('should parse layout engine parameter', () => { + const content = ` +\`\`\`graphviz:test {layout=neato} +digraph G { A -> B; } +\`\`\` +`; + + const blocks = processor.detectBlocks(content); + const params = blocks[0].metadata?.params as Record | undefined; + expect(params?.layout).toBe('neato'); + }); + + it('should parse theme parameter', () => { + const content = ` +\`\`\`graphviz:test {theme=dark} +digraph G { A -> B; } +\`\`\` +`; + + const blocks = processor.detectBlocks(content); + const params = blocks[0].metadata?.params as Record | undefined; + expect(params?.theme).toBe('dark'); + }); + + it('should parse DPI parameter', () => { + const content = ` +\`\`\`graphviz:test {dpi=300} +digraph G { A -> B; } +\`\`\` +`; + + const blocks = processor.detectBlocks(content); + const params = blocks[0].metadata?.params as Record | undefined; + expect(params?.dpi).toBe('300'); + }); + + it('should parse multiple parameters', () => { + const content = ` +\`\`\`graphviz:test {layout=neato, theme=dark, dpi=300} +digraph G { A -> B; } +\`\`\` +`; + + const blocks = processor.detectBlocks(content); + const params = blocks[0].metadata?.params as Record | undefined; + expect(params).toEqual({ + layout: 'neato', + theme: 'dark', + dpi: '300', + }); + }); + + it('should handle parameters with spaces', () => { + const content = ` +\`\`\`graphviz:test { layout = neato , theme = dark } +digraph G { A -> B; } +\`\`\` +`; + + const blocks = processor.detectBlocks(content); + const params = blocks[0].metadata?.params as Record | undefined; + expect(params).toEqual({ + layout: 'neato', + theme: 'dark', + }); + }); + }); + + describe('utility methods', () => { + it('should return supported layout engines', () => { + const engines = processor.getSupportedLayoutEngines(); + expect(engines).toEqual(['dot', 'neato', 'fdp', 'sfdp', 'twopi', 'circo']); + }); + + it('should return supported output formats', () => { + const formats = processor.getSupportedOutputFormats(); + expect(formats).toEqual(['png', 'svg', 'pdf', 'jpeg']); + }); + }); + + describe('theme application', () => { + it('should apply dark theme styling', () => { + const config = { ...defaultConfig, theme: 'dark' as const }; + const processor = new GraphvizProcessor(config); + + const dotContent = 'digraph G { A -> B; }'; + const result = processor['applyTheme'](dotContent, config); + + expect(result).toContain('bgcolor="#2d3748"'); + expect(result).toContain('color="#e2e8f0"'); + expect(result).toContain('fontcolor="#e2e8f0"'); + }); + + it('should apply light theme styling', () => { + const config = { ...defaultConfig, theme: 'light' as const }; + const processor = new GraphvizProcessor(config); + + const dotContent = 'digraph G { A -> B; }'; + const result = processor['applyTheme'](dotContent, config); + + expect(result).toContain('bgcolor="#f7fafc"'); + expect(result).toContain('color="#2d3748"'); + expect(result).toContain('fontcolor="#2d3748"'); + }); + + it('should apply custom background color', () => { + const config = { ...defaultConfig, backgroundColor: '#ff0000' }; + const processor = new GraphvizProcessor(config); + + const dotContent = 'digraph G { A -> B; }'; + const result = processor['applyTheme'](dotContent, config); + + expect(result).toContain('bgcolor="#ff0000"'); + }); + + it('should apply custom font family', () => { + const config = { ...defaultConfig, fontFamily: 'monospace' }; + const processor = new GraphvizProcessor(config); + + const dotContent = 'digraph G { A -> B; }'; + const result = processor['applyTheme'](dotContent, config); + + expect(result).toContain('fontname="monospace"'); + }); + }); + + describe('error handling', () => { + it('should handle invalid DOT syntax gracefully', () => { + // This would normally call the CLI, but we're testing the validation method + // In a real scenario, this would be caught during processing + expect(() => { + // The processor should handle this gracefully during processing + }).not.toThrow(); + }); + + it('should handle missing intermediate directory', async () => { + mockFs.existsSync.mockReturnValue(false); + mockFs.mkdirSync.mockImplementation(() => undefined); + + const content = ` +\`\`\`graphviz:test +digraph G { A -> B; } +\`\`\` +`; + + jest.spyOn(GraphvizProcessor, 'detectGraphvizCLI').mockResolvedValue(true); + + const result = await processor.process(content, context); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/workflows/blog/workflow.yml b/workflows/blog/workflow.yml index 1b440da..2679452 100644 --- a/workflows/blog/workflow.yml +++ b/workflows/blog/workflow.yml @@ -100,6 +100,8 @@ workflow: converter: "pandoc" formats: ["html"] processors: + - name: "emoji" + enabled: true # Enable emoji processing for blog posts - name: "mermaid" enabled: false # Disabled by default for blogs, can be enabled per collection config: diff --git a/workflows/job/workflow.yml b/workflows/job/workflow.yml index 9d64d9d..e81a32a 100644 --- a/workflows/job/workflow.yml +++ b/workflows/job/workflow.yml @@ -101,11 +101,6 @@ workflow: output: "cover_letter_{{user.name_sanitized}}.md" description: "Cover letter for this application" - - name: "notes" - file: "templates/notes/default.md" - output: "{{#prefix}}{{prefix}}_{{/prefix}}notes.md" - description: "Notes template for interviews, meetings, or general notes" - # Define static files (no variable substitution) statics: - name: "resume_reference" @@ -151,6 +146,9 @@ workflow: description: "Convert documents to various formats" converter: "pandoc" formats: ["docx", "html", "pdf"] + processors: + - name: "emoji" + enabled: true # Re-enabled for debugging parameters: - name: "format" type: "enum" diff --git a/workflows/presentation/workflow.yml b/workflows/presentation/workflow.yml index addcd50..794efb9 100644 --- a/workflows/presentation/workflow.yml +++ b/workflows/presentation/workflow.yml @@ -20,25 +20,25 @@ workflow: required: false description: "Template variant (default, technical, layout-optimized, compact, beginner)" help_text: "Choose a template that matches your presentation style and layout needs" - + description: "Create technical presentations with Mermaid diagrams" usage: "{alias} \"Presentation Title\" [template]" help_text: | Create professional technical presentations with auto-generated diagrams. - + Features: - Mermaid diagram generation (flowcharts, sequences, architectures) - Layout-optimized templates (horizontal flows, compact layouts) - Multiple output formats (PPTX, HTML, PDF) - Professional styling with size constraints - Version control friendly markdown source - + The presentation system processes Mermaid blocks with layout hints: ```mermaid:diagram-name {layout=horizontal, width=1000px} flowchart LR A --> B --> C ``` - + examples: - "pres \"System Architecture Overview\"" - "pres \"API Documentation\" layout-optimized" @@ -66,24 +66,19 @@ workflow: templates: - name: "content" file: "templates/content/default.md" - output: "content.md" + output: "{{user.name_sanitized}}_{{title_sanitized}}.md" description: "Main presentation content" - - - name: "slides" - file: "templates/slides/default.md" - output: "slides.md" - description: "Slide-specific formatting template" # Static resources statics: - name: "pptx_template" file: "templates/static/reference.pptx" description: "PowerPoint reference template for styling" - + - name: "html_styles" file: "templates/static/styles.css" description: "CSS styles for HTML output" - + - name: "plantuml_config" file: "templates/static/plantuml-config.txt" description: "PlantUML configuration settings" @@ -114,13 +109,13 @@ workflow: options: ["corporate", "technical", "minimal"] default: "technical" description: "Presentation theme" - + - name: "diagram_format" type: "enum" options: ["png", "svg"] default: "png" description: "Diagram output format" - + - name: "slide_layout" type: "enum" options: ["standard", "widescreen"]