5 Commits

Author SHA1 Message Date
3a353b4d4f feat: implement comprehensive asset shipping for md-render command
Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
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 <noreply@anthropic.com>
2025-10-29 23:12:44 +01:00
ed33766c91 refactor: reorganize examples directory with topic-based subdirectories
Reorganize examples directory into logical topic-based subdirectories with
comprehensive documentation:

- templates/: ISO/ARC42 documentation templates
- asset-management/: Asset management prototypes and demos
- essays/: Long-form content examples
- invoicing/: Invoice generation examples
- plugins/: Plugin development examples
- issue-demos/: Issue prevention demonstrations
- design-patterns/: Design pattern examples

Each subdirectory includes a README.txt file with topic description and
contributor signatures based on file creation timestamps.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 22:31:52 +01:00
9f4e296dd3 chore: clean up TODO.md by removing completed theme system refactor tasks
Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
Remove outdated theme system refactor content from TODO.md since the layered
theme architecture work was already completed and released in v0.6.0. The
todofile is now clean and ready for new active development tasks.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 22:25:58 +01:00
c7a83070f8 feat: implement insert mode with heading protection and fix content display bugs
Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
This commit implements a comprehensive insert mode that preserves document structure
by protecting heading levels 1-3 from modification while allowing full content editing.

## Insert Mode Features
- CLI integration with --insert flag for md-render command
- Protected heading display (read-only) for levels 1-3
- Content-only editing for sections with protected headings
- Full editing capability for heading levels 4-6
- Theme-aware CSS styling for all UI themes
- Modal confirmation dialogs with proper positioning
- Section splitting with automatic protection inheritance
- Validation to prevent protected heading modifications

## Implementation Details
- Added MARKITECT_INSERT_MODE JavaScript flag and configuration
- Enhanced Section class with heading level detection and protection methods
- Added getHeadingText() and getHeadingContent() methods for content separation
- Implemented insert mode UI with protected heading display above content editor
- Added comprehensive CSS styling for insert mode components and modals
- Updated CLI with --insert option and mutual exclusion with --edit

## Bug Fixes
- Fixed JavaScript syntax errors caused by unescaped newline characters in string literals
- Corrected split('\n') and join('\n') calls to use proper escaping for Python string context
- Fixed heading level 3 display showing "null" by improving regex pattern matching
- Resolved content not displaying in edit/insert modes due to JavaScript parsing failures

## Documentation
- Updated UserInterfaceFramework.md with complete Insert Mode Editor section
- Added behavioral comparison table between edit and insert modes
- Updated Component Integration Matrix to reflect new capabilities

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 23:55:21 +01:00
dd3a00040a feat: implement scroll indicators with disabled state styling
- Add document viewport scroll indicators with triangular arrows
- Implement disabled state styling (grey background, cursor: not-allowed)
- Add smooth scrolling with easing functions for indicator clicks
- Include hover detection at top/bottom of viewport for indicator display
- Fix CSS syntax error in scroll indicator styles
- Add theme-aware styling for all UI themes (standard, greyscale, electric, psychedelic)
- Extend confirmation dialog with theme-consistent danger and secondary button properties
- Update UserInterfaceFramework.md to mark confirmation dialog as completed

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 22:36:15 +01:00
53 changed files with 1556 additions and 96 deletions

68
TODO.md
View File

@@ -13,30 +13,7 @@ The structure organizes **future tasks** by their impact, just as a changelog or
This section is for tasks currently being discussed with or worked on by the coding assistant. These are the ephemeral, flow-of-thought tasks.
* **To Add:**
* **Complete Theme System Refactor - Layered Theme Architecture**: Major refactor to replace simple template selection with sophisticated layered theme system (currently stashed)
* **Phase 1 - Restore and Assess**:
* Restore stashed changes with `git stash pop`
* Run tests to identify current failures and validation issues
* Assess remaining work by checking all files that still use `--template`
* **Phase 2 - Complete CLI Parameter Migration**:
* Update remaining CLI commands in asset_commands.py, cli.py, and other files
* Fix parameter validation - add proper theme validation for the new string-based parameter
* Update help text and documentation to reflect new layered theme capabilities
* **Phase 3 - Fix Integration Issues**:
* Fix function signature mismatches where functions expect `template` but receive `theme`
* Add proper error handling for invalid themes (replace print statements with logging)
* Test layered theme functionality - ensure `dark,academic` type combinations work
* Verify legacy theme mapping works correctly
* **Phase 4 - Quality Assurance**:
* Run full test suite to ensure no regressions
* Test all CLI commands with new theme parameter
* Verify backward compatibility with existing templates
* Update any remaining documentation
* **Phase 5 - Clean Up and Commit**:
* Remove dead code and legacy functions if no longer needed
* Ensure consistent terminology throughout codebase
* Write comprehensive commit message documenting the major theme system improvement
* Update CHANGELOG.md with new theme layering capabilities
* None currently identified
* **To Fix:**
* None currently identified
@@ -47,35 +24,30 @@ This section is for tasks currently being discussed with or worked on by the cod
* **To Remove:**
* None currently identified
***
## Theme System Refactor Context
**Current State**: Work-in-progress theme system refactor is stashed and partially complete.
**Completed Parts ✅**:
- New Layered Theme Architecture: Complete LAYERED_THEMES system with UI, document, and branding scopes
- Theme Parsing Functions: `parse_theme_string()` and `combine_theme_properties()`
- CSS Generation Refactor: New `_get_template_css()` and `_generate_layered_css()` methods
- CLI Parameter Change: Changed from `--template` to `--theme` throughout test files
- Legacy Compatibility: LEGACY_THEME_MAPPING for backward compatibility
**Missing/Incomplete Parts ❌**:
- CLI Parameter Validation: The new `--theme` parameter needs validation for invalid themes
- Function Signature Inconsistencies: Some functions still accept `template` parameter but call it with `theme`
- Additional Files: Other files in the codebase still use old `template` parameter
- Error Handling: The warning system for unknown themes needs proper logging
**New Capabilities When Complete**:
- Single themes: `basic`, `github`, `dark`, `academic`, `light`, `corporate`, `startup`
- Layered themes: `dark,academic` combines dark UI with academic typography
- Complex combinations: `light,github,corporate` for branded GitHub-style documents
- Legacy compatibility: Existing `--template` usage continues to work
***
## 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

View File

@@ -208,23 +208,133 @@ Provides detailed information about the current editing session, including versi
### Description
Provides user confirmation for potentially destructive operations that cannot be easily undone.
### Current Implementation
- **Method**: Browser native `confirm()` (temporary solution)
### Current Implementation ✅ COMPLETED
- **Method**: Custom theme-aware modal dialog
- **Trigger**: "🔄 Reset All" button in floating action panel
- **Message**: "Reset all content to original markdown? This will lose all edits and remove split sections."
- **Message**: "Reset all content to original markdown?"
- **Warning**: "This will permanently lose all edits and remove any split sections. This action cannot be undone."
### Features Implemented
- **Theme-Aware Styling**: Adapts to all UI themes (standard, greyscale, electric, psychedelic)
- **Clear Action Buttons**:
- Primary action: "Reset Document" (red danger button)
- Secondary action: "Keep Changes" (grey cancel button)
- **Enhanced UX**:
- Detailed consequence explanation with warning styling
- Professional modal overlay with smooth animations
- Proper focus management and accessibility
- **Keyboard Support**:
- ESC key to cancel
- Enter key to confirm
- Tab navigation between buttons
### Use Cases
- **Reset All Sections**: Complete document reset to original state
- **Future**: Delete operations, bulk changes, file operations
- **Future**: Extensible for delete operations, bulk changes, file operations
### Future Enhancement Plan
**Target**: Replace browser confirm with custom modal dialog
- **Styling**: Theme-aware modal with clear action buttons
- **Features**:
- Clear primary/secondary action buttons
- Detailed consequence explanation
- Optional "Don't ask again" for non-critical confirmations
- **Accessibility**: Proper focus management, keyboard support
### Technical Implementation
**CSS Classes**:
- `.ui-edit-confirmation-modal` - Modal container
- `.ui-edit-confirmation-content` - Main message
- `.ui-edit-confirmation-warning` - Warning section
- `.ui-edit-confirmation-buttons` - Button container
- `.ui-edit-button-confirm` - Danger action button
- `.ui-edit-button-cancel` - Cancel action button
**JavaScript Method**: `showConfirmation(message, confirmText, cancelText, warningText)`
- Returns Promise<boolean> for async/await support
- Theme-consistent styling via layered theme system
- Proper event cleanup and accessibility features
---
## 7. Insert Mode Editor
**Component Name**: `Insert Mode Editor`
**Type**: Structured editing mode with heading protection
**Location**: Replaces section content during editing (contextual)
### Description ✅ COMPLETED
A specialized editing mode that duplicates edit mode functionality while enforcing document structure integrity. Provides content editing with selective heading protection for levels 1-3, maintaining document outline consistency.
### Current Implementation ✅ COMPLETED
- **CLI Activation**: `markitect md-render document.md --insert`
- **Mode Detection**: Uses `MARKITECT_INSERT_MODE` JavaScript flag
- **Heading Protection**: Levels 1-3 are read-only, displayed above content editor
- **Content Editing**: Full editing capability for content following protected headings
### Features Implemented
- **Structured Editing Interface**:
- Protected heading display (read-only) for levels 1-3
- Content-only textarea for body text editing
- Level 4+ headings remain fully editable
- **Heading Protection Logic**:
- Visual distinction with warning-styled heading display
- Prevents modification of heading text in protected sections
- Server-side validation ensures heading integrity
- **Section Management**:
- Automatic section splitting on new heading introduction
- New heading sections inherit protection based on level
- Maintains document structure during complex edits
- **Theme Integration**:
- Adapts to all UI themes (standard, greyscale, electric, psychedelic)
- Consistent styling with edit mode components
- Special styling for protected heading display
### Use Cases
- **Document Structure Preservation**: Maintain established outline while allowing content updates
- **Collaborative Editing**: Prevent accidental heading modifications in shared documents
- **Template-Based Content**: Edit content within predefined structural frameworks
- **Controlled Authoring**: Allow content contributions without structural changes
### Technical Implementation
**CLI Integration**:
- `--insert` flag added to `md-render` command
- Mutually exclusive with `--edit` flag
- Validation prevents simultaneous mode activation
**CSS Classes**:
- `.markitect-insert-mode` - Body class for insert mode
- `.ui-insert-protected-panel` - Container for protected heading sections
- `.ui-insert-heading-display` - Read-only heading display component
- `.ui-insert-content-editor` - Content-only editing textarea
**JavaScript Configuration**:
```javascript
const MARKITECT_INSERT_MODE = true;
const MARKITECT_EDITOR_CONFIG = {
mode: 'insert',
restrictedHeadingLevels: [1, 2, 3],
// ... standard editor config
};
```
**Section Enhancement**:
- `Section.detectHeadingLevel()` - Identify heading levels 1-6
- `Section.isProtectedHeading()` - Check if heading is protected in current mode
- `Section.getHeadingText()` - Extract heading text for display
- `Section.getHeadingContent()` - Extract content after heading for editing
**Validation Logic**:
- Pre-acceptance validation ensures protected headings remain unchanged
- Error handling for attempted heading modifications
- Content reconstruction maintains heading + content structure
### Behavioral Differences from Edit Mode
| Feature | Edit Mode | Insert Mode |
|---------|-----------|-------------|
| Heading Levels 1-3 | ✏️ Fully Editable | 🔒 Read-Only Display |
| Heading Levels 4-6 | ✏️ Fully Editable | ✏️ Fully Editable |
| Content Editing | ✏️ Full Section | ✏️ Content Only (for protected) |
| Section Splitting | ✅ All Headings | ✅ All Headings |
| New Heading Creation | ✅ Unlimited | ✅ With Level-Based Protection |
| Theme Support | ✅ All Themes | ✅ All Themes |
### Future Enhancements
- **Configurable Protection Levels**: Allow customization of which heading levels are protected
- **Conditional Protection**: Enable/disable protection based on section content or metadata
- **Protection Indicators**: Visual badges showing protection status in section list
- **Bulk Mode Switching**: Convert between edit and insert modes for existing documents
---
@@ -328,8 +438,9 @@ All components must adapt to the selected UI theme:
| Toast System | ❌ No | ✅ Yes | ❌ N/A | ✅ Yes | ⚠️ Basic |
| Document Canvas | ✅ Yes | ✅ Yes | ⚠️ Partial | ✅ Yes | ✅ Yes |
| Section Editor | ✅ Yes | ⚠️ Partial | ⚠️ Basic | ⚠️ Basic | ⚠️ Partial |
| Insert Mode Editor | ✅ Yes | ⚠️ Partial | ⚠️ Basic | ⚠️ Basic | ⚠️ Partial |
| Status Modal | ❌ No | ❌ No | ❌ No | ❌ No | ❌ No |
| Confirmation | ❌ No | ❌ No | ❌ No | ❌ No | ❌ No |
| Confirmation | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
**Legend**: ✅ Full Support | ⚠️ Partial/Needs Work | ❌ Not Implemented

View File

@@ -0,0 +1,14 @@
Asset Management Examples
This directory contains prototype implementations and demonstrations for asset management
concepts developed for Issue #141:
- asset_management_concept_a.py: Hash-based content-addressable storage approach
- asset_management_concept_b.py: Alternative asset management implementation
- demo_hash_store/: Working demonstration of hash-based asset storage with metadata
- demo_workspace/: Example workspace showing asset management in practice
These examples showcase different approaches to asset deduplication, storage, and
management within the MarkiTect ecosystem.
--worsch, 25-10-08

View File

@@ -0,0 +1,11 @@
Design Pattern Examples
This directory contains examples of software design patterns and architectural concepts:
- design_pattern.md: Documentation and examples of common design patterns used
in software development, with practical implementations and use cases
These examples provide educational material for understanding and implementing
design patterns in real-world projects.
--worsch, 25-10-03

View File

@@ -0,0 +1,12 @@
Essays and Long-form Content
This directory contains essay examples and long-form content demonstrations:
- BildungsKanonJon.md: "200 Jahre Bildung" - A philosophical essay exploring 200 years
of education from the perspective of world spirit to self-consciousness
- BildungsKanonJon.html: Rendered HTML version of the essay
These examples demonstrate MarkiTect's capability to handle complex, narrative content
with rich formatting and philosophical depth.
--worsch, 25-10-08

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -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.

View File

@@ -0,0 +1,11 @@
Invoicing System Examples
This directory contains examples for invoice generation and template systems:
- invoice_template.md: Markdown template for invoice generation
- invoice_data.json: Sample invoice data in JSON format for template population
These examples demonstrate how MarkiTect can be used for business document generation
with data-driven template systems.
--worsch, 25-10-03

View File

@@ -0,0 +1,11 @@
Issue Prevention Demonstrations
This directory contains examples demonstrating issue prevention and resolution:
- issue_59_prevention_demo.py: Demonstration script for preventing issues related
to Issue #59, showing best practices and defensive programming techniques
These examples serve as educational material for avoiding common pitfalls and
implementing robust solutions.
--worsch, 25-10-03

View File

@@ -0,0 +1,11 @@
Plugin Development Examples
This directory contains example plugin implementations for MarkiTect:
- example_processor.py: Example of a content processor plugin
- example_formatter.py: Example of a content formatter plugin
These examples show how to extend MarkiTect's functionality through the plugin
architecture, providing templates for custom processing and formatting plugins.
--worsch, 25-10-03

View File

@@ -0,0 +1,13 @@
Templates Collection
This directory contains various document templates for different standards and frameworks:
- TEMPLATE-ARC42.md: Software architecture documentation template following the arc42 standard
- TEMPLATE-ISO14001.md: Environmental management system template based on ISO 14001
- TEMPLATE-ISO27001-ISMS.md: Information security management system template for ISO 27001
- TEMPLATE-ISO9001.md: Quality management system template following ISO 9001
These templates provide structured starting points for creating compliant documentation
in their respective domains.
--worsch, 25-10-03

View File

@@ -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."""

View File

@@ -56,7 +56,7 @@ class CleanDocumentManager:
}
def render_file(self, input_file: str, output_file: str, template: str = None, css: str = None,
edit_mode: bool = False, editor_theme: str = 'github', keyboard_shortcuts: bool = True, nodogtag: bool = False) -> Dict[str, Any]:
edit_mode: bool = False, insert_mode: bool = False, editor_theme: str = 'github', keyboard_shortcuts: bool = True, nodogtag: bool = False) -> Dict[str, Any]:
"""
Render a markdown file to HTML with optional clean editing capabilities.
"""
@@ -85,6 +85,7 @@ class CleanDocumentManager:
css=css,
template=template,
edit_mode=edit_mode,
insert_mode=insert_mode,
editor_theme=editor_theme,
keyboard_shortcuts=keyboard_shortcuts,
original_filename=original_filename,
@@ -481,8 +482,375 @@ class CleanDocumentManager:
border-top: 1px solid {props.get('editor_panel_border', '#dee2e6')};
text-align: right;
}}
outline: none;
}}"""
/* Confirmation Dialog Styles */
.markitect-edit-mode .ui-edit-confirmation-modal {{
max-width: 500px;
}}
.markitect-edit-mode .ui-edit-confirmation-content {{
font-size: 16px;
line-height: 1.5;
margin-bottom: 24px;
color: {props.get('editor_text_color', '#212529')};
}}
.markitect-edit-mode .ui-edit-confirmation-warning {{
background: {props.get('editor_warning_bg', '#fff3cd')};
border: 1px solid {props.get('editor_warning_border', '#ffeaa7')};
color: {props.get('editor_warning_text', '#856404')};
padding: 12px 16px;
border-radius: 6px;
margin: 16px 0;
font-size: 14px;
}}
.markitect-edit-mode .ui-edit-confirmation-buttons {{
display: flex;
gap: 12px;
justify-content: flex-end;
}}
.markitect-edit-mode .ui-edit-button-confirm {{
background: {props.get('editor_danger_button', '#dc3545')};
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}}
.markitect-edit-mode .ui-edit-button-confirm:hover {{
background: {props.get('editor_danger_button_hover', '#c82333')};
transform: translateY(-1px);
}}
.markitect-edit-mode .ui-edit-button-confirm:active {{
transform: translateY(0);
}}
.markitect-edit-mode .ui-edit-button-confirm:focus {{
outline: 2px solid {props.get('editor_focus_color', '#007bff')};
outline-offset: 2px;
}}
.markitect-edit-mode .ui-edit-button-cancel {{
background: {props.get('editor_secondary_button', '#6c757d')};
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}}
.markitect-edit-mode .ui-edit-button-cancel:hover {{
background: {props.get('editor_secondary_button_hover', '#545b62')};
transform: translateY(-1px);
}}
.markitect-edit-mode .ui-edit-button-cancel:active {{
transform: translateY(0);
}}
.markitect-edit-mode .ui-edit-button-cancel:focus {{
outline: 2px solid {props.get('editor_focus_color', '#007bff')};
outline-offset: 2px;
}}
/* Document Scroll Indicators */
.ui-scroll-indicator {{
position: fixed;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 30px;
background: {props.get('editor_panel_bg', '#f8f9fa')};
border: 1px solid {props.get('editor_panel_border', '#dee2e6')};
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.2s ease, background-color 0.2s ease;
z-index: 1000;
box-shadow: 0 4px 12px {props.get('editor_shadow', 'rgba(0,0,0,0.15)')};
}}
.ui-scroll-indicator:hover {{
transform: translateX(-50%) scale(1.05);
}}
.ui-scroll-indicator:not(.disabled):hover {{
background: {props.get('editor_button_hover', '#e9ecef')};
}}
.ui-scroll-indicator.active {{
opacity: 0.9;
visibility: visible;
}}
.ui-scroll-indicator.disabled {{
background: {props.get('editor_button_active', '#dee2e6')};
cursor: not-allowed;
opacity: 0.6;
}}
.ui-scroll-indicator.disabled:hover {{
transform: translateX(-50%);
background: {props.get('editor_button_active', '#dee2e6')};
}}
.ui-scroll-indicator-up {{
top: 20px;
}}
.ui-scroll-indicator-down {{
bottom: 20px;
}}
.ui-scroll-indicator::before {{
content: '';
width: 0;
height: 0;
border-style: solid;
transition: border-color 0.2s ease;
}}
.ui-scroll-indicator-up::before {{
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 12px solid {props.get('editor_text_color', '#212529')};
}}
.ui-scroll-indicator-down::before {{
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 12px solid {props.get('editor_text_color', '#212529')};
}}
.ui-scroll-indicator.disabled.ui-scroll-indicator-up::before {{
border-bottom-color: {props.get('editor_secondary_button', '#6c757d')};
}}
.ui-scroll-indicator.disabled.ui-scroll-indicator-down::before {{
border-top-color: {props.get('editor_secondary_button', '#6c757d')};
}}
/* Insert Mode Specific Styles */
.markitect-insert-mode .ui-edit-floater-panel {{
background: {props['editor_panel_bg']};
border: 1px solid {props.get('editor_panel_border', '#dee2e6')};
box-shadow: 0 4px 12px {props.get('editor_shadow', 'rgba(0,0,0,0.1)')};
color: {props.get('editor_text_color', '#212529')};
}}
.markitect-insert-mode .ui-edit-floater-header {{
color: {props.get('editor_text_color', '#212529')};
}}
.markitect-insert-mode .ui-edit-floater-header h3 {{
color: {props.get('editor_text_color', '#212529')};
}}
.markitect-insert-mode .ui-edit-inline-panel {{
background: {props['editor_panel_bg']};
border: 1px solid {props.get('editor_panel_border', '#dee2e6')};
box-shadow: 0 2px 8px {props.get('editor_shadow', 'rgba(0,0,0,0.1)')};
color: {props.get('editor_text_color', '#212529')};
border-radius: 8px;
padding: 12px;
margin: 8px 0;
}}
.markitect-insert-mode .ui-insert-protected-panel {{
border-left: 4px solid #ff9800;
}}
.markitect-insert-mode .ui-insert-heading-display {{
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 14px;
font-weight: bold;
padding: 8px 12px;
background: {props.get('editor_warning_bg', '#fff3cd')};
border: 1px solid {props.get('editor_warning_border', '#ffeaa7')};
border-radius: 4px;
border-left: 4px solid #007bff;
color: {props.get('editor_warning_text', '#856404')};
margin-bottom: 8px;
}}
.markitect-insert-mode .ui-insert-content-editor {{
border: 1px solid {props.get('editor_panel_border', '#dee2e6')};
color: {props.get('editor_text_color', '#212529')};
background: {props.get('editor_button_bg', '#ffffff')};
}}
.markitect-insert-mode .ui-insert-content-editor:focus {{
border-color: {props.get('editor_focus_color', '#007bff')};
box-shadow: 0 0 0 2px {props.get('editor_focus_color', '#007bff')}33;
}}
.markitect-insert-mode .ui-edit-button {{
background: {props.get('editor_button_bg', '#ffffff')};
color: {props.get('editor_text_color', '#212529')};
border: 1px solid {props.get('editor_panel_border', '#dee2e6')};
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
min-width: 70px;
font-weight: 500;
transition: all 0.2s;
}}
.markitect-insert-mode .ui-edit-button:hover {{
background: {props.get('editor_button_hover', '#e9ecef')};
}}
.markitect-insert-mode .ui-edit-button:active,
.markitect-insert-mode .ui-edit-button.active {{
background: {props.get('editor_button_active', '#dee2e6')};
}}
.markitect-insert-mode .ui-edit-button-accept {{
background: #4caf50;
color: white;
}}
.markitect-insert-mode .ui-edit-button-accept:hover {{
background: #388e3c;
}}
.markitect-insert-mode .ui-edit-button-cancel {{
background: #f44336;
color: white;
}}
.markitect-insert-mode .ui-edit-button-cancel:hover {{
background: #d32f2f;
}}
.markitect-insert-mode .ui-edit-button-reset {{
background: #ff9800;
color: white;
}}
.markitect-insert-mode .ui-edit-button-reset:hover {{
background: #f57c00;
}}
.markitect-insert-mode .ui-edit-section-frame {{
border: 2px solid {props.get('editor_focus_color', '#007bff')};
box-shadow: 0 0 0 3px {props.get('editor_focus_color', '#007bff')}33;
}}
/* Modal Overlay and Dialog Styles for Insert Mode */
.markitect-insert-mode .ui-edit-modal-overlay {{
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
}}
.markitect-insert-mode .ui-edit-modal-overlay.active {{
opacity: 1;
visibility: visible;
}}
.markitect-insert-mode .ui-edit-modal {{
background: {props['editor_panel_bg']};
border: 1px solid {props.get('editor_panel_border', '#dee2e6')};
box-shadow: 0 8px 32px {props.get('editor_shadow', 'rgba(0,0,0,0.2)')};
color: {props.get('editor_text_color', '#212529')};
border-radius: 8px;
max-width: 600px;
max-height: 80vh;
width: 90%;
overflow: hidden;
transform: scale(0.9) translateY(-20px);
transition: transform 0.3s;
}}
.markitect-insert-mode .ui-edit-modal-overlay.active .ui-edit-modal {{
transform: scale(1) translateY(0);
}}
.markitect-insert-mode .ui-edit-modal-header {{
padding: 20px 24px 16px;
border-bottom: 1px solid {props.get('editor_panel_border', '#dee2e6')};
display: flex;
justify-content: space-between;
align-items: center;
}}
.markitect-insert-mode .ui-edit-modal-title {{
margin: 0;
font-size: 18px;
font-weight: 600;
color: {props.get('editor_text_color', '#212529')};
}}
.markitect-insert-mode .ui-edit-modal-close {{
background: transparent;
border: none;
font-size: 24px;
cursor: pointer;
color: {props.get('editor_text_color', '#212529')};
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
}}
.markitect-insert-mode .ui-edit-modal-close:hover {{
background: {props.get('editor_button_hover', '#e9ecef')};
}}
.markitect-insert-mode .ui-edit-modal-body {{
padding: 20px 24px;
max-height: 400px;
overflow-y: auto;
color: {props.get('editor_text_color', '#212529')};
}}
.markitect-insert-mode .ui-edit-modal-section {{
margin-bottom: 8px;
color: {props.get('editor_text_color', '#212529')};
}}
.markitect-insert-mode .ui-edit-modal-footer {{
padding: 16px 24px 20px;
border-top: 1px solid {props.get('editor_panel_border', '#dee2e6')};
text-align: right;
}}
/* Confirmation Dialog Styles for Insert Mode */
.markitect-insert-mode .ui-edit-confirmation-modal {{
max-width: 500px;
}}
.markitect-insert-mode .ui-edit-confirmation-content {{
font-size: 16px;
line-height: 1.5;
margin-bottom: 24px;
color: {props.get('editor_text_color', '#212529')};
}}
.markitect-insert-mode .ui-edit-confirmation-warning {{
background: {props.get('editor_warning_bg', '#fff3cd')};
color: {props.get('editor_warning_text', '#856404')};
border: 1px solid {props.get('editor_warning_border', '#ffeaa7')};
border-radius: 6px;
padding: 12px 16px;
margin: 16px 0;
font-size: 14px;
line-height: 1.4;
}}
.markitect-insert-mode .ui-edit-confirmation-buttons {{
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
}}
.markitect-insert-mode .ui-edit-button-confirm {{
background: #dc3545;
color: white;
border: 1px solid #dc3545;
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}}
.markitect-insert-mode .ui-edit-button-confirm:hover {{
background: #c82333;
border-color: #bd2130;
}}
.markitect-insert-mode .ui-edit-button-cancel {{
background: {props.get('editor_button_bg', '#ffffff')};
color: {props.get('editor_text_color', '#212529')};
border: 1px solid {props.get('editor_panel_border', '#dee2e6')};
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}}
.markitect-insert-mode .ui-edit-button-cancel:hover {{
background: {props.get('editor_button_hover', '#e9ecef')};
}}
"""
return f"<style>{base_css}{heading_css}{text_css}{element_css}{link_css}{accent_css}{ui_css}</style>"
@@ -521,7 +889,7 @@ class CleanDocumentManager:
return self._generate_layered_css(layered_props)
def _generate_html_template(self, markdown_content: str, title: str, css: str = None, template: str = None,
edit_mode: bool = False, editor_theme: str = 'github', keyboard_shortcuts: bool = True, original_filename: str = 'document', version_info: dict = None, nodogtag: bool = False) -> str:
edit_mode: bool = False, insert_mode: bool = False, editor_theme: str = 'github', keyboard_shortcuts: bool = True, original_filename: str = 'document', version_info: dict = None, nodogtag: bool = False) -> str:
"""Generate clean HTML template."""
# Add dogtag to markdown content if not disabled
@@ -579,6 +947,28 @@ class CleanDocumentManager:
editor_config = f"""
const MARKITECT_EDIT_MODE = true;
const MARKITECT_EDITOR_CONFIG = {{
mode: 'edit',
theme: '{editor_theme}',
keyboardShortcuts: {str(keyboard_shortcuts).lower()},
autosave: false,
sections: true,
originalFilename: '{original_filename}',
version: '{version_str}',
repoName: '{version_info['repo_name'] if version_info else 'Markitect'}'
}};
// Make config available globally
window.editorConfig = MARKITECT_EDITOR_CONFIG;"""
elif insert_mode:
body_classes = ' class="markitect-insert-mode"'
# Configuration for insert mode editor
version_str = f"{version_info['repo_name']} v{version_info['version']}{version_info['git_info']}" if version_info else "Markitect v0.5.0.dev"
editor_config = f"""
const MARKITECT_INSERT_MODE = true;
const MARKITECT_EDITOR_CONFIG = {{
mode: 'insert',
restrictedHeadingLevels: [1, 2, 3],
theme: '{editor_theme}',
keyboardShortcuts: {str(keyboard_shortcuts).lower()},
autosave: false,
@@ -591,7 +981,8 @@ class CleanDocumentManager:
// Make config available globally
window.editorConfig = MARKITECT_EDITOR_CONFIG;"""
# Load clean editor architecture
# Load clean editor architecture for both edit and insert modes
if edit_mode or insert_mode:
editor_scripts = self._get_clean_editor_scripts()
# Generate the complete HTML template
@@ -656,17 +1047,30 @@ class CleanDocumentManager:
}}
}}
// Step 2: Initialize edit capabilities if enabled
if (typeof MARKITECT_EDIT_MODE !== 'undefined' && MARKITECT_EDIT_MODE) {{
console.log("Initializing clean edit capabilities...");
// Step 2: Initialize edit/insert capabilities if enabled
if ((typeof MARKITECT_EDIT_MODE !== 'undefined' && MARKITECT_EDIT_MODE) ||
(typeof MARKITECT_INSERT_MODE !== 'undefined' && MARKITECT_INSERT_MODE)) {{
const mode = (typeof MARKITECT_INSERT_MODE !== 'undefined' && MARKITECT_INSERT_MODE) ? 'insert' : 'edit';
console.log(`Initializing clean ${{mode}} capabilities...`);
try {{
console.log("Creating clean editor instance...");
initializeCleanEditor();
console.log("✓ Clean edit mode active - click any section to edit");
if (mode === 'insert') {{
console.log("✓ Clean insert mode active - click any section to edit (headings 1-3 protected)");
}} else {{
console.log("✓ Clean edit mode active - click any section to edit");
}}
}} catch (error) {{
console.error("Clean edit mode failed to initialize:", error);
console.error(`Clean ${{mode}} mode failed to initialize:`, error);
}}
}}
// Step 3: Initialize document scroll indicators (always available)
try {{
initializeScrollIndicators();
}} catch (error) {{
console.error("Scroll indicators failed to initialize:", error);
}}
}});
// Handle CDN loading errors
@@ -719,6 +1123,7 @@ class Section {
this.editingMarkdown = null;
this.pendingMarkdown = null;
this.sectionType = sectionType;
this.headingLevel = Section.detectHeadingLevel(originalMarkdown);
this.state = EditState.ORIGINAL;
this.domElement = null;
this.lastSaved = null;
@@ -843,6 +1248,39 @@ class Section {
}
return SectionType.PARAGRAPH;
}
static detectHeadingLevel(markdown) {
const trimmed = markdown.trim();
const match = trimmed.match(/^(#{1,6})\s/);
return match ? match[1].length : null;
}
isHeading() {
return this.sectionType === SectionType.HEADING;
}
isProtectedHeading() {
if (!this.isHeading()) return false;
// Check if we're in insert mode and if this heading level is protected
const config = window.editorConfig || {};
const restrictedLevels = config.restrictedHeadingLevels || [];
return config.mode === 'insert' && restrictedLevels.includes(this.headingLevel);
}
getHeadingText() {
if (!this.isHeading()) return null;
// Extract first line for heading text
const firstLine = this.originalMarkdown.trim().split('\\n')[0];
const match = firstLine.match(/^(#{1,6})\s+(.+)$/);
return match ? match[2] : null;
}
getHeadingContent() {
if (!this.isHeading()) return this.currentMarkdown;
const lines = this.currentMarkdown.split('\\n');
// Return content after the heading line
return lines.slice(1).join('\\n');
}
}
/**
@@ -876,7 +1314,7 @@ class SectionManager {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isHeading = /^#{1,6}\\s/.test(line);
const isHeading = /^#{1,6}\s/.test(line);
const isNewParagraph = line.trim() && i > 0 && !lines[i-1].trim();
const isNewSection = isHeading || isNewParagraph;
@@ -939,6 +1377,15 @@ class SectionManager {
throw new Error(`Section ${sectionId} not found`);
}
// For protected headings in insert mode, validate that heading hasn't changed
if (section.isProtectedHeading()) {
const originalHeadingLine = section.originalMarkdown.split('\\n')[0];
const newHeadingLine = section.editingMarkdown.split('\\n')[0];
if (originalHeadingLine !== newHeadingLine) {
throw new Error(`Cannot modify protected heading in insert mode. Heading level ${section.headingLevel} is read-only.`);
}
}
// Check if the edited content contains new headings that would create splits
const newContent = section.editingMarkdown;
const originalContent = section.originalMarkdown;
@@ -969,14 +1416,14 @@ class SectionManager {
// Count headings in new content
for (const line of lines) {
if (/^#{1,6}\\s/.test(line.trim())) {
if (/^#{1,6}\s/.test(line.trim())) {
newHeadingCount++;
}
}
// Count headings in original content
for (const line of originalLines) {
if (/^#{1,6}\\s/.test(line.trim())) {
if (/^#{1,6}\s/.test(line.trim())) {
originalHeadingCount++;
}
}
@@ -1044,7 +1491,7 @@ class SectionManager {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isHeading = /^#{1,6}\\s/.test(line.trim());
const isHeading = /^#{1,6}\s/.test(line.trim());
if (isHeading) {
// When we encounter a heading, complete any previous section
@@ -1229,18 +1676,54 @@ class DOMRenderer {
this.hideCurrentEditor();
const section = this.sectionManager.sections.get(sectionId);
const isProtectedHeading = section && section.isProtectedHeading();
const editorContainer = document.createElement('div');
editorContainer.className = 'ui-edit-inline-panel';
editorContainer.className = isProtectedHeading ? 'ui-edit-inline-panel ui-insert-protected-panel' : 'ui-edit-inline-panel';
editorContainer.style.cssText = `
display: flex;
gap: 12px;
align-items: flex-start;
flex-direction: column;
gap: 8px;
width: 100%;
`;
// If this is a protected heading, show the heading display
if (isProtectedHeading) {
const headingDisplay = document.createElement('div');
headingDisplay.className = 'ui-insert-heading-display';
const headingText = section.getHeadingText();
const headingLevel = section.headingLevel;
const headingMarkdown = '#'.repeat(headingLevel) + ' ' + headingText;
headingDisplay.textContent = headingMarkdown;
headingDisplay.style.cssText = `
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 14px;
font-weight: bold;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
border-left: 4px solid #007bff;
color: #333;
`;
editorContainer.appendChild(headingDisplay);
}
// Create content editing area
const editingArea = document.createElement('div');
editingArea.style.cssText = `
display: flex;
gap: 12px;
align-items: flex-start;
`;
const textarea = document.createElement('textarea');
textarea.className = 'ui-edit-textarea ui-edit-textarea-main';
textarea.value = content;
textarea.className = isProtectedHeading ? 'ui-edit-textarea ui-insert-content-editor' : 'ui-edit-textarea ui-edit-textarea-main';
// For protected headings, only show content after the heading
const textareaContent = isProtectedHeading ? section.getHeadingContent() : content;
textarea.value = textareaContent;
textarea.style.cssText = `
flex: 1;
min-height: 100px;
@@ -1253,7 +1736,14 @@ class DOMRenderer {
`;
textarea.addEventListener('input', () => {
this.sectionManager.updateContent(sectionId, textarea.value);
if (isProtectedHeading) {
// Reconstruct full content with protected heading
const headingLine = section.originalMarkdown.split('\\n')[0];
const fullContent = headingLine + '\\n' + textarea.value;
this.sectionManager.updateContent(sectionId, fullContent);
} else {
this.sectionManager.updateContent(sectionId, textarea.value);
}
});
textarea.addEventListener('keydown', this.handleKeydown);
@@ -1276,8 +1766,9 @@ class DOMRenderer {
controls.appendChild(createButton('✗ Cancel', 'ui-edit-button ui-edit-button-cancel', () => this.handleCancel(sectionId)));
controls.appendChild(createButton('🔄 Reset', 'ui-edit-button ui-edit-button-reset', () => this.handleReset(sectionId)));
editorContainer.appendChild(textarea);
editorContainer.appendChild(controls);
editingArea.appendChild(textarea);
editingArea.appendChild(controls);
editorContainer.appendChild(editingArea);
element.innerHTML = '';
element.appendChild(editorContainer);
@@ -1675,8 +2166,143 @@ class MarkitectCleanEditor {
}
}
resetAllSections() {
if (confirm('Reset all content to original markdown? This will lose all edits and remove split sections.')) {
/**
* Show custom confirmation dialog with theme-consistent styling
* @param {string} message - The confirmation message
* @param {string} confirmText - Text for confirm button (default: "Confirm")
* @param {string} cancelText - Text for cancel button (default: "Cancel")
* @param {string} warningText - Optional warning text to highlight consequences
* @returns {Promise<boolean>} - True if confirmed, false if cancelled
*/
showConfirmation(message, confirmText = "Confirm", cancelText = "Cancel", warningText = null) {
return new Promise((resolve) => {
// Remove any existing modal
const existingModal = document.querySelector('.ui-edit-modal-overlay');
if (existingModal) {
existingModal.remove();
}
// Create modal overlay
const overlay = document.createElement('div');
overlay.className = 'ui-edit-modal-overlay';
// Create modal content
const modal = document.createElement('div');
modal.className = 'ui-edit-modal ui-edit-confirmation-modal';
// Create header
const header = document.createElement('div');
header.className = 'ui-edit-modal-header';
const title = document.createElement('h3');
title.className = 'ui-edit-modal-title';
title.textContent = 'Confirm Action';
const closeBtn = document.createElement('button');
closeBtn.className = 'ui-edit-modal-close';
closeBtn.innerHTML = '×';
closeBtn.setAttribute('aria-label', 'Close');
header.appendChild(title);
header.appendChild(closeBtn);
// Create body
const body = document.createElement('div');
body.className = 'ui-edit-modal-body';
const content = document.createElement('div');
content.className = 'ui-edit-confirmation-content';
content.textContent = message;
body.appendChild(content);
// Add warning section if provided
if (warningText) {
const warning = document.createElement('div');
warning.className = 'ui-edit-confirmation-warning';
warning.textContent = warningText;
body.appendChild(warning);
}
// Create footer with action buttons
const footer = document.createElement('div');
footer.className = 'ui-edit-modal-footer';
const buttonContainer = document.createElement('div');
buttonContainer.className = 'ui-edit-confirmation-buttons';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'ui-edit-button-cancel';
cancelBtn.textContent = cancelText;
const confirmBtn = document.createElement('button');
confirmBtn.className = 'ui-edit-button-confirm';
confirmBtn.textContent = confirmText;
buttonContainer.appendChild(cancelBtn);
buttonContainer.appendChild(confirmBtn);
footer.appendChild(buttonContainer);
// Assemble modal
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
overlay.appendChild(modal);
document.body.appendChild(overlay);
// Function to close modal and resolve
const closeModal = (result) => {
overlay.remove();
resolve(result);
};
// Event listeners
closeBtn.addEventListener('click', () => closeModal(false));
cancelBtn.addEventListener('click', () => closeModal(false));
confirmBtn.addEventListener('click', () => closeModal(true));
// Close on overlay click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closeModal(false);
}
});
// Keyboard support
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
closeModal(false);
} else if (e.key === 'Enter') {
closeModal(true);
}
};
document.addEventListener('keydown', handleKeyDown);
// Clean up event listener when modal is closed
const originalResolve = resolve;
resolve = (result) => {
document.removeEventListener('keydown', handleKeyDown);
originalResolve(result);
};
// Show modal with animation
setTimeout(() => {
overlay.classList.add('active');
// Focus the confirm button for accessibility
confirmBtn.focus();
}, 10);
});
}
async resetAllSections() {
const confirmed = await this.showConfirmation(
'Reset all content to original markdown?',
'Reset Document',
'Keep Changes',
'This will permanently lose all edits and remove any split sections. This action cannot be undone.'
);
if (confirmed) {
// Clear the section manager completely
this.sectionManager.sections.clear();
// Note: No longer tracking single editingSection
@@ -1912,6 +2538,136 @@ function initializeCleanEditor() {
console.log('✅ Clean section editor initialized successfully');
}
// Document scroll indicators
function initializeScrollIndicators() {
// Create scroll indicators
const scrollUpIndicator = document.createElement('div');
scrollUpIndicator.className = 'ui-scroll-indicator ui-scroll-indicator-up';
scrollUpIndicator.setAttribute('aria-label', 'Scroll to top');
scrollUpIndicator.setAttribute('title', 'Scroll up');
const scrollDownIndicator = document.createElement('div');
scrollDownIndicator.className = 'ui-scroll-indicator ui-scroll-indicator-down';
scrollDownIndicator.setAttribute('aria-label', 'Scroll to bottom');
scrollDownIndicator.setAttribute('title', 'Scroll down');
document.body.appendChild(scrollUpIndicator);
document.body.appendChild(scrollDownIndicator);
let scrollIndicatorTimeout = null;
// Function to show/hide indicators based on scroll position and mouse position
function updateScrollIndicators(mouseY = null) {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Determine if scrolling is possible in each direction
const canScrollUp = scrollTop > 0;
const canScrollDown = scrollTop < documentHeight - windowHeight;
// Only show indicators if there's any scroll possibility or if document is short
let showUp = false;
let showDown = false;
// Show indicators on mouseover near top/bottom of viewport
if (mouseY !== null) {
const topZone = 100; // pixels from top
const bottomZone = windowHeight - 100; // pixels from bottom
if (mouseY <= topZone) {
showUp = true;
}
if (mouseY >= bottomZone) {
showDown = true;
}
}
// Update indicator visibility and state
if (showUp) {
scrollUpIndicator.classList.add('active');
if (canScrollUp) {
scrollUpIndicator.classList.remove('disabled');
} else {
scrollUpIndicator.classList.add('disabled');
}
} else {
scrollUpIndicator.classList.remove('active');
}
if (showDown) {
scrollDownIndicator.classList.add('active');
if (canScrollDown) {
scrollDownIndicator.classList.remove('disabled');
} else {
scrollDownIndicator.classList.add('disabled');
}
} else {
scrollDownIndicator.classList.remove('active');
}
// Auto-hide after a delay when mouse moves away
if (scrollIndicatorTimeout) {
clearTimeout(scrollIndicatorTimeout);
}
scrollIndicatorTimeout = setTimeout(() => {
scrollUpIndicator.classList.remove('active');
scrollDownIndicator.classList.remove('active');
}, 2000);
}
// Mouse move handler
function handleMouseMove(e) {
updateScrollIndicators(e.clientY);
}
// Smooth scroll function
function smoothScroll(targetY, duration = 500) {
const startY = window.pageYOffset;
const difference = targetY - startY;
const startTime = performance.now();
function step(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Easing function (ease-out)
const easeOut = 1 - Math.pow(1 - progress, 3);
window.scrollTo(0, startY + difference * easeOut);
if (progress < 1) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
// Click handlers for smooth scrolling
scrollUpIndicator.addEventListener('click', () => {
const currentScroll = window.pageYOffset;
const targetScroll = Math.max(0, currentScroll - window.innerHeight * 0.8);
smoothScroll(targetScroll);
});
scrollDownIndicator.addEventListener('click', () => {
const currentScroll = window.pageYOffset;
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
const targetScroll = Math.min(maxScroll, currentScroll + window.innerHeight * 0.8);
smoothScroll(targetScroll);
});
// Event listeners
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('scroll', () => updateScrollIndicators());
// Initial check
updateScrollIndicators();
console.log('✅ Document scroll indicators initialized');
}
// Export for testing and usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = { Section, SectionManager, DOMRenderer, MarkitectCleanEditor };

View File

@@ -75,7 +75,14 @@ LAYERED_THEMES = {
'editor_button_active': '#dee2e6',
'editor_text_color': '#212529',
'editor_focus_color': '#0066cc',
'editor_shadow': 'rgba(0,0,0,0.1)'
'editor_shadow': 'rgba(0,0,0,0.1)',
'editor_danger_button': '#dc3545',
'editor_danger_button_hover': '#c82333',
'editor_secondary_button': '#6c757d',
'editor_secondary_button_hover': '#545b62',
'editor_warning_bg': '#fff3cd',
'editor_warning_border': '#ffeaa7',
'editor_warning_text': '#856404'
}
},
'greyscale': {
@@ -93,10 +100,13 @@ LAYERED_THEMES = {
'editor_accept_hover': '#777777',
'editor_cancel_bg': '#999999',
'editor_cancel_hover': '#808080',
'editor_reset_bg': '#aaaaaa',
'editor_reset_hover': '#999999',
'editor_secondary_bg': '#bbbbbb',
'editor_secondary_hover': '#aaaaaa'
'editor_danger_button': '#8b0000',
'editor_danger_button_hover': '#700000',
'editor_secondary_button': '#666666',
'editor_secondary_button_hover': '#555555',
'editor_warning_bg': '#f0f0f0',
'editor_warning_border': '#cccccc',
'editor_warning_text': '#555555'
}
},
'electric': {
@@ -109,7 +119,14 @@ LAYERED_THEMES = {
'editor_button_active': '#0099ff',
'editor_text_color': '#00ffff',
'editor_focus_color': '#ffff00',
'editor_shadow': '0 0 20px rgba(0,255,255,0.5), 0 0 40px rgba(255,255,0,0.2)'
'editor_shadow': '0 0 20px rgba(0,255,255,0.5), 0 0 40px rgba(255,255,0,0.2)',
'editor_danger_button': '#ff3366',
'editor_danger_button_hover': '#ff0033',
'editor_secondary_button': '#006699',
'editor_secondary_button_hover': '#004d73',
'editor_warning_bg': '#003366',
'editor_warning_border': '#00ffff',
'editor_warning_text': '#ffff00'
}
},
'psychedelic': {
@@ -122,7 +139,14 @@ LAYERED_THEMES = {
'editor_button_active': 'rgba(255,20,147,0.5)',
'editor_text_color': '#ffffff',
'editor_focus_color': '#ff1493',
'editor_shadow': 'rgba(255,20,147,0.4)'
'editor_shadow': 'rgba(255,20,147,0.4)',
'editor_danger_button': 'linear-gradient(45deg, #ff0066, #cc0044)',
'editor_danger_button_hover': 'linear-gradient(45deg, #ff3388, #dd1155)',
'editor_secondary_button': 'linear-gradient(45deg, #8a2be2, #4b0082)',
'editor_secondary_button_hover': 'linear-gradient(45deg, #9932cc, #6a1a9a)',
'editor_warning_bg': 'linear-gradient(45deg, #ffa500, #ff8c00)',
'editor_warning_border': '#ff1493',
'editor_warning_text': '#ffffff'
}
},
@@ -1937,6 +1961,8 @@ def md_list_command(ctx, output_format, names_only):
help='Custom CSS file to include')
@click.option('--edit', is_flag=True,
help='Open in interactive edit mode with stable section editing')
@click.option('--insert', is_flag=True,
help='Open in interactive insert mode with heading protection (levels 1-3 read-only)')
@click.option('--editor-theme', default='github',
type=click.Choice(['github', 'monokai', 'tomorrow', 'dark']),
help='Editor theme for live edit mode (default: github)')
@@ -1948,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, editor_theme,
keyboard_shortcuts, use_publication_dir, dont_use_publication_dir, nodogtag):
def md_render_command(ctx, input_file, output, theme, css, edit, insert, editor_theme,
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.
@@ -1968,6 +1999,7 @@ def md_render_command(ctx, input_file, output, theme, css, edit, editor_theme,
markitect md-render README.md
markitect md-render docs/guide.md --output guide.html --theme github
markitect md-render draft.md --edit --editor-theme monokai
markitect md-render draft.md --insert --editor-theme monokai
markitect md-render doc.md --theme dark --css custom.css
markitect md-render doc.md --theme dark,academic
markitect md-render doc.md --theme light,github,corporate
@@ -1977,17 +2009,65 @@ def md_render_command(ctx, input_file, output, theme, css, edit, editor_theme,
try:
input_path = Path(input_file)
# Determine output path
# Validate mode flags
if edit and insert:
raise click.BadParameter("Cannot use both --edit and --insert flags simultaneously. Choose one mode.")
# 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
@@ -2010,10 +2090,29 @@ def md_render_command(ctx, input_file, output, theme, css, edit, editor_theme,
click.echo(f"Keyboard shortcuts: {'enabled' if keyboard_shortcuts else 'disabled'}")
click.echo(f"Theme: {theme or 'default'}")
click.echo(f"CSS: {css or 'default'}")
elif insert:
# Insert mode - generate HTML with insert capabilities and heading protection
result = doc_manager.render_file(input_file, str(output_path),
template=theme, css=css,
insert_mode=True,
editor_theme=editor_theme,
keyboard_shortcuts=keyboard_shortcuts,
nodogtag=nodogtag)
click.echo(f"✓ Rendered with interactive insert capabilities to: {output_path}")
if config.get('verbose', False):
click.echo(f"Editor theme: {editor_theme}")
click.echo(f"Keyboard shortcuts: {'enabled' if keyboard_shortcuts else 'disabled'}")
click.echo(f"Heading protection: levels 1-3 read-only")
click.echo(f"Theme: {theme or 'default'}")
click.echo(f"CSS: {css or 'default'}")
else:
# Static render
result = doc_manager.render_file(input_file, str(output_path),
template=theme, css=css,
edit_mode=False,
insert_mode=False,
nodogtag=nodogtag)
click.echo(f"✓ Rendered to: {output_path}")
@@ -3383,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)

View File

@@ -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("<svg>fake svg</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()