From 3a353b4d4f562a9cdfe2947aebba17e8c492db99 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 29 Oct 2025 23:12:44 +0100 Subject: [PATCH] feat: implement comprehensive asset shipping for md-render command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add automatic asset copying when rendering markdown to different output directories with intelligent defaults and full user control. Key Features: - Environment variable support: MARKITECT_OUTPUT_DIR sets default output directory - Smart defaults: auto-ship assets for directory output, disabled for file output - CLI control flags: --ship-assets and --no-ship-assets for explicit control - Timestamp-based copying: only copies when source newer than destination - Path preservation: maintains relative directory structure in output - Graceful error handling: missing assets logged as warnings, not failures Technical Implementation: - Enhanced asset discovery in markitect/assets/discovery.py with discover_assets_from_markdown() - Added environment variable priority: CLI --output > MARKITECT_OUTPUT_DIR > input directory - Comprehensive asset shipping logic with _ship_assets() function - Directory vs file output detection for intelligent default behavior Examples and Testing: - Added image-assets example directory with 6 sample images and comprehensive README - Created comprehensive TDD test suite with 10 tests covering all functionality - Tests validate environment variables, CLI flags, asset discovery, shipping logic, timestamp handling, missing assets, path preservation, and default behaviors Usage: markitect md-render file.md -o /output/dir/ # Auto-ships assets markitect md-render file.md --no-ship-assets # Suppresses shipping MARKITECT_OUTPUT_DIR=/docs markitect md-render file.md # Uses env var 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- TODO.md | 19 ++ examples/image-assets/README.txt | 16 ++ .../images/architecture_diagram.png | Bin 0 -> 3910 bytes examples/image-assets/images/company_logo.png | Bin 0 -> 2029 bytes .../images/dashboard_screenshot.png | Bin 0 -> 11091 bytes .../image-assets/images/performance_chart.png | Bin 0 -> 2987 bytes examples/image-assets/images/project_icon.png | Bin 0 -> 458 bytes .../image-assets/images/settings_panel.png | Bin 0 -> 8954 bytes .../image-assets/project_documentation.md | 71 ++++++ markitect/assets/discovery.py | 39 +++ .../plugins/builtin/markdown_commands.py | 128 +++++++++- tests/test_md_render_asset_shipping.py | 240 ++++++++++++++++++ 12 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 examples/image-assets/README.txt create mode 100644 examples/image-assets/images/architecture_diagram.png create mode 100644 examples/image-assets/images/company_logo.png create mode 100644 examples/image-assets/images/dashboard_screenshot.png create mode 100644 examples/image-assets/images/performance_chart.png create mode 100644 examples/image-assets/images/project_icon.png create mode 100644 examples/image-assets/images/settings_panel.png create mode 100644 examples/image-assets/project_documentation.md create mode 100644 tests/test_md_render_asset_shipping.py diff --git a/TODO.md b/TODO.md index 5c289421..86152e88 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,25 @@ This section is for tasks currently being discussed with or worked on by the cod ## Completed Tasks +**Asset Shipping for md-render - COMPLETED ✅**: +- ✅ Implemented automatic asset copying when rendering markdown to different output directories +- ✅ Added asset discovery functionality parsing markdown for image/link references +- ✅ Implemented timestamp-based asset copying (only copy if source newer than destination) +- ✅ Added `--ship-assets` and `--no-ship-assets` CLI flags for explicit control +- ✅ Added `MARKITECT_OUTPUT_DIR` environment variable support for default output directory +- ✅ Smart defaults: assets ship automatically when output is directory, disabled for specific files +- ✅ Preserved relative path structure in output directory maintaining markdown link compatibility +- ✅ Graceful handling of missing assets with warning messages +- ✅ Full backward compatibility with existing md-render workflows +- ✅ Comprehensive TDD test suite covering all functionality and edge cases + +**Feature Capabilities**: +- Environment variable priority: CLI `--output` > `MARKITECT_OUTPUT_DIR` > input file directory +- Automatic asset discovery from standard markdown syntax: `![alt](path)` and `[text](path)` +- Timestamp-based incremental copying prevents unnecessary file operations +- Directory structure preservation maintains working relative links in output HTML +- Support for images, documents, and other asset types referenced in markdown + **CHANGELOG.md Enhancement - COMPLETED ✅**: - ✅ Added missing version entries for 0.1.0, 0.2.0, and 0.3.0 - ✅ Added standard Keep a Changelog header with proper format diff --git a/examples/image-assets/README.txt b/examples/image-assets/README.txt new file mode 100644 index 00000000..07592f5d --- /dev/null +++ b/examples/image-assets/README.txt @@ -0,0 +1,16 @@ +Image Asset Management Examples + +This directory contains examples demonstrating MarkiTect's image asset management +capabilities: + +- project_documentation.md: Sample project documentation with embedded images + showing how MarkiTect handles image assets in markdown documents +- images/: Directory containing sample images used in the documentation examples + +These examples showcase: +- Image embedding in markdown documents +- Asset deduplication and content-addressable storage +- Relative path handling for images in MarkiTect projects +- Best practices for organizing image assets in documentation + +--worsch, 25-10-29 \ No newline at end of file diff --git a/examples/image-assets/images/architecture_diagram.png b/examples/image-assets/images/architecture_diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..2a09556a94f405dc2ce3c126069989ee4925b9c9 GIT binary patch literal 3910 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 LNkvXXu0mjf-_qzv literal 0 HcmV?d00001 diff --git a/examples/image-assets/images/dashboard_screenshot.png b/examples/image-assets/images/dashboard_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..49f1bcf09e4a6904c5ef31798374f3688eccde05 GIT binary patch literal 11091 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 Nc+T9o!0^WJ{}0btaIpXY literal 0 HcmV?d00001 diff --git a/examples/image-assets/images/performance_chart.png b/examples/image-assets/images/performance_chart.png new file mode 100644 index 0000000000000000000000000000000000000000..b51a44c8820174219d757f996755f273f5db0405 GIT binary patch literal 2987 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 literal 0 HcmV?d00001 diff --git a/examples/image-assets/project_documentation.md b/examples/image-assets/project_documentation.md new file mode 100644 index 00000000..e9f0e6f2 --- /dev/null +++ b/examples/image-assets/project_documentation.md @@ -0,0 +1,71 @@ +# Project Documentation Example + +## Overview + +This document demonstrates MarkiTect's image asset management capabilities by embedding various types of images commonly used in technical documentation. + +## Architecture Diagram + +The following diagram shows the overall system architecture: + +![System Architecture](images/architecture_diagram.png) + +*Figure 1: High-level system architecture showing component interactions* + +## User Interface Screenshots + +### Dashboard View + +The main dashboard provides an overview of system status: + +![Dashboard Screenshot](images/dashboard_screenshot.png) + +*Figure 2: Main dashboard interface with key metrics and navigation* + +### Settings Panel + +Users can configure system behavior through the settings panel: + +![Settings Panel](images/settings_panel.png) + +*Figure 3: Configuration interface for system preferences* + +## Logo and Branding + +### Company Logo + +![Company Logo](images/company_logo.png) + +### Project Icon + +The project uses this icon throughout the interface: + +![Project Icon](images/project_icon.png) + +## Asset Management Features + +MarkiTect provides several key features for managing image assets: + +1. **Content-Addressable Storage**: Images are stored using SHA-256 hashes to prevent duplication +2. **Automatic Deduplication**: Identical images are only stored once, regardless of filename +3. **Relative Path Resolution**: Images can be referenced using relative paths from the markdown file +4. **Asset Tracking**: All referenced assets are tracked and validated during document processing + +## Performance Metrics + +The following chart shows system performance over time: + +![Performance Chart](images/performance_chart.png) + +*Figure 4: System performance metrics showing response time and throughput* + +## Conclusion + +This example demonstrates how MarkiTect seamlessly handles multiple image assets within a single document, providing: + +- Efficient storage through deduplication +- Reliable asset resolution +- Clean integration with markdown syntax +- Support for various image formats (PNG, JPG, SVG, etc.) + +All images in this document will be processed through MarkiTect's asset management system when the document is rendered or packaged. \ No newline at end of file diff --git a/markitect/assets/discovery.py b/markitect/assets/discovery.py index c36faa99..d403211e 100644 --- a/markitect/assets/discovery.py +++ b/markitect/assets/discovery.py @@ -223,6 +223,45 @@ class MarkdownScanner: return len(lines) +def discover_assets_from_markdown(markdown_content: str, base_path: Path) -> List[AssetReference]: + """ + Simple function to discover assets from markdown content for md-render. + + Args: + markdown_content: The markdown content to scan + base_path: Base path for resolving relative asset paths + + Returns: + List of AssetReference objects found in the markdown + """ + scanner = MarkdownScanner() + + # Create a temporary file to use the existing scan_file method + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as temp_file: + temp_file.write(markdown_content) + temp_path = Path(temp_file.name) + + try: + references = scanner.scan_file(temp_path) + # Update the source_file to the actual base_path for relative resolution + for ref in references: + ref.source_file = base_path + # Resolve the asset path relative to base_path + if not ref.asset_path.startswith(('http:', 'https:', 'mailto:', 'data:')): + # Clean up relative path indicators + clean_path = ref.asset_path.lstrip('./') + resolved_path = base_path / clean_path + if resolved_path.exists(): + ref.resolved_path = resolved_path + else: + ref.is_broken = True + return references + finally: + # Clean up temporary file + temp_path.unlink(missing_ok=True) + + class AssetDiscoveryEngine: """Main engine for asset discovery and analysis.""" diff --git a/markitect/plugins/builtin/markdown_commands.py b/markitect/plugins/builtin/markdown_commands.py index d3785ca6..a9332284 100644 --- a/markitect/plugins/builtin/markdown_commands.py +++ b/markitect/plugins/builtin/markdown_commands.py @@ -1974,9 +1974,14 @@ def md_list_command(ctx, output_format, names_only): help='Don\'t use publication directory for output') @click.option('--nodogtag', is_flag=True, help='Don\'t add HTML generation dogtag at end of document') +@click.option('--ship-assets', is_flag=True, default=None, + help='Copy referenced assets to output directory') +@click.option('--no-ship-assets', is_flag=True, + help='Don\'t copy referenced assets to output directory') @click.pass_context def md_render_command(ctx, input_file, output, theme, css, edit, insert, editor_theme, - keyboard_shortcuts, use_publication_dir, dont_use_publication_dir, nodogtag): + keyboard_shortcuts, use_publication_dir, dont_use_publication_dir, nodogtag, + ship_assets, no_ship_assets): """ Render a markdown file to HTML with basic templates and live preview capabilities. @@ -2008,17 +2013,61 @@ def md_render_command(ctx, input_file, output, theme, css, edit, insert, editor_ if edit and insert: raise click.BadParameter("Cannot use both --edit and --insert flags simultaneously. Choose one mode.") - # Determine output path + # Validate asset shipping flags + if ship_assets and no_ship_assets: + raise click.BadParameter("Cannot use both --ship-assets and --no-ship-assets flags simultaneously.") + + # Determine output path with environment variable support if output: output_path = Path(output) + # If output is a directory, use canonical filename within that directory + if output_path.is_dir() or (not output_path.suffix and not output_path.exists()): + # Ensure the directory exists + output_path.mkdir(parents=True, exist_ok=True) + # Use canonical filename (input name + .html) in the specified directory + canonical_filename = input_path.with_suffix('.html').name + output_path = output_path / canonical_filename + output_is_directory = True + else: + output_is_directory = False else: - output_path = input_path.with_suffix('.html') + # Check for environment variable + import os + env_output_dir = os.environ.get('MARKITECT_OUTPUT_DIR') + if env_output_dir: + output_path = Path(env_output_dir) + output_path.mkdir(parents=True, exist_ok=True) + canonical_filename = input_path.with_suffix('.html').name + output_path = output_path / canonical_filename + output_is_directory = True + else: + output_path = input_path.with_suffix('.html') + output_is_directory = False # Use publication directory if specified if use_publication_dir and not dont_use_publication_dir: pub_dir = get_publication_directory() ensure_publication_directory(pub_dir) output_path = pub_dir / get_output_filename(input_path) + output_is_directory = True # Publication dir is always a directory output + + # Determine if we should ship assets + should_ship_assets = False + if no_ship_assets: + should_ship_assets = False + elif ship_assets: + should_ship_assets = True + elif output_is_directory: + # Default: ship assets when output is a directory + should_ship_assets = True + + + # Discover and ship assets if needed + if should_ship_assets: + if output_is_directory: + # For directory output, ship to the same directory as the HTML file + _ship_assets(input_path, output_path.parent, config.get('verbose', False)) + # For file output, we don't ship assets (shouldn't reach here anyway) # Initialize clean document manager from markitect.clean_document_manager import CleanDocumentManager @@ -3433,3 +3482,76 @@ class FilenameDecoder: return [self.decode(filename) for filename in filenames] +def _ship_assets(input_path: Path, output_dir: Path, verbose: bool = False): + """ + Ship (copy) assets referenced in markdown file to output directory. + + Args: + input_path: Path to the markdown file + output_dir: Directory where assets should be copied + verbose: Whether to print verbose output + """ + import shutil + from markitect.assets.discovery import discover_assets_from_markdown + + try: + # Read the markdown content + markdown_content = input_path.read_text(encoding='utf-8') + + # Discover assets + base_path = input_path.parent + assets = discover_assets_from_markdown(markdown_content, base_path) + + shipped_count = 0 + skipped_count = 0 + missing_count = 0 + + for asset_ref in assets: + # Skip URLs and broken assets + if asset_ref.asset_path.startswith(('http:', 'https:', 'mailto:', 'data:')): + continue + + if asset_ref.is_broken or not asset_ref.resolved_path: + missing_count += 1 + if verbose: + click.echo(f" ⚠ Missing asset: {asset_ref.asset_path}", err=True) + continue + + # Determine output path (preserve relative directory structure) + clean_path = asset_ref.asset_path.lstrip('./') + dest_path = output_dir / clean_path + + # Create destination directory + dest_path.parent.mkdir(parents=True, exist_ok=True) + + # Check if we need to copy (timestamp-based) + should_copy = True + if dest_path.exists(): + source_mtime = asset_ref.resolved_path.stat().st_mtime + dest_mtime = dest_path.stat().st_mtime + if source_mtime <= dest_mtime: + should_copy = False + skipped_count += 1 + + if should_copy: + shutil.copy2(asset_ref.resolved_path, dest_path) + shipped_count += 1 + if verbose: + click.echo(f" ✓ Copied: {asset_ref.asset_path}") + elif verbose: + click.echo(f" → Skipped (up-to-date): {asset_ref.asset_path}") + + # Summary + if verbose or shipped_count > 0: + if shipped_count > 0: + click.echo(f"✓ Shipped {shipped_count} assets") + if skipped_count > 0: + click.echo(f" → Skipped {skipped_count} up-to-date assets") + if missing_count > 0: + click.echo(f" ⚠ {missing_count} assets not found", err=True) + + except Exception as e: + if verbose: + click.echo(f"Error shipping assets: {e}", err=True) + + diff --git a/tests/test_md_render_asset_shipping.py b/tests/test_md_render_asset_shipping.py new file mode 100644 index 00000000..e1c260ff --- /dev/null +++ b/tests/test_md_render_asset_shipping.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +TDD tests for asset shipping in md-render command. + +Tests the automatic copying of referenced assets when rendering markdown +to different output directories. +""" + +import os +import tempfile +import pytest +from pathlib import Path +from unittest.mock import patch + +from markitect.plugins.builtin.markdown_commands import md_render_command +from click.testing import CliRunner + + +class TestAssetShippingMdRender: + """Test asset shipping functionality in md-render.""" + + def setup_method(self): + """Set up test environment.""" + self.runner = CliRunner() + self.temp_dir = tempfile.mkdtemp() + self.test_dir = Path(self.temp_dir) + + # Create test markdown with image references + self.markdown_content = """# Test Document + +## Images + +![Architecture](images/arch.png) +![Logo](assets/logo.jpg) +![Diagram](./diagrams/flow.svg) + +## Links + +[Documentation](docs/readme.md) +""" + + # Create test file structure + self.md_file = self.test_dir / "test.md" + self.md_file.write_text(self.markdown_content) + + # Create asset directories and files + (self.test_dir / "images").mkdir() + (self.test_dir / "assets").mkdir() + (self.test_dir / "diagrams").mkdir() + (self.test_dir / "docs").mkdir() + + # Create sample asset files + (self.test_dir / "images" / "arch.png").write_bytes(b"fake png data") + (self.test_dir / "assets" / "logo.jpg").write_bytes(b"fake jpg data") + (self.test_dir / "diagrams" / "flow.svg").write_text("fake svg") + (self.test_dir / "docs" / "readme.md").write_text("# README") + + def teardown_method(self): + """Clean up test environment.""" + import shutil + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_environment_variable_output_directory(self): + """Test that MARKITECT_OUTPUT_DIR is used when no --output is specified.""" + output_dir = self.test_dir / "env_output" + output_dir.mkdir() + + with patch.dict(os.environ, {'MARKITECT_OUTPUT_DIR': str(output_dir)}): + result = self.runner.invoke(md_render_command, [str(self.md_file)]) + + assert result.exit_code == 0 + assert (output_dir / "test.html").exists() + + def test_cli_output_overrides_environment_variable(self): + """Test that CLI --output parameter overrides environment variable.""" + env_output = self.test_dir / "env_output" + cli_output = self.test_dir / "cli_output" + env_output.mkdir() + cli_output.mkdir() + + with patch.dict(os.environ, {'MARKITECT_OUTPUT_DIR': str(env_output)}): + result = self.runner.invoke(md_render_command, [ + str(self.md_file), + '--output', str(cli_output) + ]) + + assert result.exit_code == 0 + assert (cli_output / "test.html").exists() + assert not (env_output / "test.html").exists() + + def test_asset_shipping_enabled_by_default_for_directory_output(self): + """Test that assets are shipped automatically when output is a directory.""" + output_dir = self.test_dir / "output" + output_dir.mkdir() + + result = self.runner.invoke(md_render_command, [ + str(self.md_file), + '--output', str(output_dir) + ]) + + assert result.exit_code == 0 + assert (output_dir / "test.html").exists() + + # Check that assets were copied + assert (output_dir / "images" / "arch.png").exists() + assert (output_dir / "assets" / "logo.jpg").exists() + assert (output_dir / "diagrams" / "flow.svg").exists() + assert (output_dir / "docs" / "readme.md").exists() + + def test_no_ship_assets_flag_suppresses_asset_copying(self): + """Test that --no-ship-assets flag prevents asset copying.""" + output_dir = self.test_dir / "output" + output_dir.mkdir() + + result = self.runner.invoke(md_render_command, [ + str(self.md_file), + '--output', str(output_dir), + '--no-ship-assets' + ]) + + assert result.exit_code == 0 + assert (output_dir / "test.html").exists() + + # Check that assets were NOT copied + assert not (output_dir / "images").exists() + assert not (output_dir / "assets").exists() + assert not (output_dir / "diagrams").exists() + + def test_timestamp_based_asset_copying(self): + """Test that assets are only copied if source is newer than destination.""" + output_dir = self.test_dir / "output" + output_dir.mkdir() + + # First render - assets should be copied + result = self.runner.invoke(md_render_command, [ + str(self.md_file), + '--output', str(output_dir) + ]) + assert result.exit_code == 0 + + # Mark output asset as newer + output_asset = output_dir / "images" / "arch.png" + original_mtime = output_asset.stat().st_mtime + output_asset.touch() # Update timestamp + + # Second render - asset should not be overwritten + result = self.runner.invoke(md_render_command, [ + str(self.md_file), + '--output', str(output_dir) + ]) + assert result.exit_code == 0 + + # Check that the timestamp wasn't changed (asset wasn't overwritten) + assert output_asset.stat().st_mtime > original_mtime + + def test_ship_assets_flag_explicit_enable(self): + """Test that --ship-assets flag explicitly enables asset shipping.""" + output_dir = self.test_dir / "output" + output_dir.mkdir() + + result = self.runner.invoke(md_render_command, [ + str(self.md_file), + '--output', str(output_dir), + '--ship-assets' + ]) + + assert result.exit_code == 0 + assert (output_dir / "test.html").exists() + assert (output_dir / "images" / "arch.png").exists() + + def test_missing_assets_handled_gracefully(self): + """Test that missing assets are handled with warnings, not errors.""" + # Remove one of the assets + (self.test_dir / "images" / "arch.png").unlink() + + output_dir = self.test_dir / "output" + output_dir.mkdir() + + result = self.runner.invoke(md_render_command, [ + str(self.md_file), + '--output', str(output_dir) + ]) + + # Should succeed despite missing asset + assert result.exit_code == 0 + assert (output_dir / "test.html").exists() + + # Other assets should still be copied + assert (output_dir / "assets" / "logo.jpg").exists() + + def test_asset_discovery_from_markdown_content(self): + """Test discovery of assets from markdown content.""" + from markitect.assets.discovery import discover_assets_from_markdown + + assets = discover_assets_from_markdown(self.markdown_content, self.test_dir) + + # Should find all asset references + asset_paths = [asset.asset_path for asset in assets] + assert "images/arch.png" in asset_paths + assert "assets/logo.jpg" in asset_paths + assert "./diagrams/flow.svg" in asset_paths + assert "docs/readme.md" in asset_paths + + def test_relative_path_preservation(self): + """Test that relative path structure is preserved in output.""" + output_dir = self.test_dir / "output" + output_dir.mkdir() + + result = self.runner.invoke(md_render_command, [ + str(self.md_file), + '--output', str(output_dir) + ]) + + assert result.exit_code == 0 + + # Check that directory structure is preserved + assert (output_dir / "images" / "arch.png").exists() + assert (output_dir / "assets" / "logo.jpg").exists() + assert (output_dir / "diagrams" / "flow.svg").exists() + assert (output_dir / "docs" / "readme.md").exists() + + def test_asset_shipping_disabled_for_file_output(self): + """Test that asset shipping is disabled when output is a specific file.""" + # Create a separate output directory + output_dir = self.test_dir / "output_dir" + output_dir.mkdir() + output_file = output_dir / "specific_output.html" + + result = self.runner.invoke(md_render_command, [ + str(self.md_file), + '--output', str(output_file) + ]) + + assert result.exit_code == 0 + assert output_file.exists() + + # Assets should NOT be copied when output is a specific file + # (they should not exist in the output directory) + assert not (output_dir / "images").exists() + assert not (output_dir / "assets").exists() \ No newline at end of file