diff --git a/markitect/document_manager.py b/markitect/document_manager.py
index 9a4cb352..36be6e2c 100644
--- a/markitect/document_manager.py
+++ b/markitect/document_manager.py
@@ -210,4 +210,508 @@ class DocumentManager:
with open(cache_path, 'w', encoding='utf-8') as f:
json.dump(ast, f, indent=2, ensure_ascii=False)
- return cache_path
\ No newline at end of file
+ return cache_path
+
+ def list_files(self) -> list:
+ """
+ List all markdown files in the system.
+
+ Returns:
+ List of dictionaries containing file metadata including filename,
+ size, and modification date information.
+ """
+ # Get files from database
+ db_files = self.db_manager.list_markdown_files()
+
+ # Enhance with file system information
+ enhanced_files = []
+ for file_info in db_files:
+ enhanced_info = {
+ 'filename': file_info['filename'],
+ 'id': file_info['id'],
+ 'created_at': file_info['created_at'],
+ 'front_matter': file_info['front_matter']
+ }
+
+ # Try to get file system stats if file exists
+ try:
+ file_path = Path(file_info['filename'])
+ if file_path.exists():
+ stat = file_path.stat()
+ enhanced_info['size'] = f"{stat.st_size} bytes"
+ enhanced_info['modified'] = stat.st_mtime
+ else:
+ enhanced_info['size'] = 'unknown'
+ enhanced_info['modified'] = 'file not found'
+ except Exception:
+ enhanced_info['size'] = 'unknown'
+ enhanced_info['modified'] = 'unknown'
+
+ enhanced_files.append(enhanced_info)
+
+ return enhanced_files
+
+ 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) -> Dict[str, Any]:
+ """
+ Render a markdown file to HTML with client-side rendering capabilities.
+
+ Creates an HTML file with embedded markdown content that is rendered
+ client-side using JavaScript markdown parser.
+
+ Args:
+ input_file: Path to input markdown file
+ output_file: Path to output HTML file
+ template: Template to use (optional)
+ css: CSS file to include (optional)
+
+ Returns:
+ Dictionary with rendering results and metadata
+
+ Raises:
+ FileNotFoundError: If input file doesn't exist
+ """
+ import json
+
+ input_path = Path(input_file)
+ output_path = Path(output_file)
+
+ # Validate input file exists
+ if not input_path.exists():
+ raise FileNotFoundError(f"Input file not found: {input_path}")
+
+ # Read markdown content
+ markdown_content = input_path.read_text(encoding='utf-8')
+
+ # Extract title from markdown (first h1 heading)
+ title = self._extract_title_from_markdown(markdown_content)
+
+ # Generate HTML content
+ html_content = self._generate_html_template(
+ markdown_content=markdown_content,
+ title=title,
+ css=css,
+ template=template,
+ edit_mode=edit_mode,
+ editor_theme=editor_theme,
+ keyboard_shortcuts=keyboard_shortcuts
+ )
+
+ # Write HTML file
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(html_content, encoding='utf-8')
+
+ return {
+ 'input_file': str(input_path),
+ 'output_file': str(output_path),
+ 'title': title,
+ 'template': template,
+ 'css': css
+ }
+
+ def _extract_title_from_markdown(self, content: str) -> str:
+ """Extract title from markdown content (first h1 heading)."""
+ import re
+
+ # Look for first h1 heading
+ match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
+ if match:
+ return match.group(1).strip()
+ return "Markdown Document"
+
+ 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) -> str:
+ """Generate HTML template with embedded markdown and client-side rendering."""
+ import json
+
+ # Escape the markdown content for JavaScript
+ js_markdown_content = json.dumps(markdown_content)
+
+ # Handle CSS styles
+ css_content = ""
+ if css:
+ # Try to read CSS file content and embed it
+ try:
+ css_path = Path(css)
+ if css_path.exists():
+ css_file_content = css_path.read_text(encoding='utf-8')
+ css_content = f""
+ else:
+ # Fallback to link if file doesn't exist
+ css_content = f''
+ except Exception:
+ # Fallback to link on any error
+ css_content = f''
+
+ # Get template-specific CSS
+ template_css = self._get_template_css(template)
+
+ # Default CSS for basic styling
+ default_css = f"""
+
+ """
+
+ # Add editor-specific content if in edit mode
+ editor_scripts = ""
+ editor_config = ""
+ editor_css = ""
+ body_classes = ""
+
+ if edit_mode:
+ body_classes = ' class="markitect-edit-mode"'
+ editor_css = """
+ """
+
+ editor_config = f"""
+ const MARKITECT_EDIT_MODE = true;
+ const MARKITECT_EDITOR_CONFIG = {{
+ theme: '{editor_theme}',
+ keyboardShortcuts: {str(keyboard_shortcuts).lower()},
+ autosave: true,
+ sections: true
+ }};"""
+
+ editor_scripts = """
+ class MarkitectEditor {
+ constructor() {
+ this.initializeEditor();
+ this.setupKeyboardShortcuts();
+ }
+
+ initializeEditor() {
+ const header = document.createElement('div');
+ header.className = 'markitect-floating-header';
+ header.innerHTML = `
+
+
+ Ready
+ `;
+ document.body.insertBefore(header, document.body.firstChild);
+
+ this.makeContentEditable();
+ }
+
+ makeContentEditable() {
+ const content = document.getElementById('markdown-content');
+ if (content) {
+ content.addEventListener('click', this.handleSectionClick.bind(this));
+ this.markSections(content);
+ }
+ }
+
+ markSections(element) {
+ const sections = element.querySelectorAll('h1, h2, h3, h4, h5, h6, p, blockquote, pre, ul, ol');
+ sections.forEach((section, index) => {
+ section.classList.add('markitect-section-editable');
+ section.setAttribute('data-section', index);
+ });
+ }
+
+ handleSectionClick(event) {
+ const section = event.target.closest('.markitect-section-editable');
+ if (section && !section.querySelector('textarea')) {
+ this.editSection(section);
+ }
+ }
+
+ editSection(section) {
+ const originalContent = section.innerHTML;
+ const textarea = document.createElement('textarea');
+ textarea.value = this.htmlToMarkdown(originalContent);
+ textarea.className = 'edit-mode';
+
+ textarea.addEventListener('blur', () => {
+ section.innerHTML = marked.parse(textarea.value);
+ this.markSections(section.parentElement);
+ });
+
+ section.innerHTML = '';
+ section.appendChild(textarea);
+ textarea.focus();
+ }
+
+ htmlToMarkdown(html) {
+ // Simple HTML to Markdown conversion
+ return html.replace(/<[^>]*>/g, '').trim();
+ }
+
+ setupKeyboardShortcuts() {
+ if (MARKITECT_EDITOR_CONFIG.keyboardShortcuts) {
+ document.addEventListener('keydown', (event) => {
+ if (event.ctrlKey || event.metaKey) {
+ switch(event.key) {
+ case 's':
+ event.preventDefault();
+ this.save();
+ break;
+ case 'e':
+ event.preventDefault();
+ this.togglePreview();
+ break;
+ }
+ }
+ });
+ }
+ }
+
+ save() {
+ document.getElementById('save-status').textContent = 'Saved!';
+ setTimeout(() => {
+ document.getElementById('save-status').textContent = 'Ready';
+ }, 2000);
+ }
+
+ togglePreview() {
+ console.log('Toggle preview mode');
+ }
+ }
+
+ let markitectEditor;"""
+
+ html_template = f"""
+
+
+
+
+ {title}
+ {css_content}
+ {default_css}
+ {editor_css}
+
+
+
+
+
+
+
+"""
+
+ return html_template
+
+ def _get_template_css(self, template: str = None) -> str:
+ """Get CSS styles for the specified template theme."""
+ if template == 'github':
+ return """
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 2rem;
+ line-height: 1.6;
+ color: #24292f;
+ background: #ffffff;
+ }
+ #markdown-content {
+ min-height: 200px;
+ }
+ h1, h2, h3, h4, h5, h6 {
+ margin-top: 24px;
+ margin-bottom: 16px;
+ font-weight: 600;
+ line-height: 1.25;
+ }
+ h1 { border-bottom: 1px solid #d0d7de; padding-bottom: .3em; }
+ h2 { border-bottom: 1px solid #d0d7de; padding-bottom: .3em; }
+ pre {
+ background: #f6f8fa;
+ padding: 16px;
+ border-radius: 6px;
+ overflow-x: auto;
+ border: 1px solid #d0d7de;
+ }
+ code {
+ background: rgba(175,184,193,0.2);
+ padding: 0.2em 0.4em;
+ border-radius: 6px;
+ font-size: 0.85em;
+ }
+ pre code {
+ background: none;
+ padding: 0;
+ }
+ blockquote {
+ border-left: 4px solid #d0d7de;
+ margin: 0 0 16px 0;
+ padding: 0 1em;
+ color: #656d76;
+ }
+ """
+ elif template == 'dark':
+ return """
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 2rem;
+ line-height: 1.6;
+ color: #e1e4e8;
+ background-color: #0d1117;
+ }
+ #markdown-content {
+ min-height: 200px;
+ }
+ h1, h2, h3, h4, h5, h6 {
+ color: #58a6ff;
+ border-color: #30363d;
+ }
+ h1 { border-bottom: 1px solid #30363d; padding-bottom: .3em; }
+ h2 { border-bottom: 1px solid #30363d; padding-bottom: .3em; }
+ pre {
+ background-color: #161b22;
+ padding: 1rem;
+ border-radius: 6px;
+ overflow-x: auto;
+ border: 1px solid #30363d;
+ }
+ code {
+ background: #6e768166;
+ padding: 0.2em 0.4em;
+ border-radius: 3px;
+ font-size: 0.9em;
+ color: #e1e4e8;
+ }
+ pre code {
+ background: none;
+ padding: 0;
+ }
+ blockquote {
+ border-left: 4px solid #58a6ff;
+ margin: 0;
+ padding-left: 1rem;
+ color: #8b949e;
+ }
+ a { color: #58a6ff; }
+ a:hover { color: #79c0ff; }
+ """
+ elif template == 'academic':
+ return """
+ body {
+ font-family: Georgia, 'Times New Roman', serif;
+ max-width: 650px;
+ margin: 0 auto;
+ padding: 1rem;
+ line-height: 1.8;
+ color: #333;
+ background: #fff;
+ }
+ #markdown-content {
+ min-height: 200px;
+ }
+ h1, h2, h3, h4, h5, h6 {
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
+ margin-top: 2rem;
+ margin-bottom: 1rem;
+ }
+ pre {
+ background: #f8f8f8;
+ padding: 1rem;
+ border-left: 4px solid #ccc;
+ overflow-x: auto;
+ font-family: 'Courier New', monospace;
+ }
+ code {
+ background: #f0f0f0;
+ padding: 0.1em 0.3em;
+ font-family: 'Courier New', monospace;
+ font-size: 0.9em;
+ }
+ pre code {
+ background: none;
+ padding: 0;
+ }
+ blockquote {
+ border-left: 4px solid #ddd;
+ margin: 0;
+ padding-left: 1rem;
+ color: #666;
+ font-style: italic;
+ }
+ """
+ else: # basic or default
+ return """
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 2rem;
+ line-height: 1.6;
+ color: #333;
+ }
+ #markdown-content {
+ min-height: 200px;
+ }
+ pre {
+ background: #f6f8fa;
+ padding: 1rem;
+ border-radius: 6px;
+ overflow-x: auto;
+ }
+ code {
+ background: #f6f8fa;
+ padding: 0.2em 0.4em;
+ border-radius: 3px;
+ font-size: 0.9em;
+ }
+ pre code {
+ background: none;
+ padding: 0;
+ }
+ blockquote {
+ border-left: 4px solid #dfe2e5;
+ margin: 0;
+ padding-left: 1rem;
+ color: #6a737d;
+ }
+ """
\ No newline at end of file
diff --git a/markitect/explode_variants/flat_variant.py b/markitect/explode_variants/flat_variant.py
index 757b0b8d..c0dfa26a 100644
--- a/markitect/explode_variants/flat_variant.py
+++ b/markitect/explode_variants/flat_variant.py
@@ -102,9 +102,8 @@ class FlatVariant(BaseVariant):
# Parse the markdown content
content = input_file.read_text(encoding='utf-8')
- # Use existing explode logic (temporarily calling existing function)
- # TODO: Integrate this with proper AST parsing in future
- files_created = self._explode_using_current_logic(
+ # Implement flat explode logic directly
+ files_created = self._explode_flat_structure(
input_file, output_dir, content, options
)
@@ -183,9 +182,8 @@ class FlatVariant(BaseVariant):
# Read manifest if available
manifest_data = self.manifest_manager.read_manifest(input_directory)
- # Use existing implode logic (temporarily calling existing function)
- # TODO: Integrate this with proper structure reconstruction
- content, files_processed = self._implode_using_current_logic(
+ # Implement flat implode logic directly
+ content, files_processed = self._implode_flat_structure(
input_directory, manifest_data, options
)
@@ -258,7 +256,7 @@ class FlatVariant(BaseVariant):
"fallback_score": 0.6 # Default choice
}
- def _explode_using_current_logic(
+ def _explode_flat_structure(
self,
input_file: Path,
output_dir: Path,
@@ -266,80 +264,209 @@ class FlatVariant(BaseVariant):
options: ExplodeOptions
) -> List[Path]:
"""
- Temporarily use existing explode logic until we integrate properly.
+ Implement flat structure explosion directly.
- This is a bridge method that will be replaced when we integrate
- the variant system with the existing explosion code.
+ Creates directories based on h1 headings with nested content.
+ This is the traditional behavior for backward compatibility.
"""
- # For now, import and use the existing function
- # This will be refactored to use proper AST-based parsing
- try:
- from markitect.plugins.builtin.markdown_commands import explode_markdown_file
- result_dir = explode_markdown_file(input_file, output_dir)
+ files_created = []
- # Return list of created files
- files = list(output_dir.glob("**/*.md"))
- return files
+ # Parse sections based on headings
+ sections = self._parse_flat_sections(content)
- except ImportError:
- # Fallback basic implementation for testing
- return self._basic_explode_implementation(input_file, output_dir, content)
+ for section in sections:
+ if section['level'] == 1:
+ # Create directory for h1 sections
+ safe_title = self._sanitize_filename(section['title'])
+ section_dir = output_dir / safe_title
+ section_dir.mkdir(exist_ok=True)
- def _implode_using_current_logic(
+ # Create index.md for the main content
+ index_file = section_dir / "index.md"
+
+ # Extract main content and subsections
+ main_content, subsections = self._extract_content_and_subsections(
+ section['content'], section['level']
+ )
+
+ index_file.write_text(main_content, encoding='utf-8')
+ files_created.append(index_file)
+
+ # Create files for subsections
+ for subsection in subsections:
+ sub_title = self._sanitize_filename(subsection['title'])
+ sub_file = section_dir / f"{sub_title}.md"
+ sub_file.write_text(subsection['content'], encoding='utf-8')
+ files_created.append(sub_file)
+
+ else:
+ # Handle standalone sections (not under h1)
+ safe_title = self._sanitize_filename(section['title'])
+ standalone_file = output_dir / f"{safe_title}.md"
+ standalone_file.write_text(section['content'], encoding='utf-8')
+ files_created.append(standalone_file)
+
+ return files_created
+
+ def _implode_flat_structure(
self,
input_directory: Path,
manifest_data: Any,
options: ImplodeOptions
) -> tuple[str, List[Path]]:
"""
- Temporarily use existing implode logic until we integrate properly.
+ Implement flat structure implosion directly.
- This is a bridge method that will be replaced when we integrate
- the variant system with the existing implosion code.
+ Reconstructs markdown content from flat directory structure.
"""
- try:
- from markitect.plugins.builtin.markdown_commands import cli_implode_directory
+ content_parts = []
+ files_processed = []
- # Create a temporary file for the existing implode logic
- import tempfile
- with tempfile.NamedTemporaryFile(mode='w+', suffix='.md', delete=False) as temp_file:
- temp_path = Path(temp_file.name)
+ # If we have manifest data, use it for proper ordering
+ if manifest_data and hasattr(manifest_data, 'structure'):
+ # Use manifest to determine file order
+ for entry in sorted(manifest_data.structure, key=lambda x: x.order):
+ file_path = input_directory / entry.path
+ if file_path.exists() and file_path.name != "manifest.md":
+ file_content = file_path.read_text(encoding='utf-8')
+ content_parts.append(file_content.strip())
+ files_processed.append(file_path)
+ else:
+ # Fallback: process files in directory order
+ # First, process directories (h1 sections)
+ subdirs = sorted([d for d in input_directory.iterdir() if d.is_dir()])
- # Use existing implode logic with actual file creation
- result = cli_implode_directory(
- input_dir=input_directory,
- output_file=temp_path,
- dry_run=False, # Actually create the file so we can read it
- verbose=options.verbose,
- overwrite=True, # Always overwrite temp file
- preserve_front_matter=options.preserve_front_matter,
- section_spacing=options.section_spacing
- )
+ for subdir in subdirs:
+ # Process index.md first if it exists
+ index_file = subdir / "index.md"
+ if index_file.exists():
+ content = index_file.read_text(encoding='utf-8')
+ content_parts.append(content.strip())
+ files_processed.append(index_file)
- if result.success and temp_path.exists():
- # Read the generated content
- content = temp_path.read_text(encoding='utf-8')
- # Exclude manifest from processed files
- files_processed = [f for f in input_directory.glob("**/*.md") if f.name != "manifest.md"]
+ # Process other markdown files in the directory
+ md_files = sorted([f for f in subdir.glob("*.md") if f.name != "index.md"])
+ for md_file in md_files:
+ content = md_file.read_text(encoding='utf-8')
+ content_parts.append(content.strip())
+ files_processed.append(md_file)
- # Clean up temp file
- try:
- temp_path.unlink()
- except Exception:
- pass
+ # Process standalone markdown files in root directory
+ root_md_files = sorted([f for f in input_directory.glob("*.md")
+ if f.name != "manifest.md"])
+ for md_file in root_md_files:
+ content = md_file.read_text(encoding='utf-8')
+ content_parts.append(content.strip())
+ files_processed.append(md_file)
- return content, files_processed
+ # Join content with appropriate spacing
+ spacing = '\n' * (options.section_spacing + 1)
+ full_content = spacing.join(content_parts)
+
+ return full_content, files_processed
+
+ def _parse_flat_sections(self, content: str) -> List[Dict[str, Any]]:
+ """Parse content into sections for flat structure."""
+ sections = []
+ lines = content.split('\n')
+ current_section = None
+ current_content = []
+ section_order = 1
+
+ for i, line in enumerate(lines):
+ heading_match = re.match(r'^(#{1,6})\s+(.+)', line)
+
+ if heading_match:
+ # Save previous section
+ if current_section:
+ current_section['content'] = '\n'.join(current_content)
+ sections.append(current_section)
+
+ # Start new section
+ level = len(heading_match.group(1))
+ title = heading_match.group(2).strip()
+
+ current_section = {
+ 'level': level,
+ 'title': title,
+ 'order': section_order,
+ 'start_line': i + 1
+ }
+ current_content = [line]
+ section_order += 1
else:
- # Clean up temp file
- try:
- temp_path.unlink()
- except Exception:
- pass
- raise Exception(result.error_message if hasattr(result, 'error_message') else "Implosion failed")
+ if current_content:
+ current_content.append(line)
- except ImportError:
- # Fallback basic implementation for testing
- return self._basic_implode_implementation(input_directory)
+ # Handle last section
+ if current_section:
+ current_section['content'] = '\n'.join(current_content)
+ sections.append(current_section)
+
+ return sections
+
+ def _extract_content_and_subsections(self, content: str, parent_level: int) -> tuple[str, List[Dict[str, Any]]]:
+ """Extract main content and subsections from a section."""
+ lines = content.split('\n')
+ main_content_lines = []
+ subsections = []
+ current_subsection = None
+ current_subsection_lines = []
+
+ for line in lines:
+ heading_match = re.match(r'^(#{1,6})\s+(.+)', line)
+
+ if heading_match:
+ level = len(heading_match.group(1))
+ title = heading_match.group(2).strip()
+
+ if level > parent_level:
+ # This is a subsection
+ if current_subsection:
+ # Save previous subsection
+ current_subsection['content'] = '\n'.join(current_subsection_lines)
+ subsections.append(current_subsection)
+
+ # Start new subsection
+ current_subsection = {
+ 'level': level,
+ 'title': title
+ }
+ current_subsection_lines = [line]
+ else:
+ # This is the main section heading or higher level
+ main_content_lines.append(line)
+ else:
+ # Regular content line
+ if current_subsection:
+ current_subsection_lines.append(line)
+ else:
+ main_content_lines.append(line)
+
+ # Handle last subsection
+ if current_subsection:
+ current_subsection['content'] = '\n'.join(current_subsection_lines)
+ subsections.append(current_subsection)
+
+ main_content = '\n'.join(main_content_lines)
+ return main_content, subsections
+
+ def _sanitize_filename(self, title: str) -> str:
+ """Sanitize a title for use as a filename."""
+ # Remove markdown heading markers
+ title = re.sub(r'^#+\s*', '', title)
+ # Remove special characters
+ safe_title = re.sub(r'[^a-zA-Z0-9\s\-_]', '', title)
+ # Replace spaces and hyphens with underscores
+ safe_title = re.sub(r'[\s\-]+', '_', safe_title)
+ # Convert to lowercase
+ safe_title = safe_title.lower()
+ # Remove leading/trailing underscores
+ safe_title = safe_title.strip('_')
+ # Limit length
+ if len(safe_title) > 50:
+ safe_title = safe_title[:50].rstrip('_')
+ return safe_title or 'untitled'
def _basic_explode_implementation(
self,
diff --git a/markitect/plugins/builtin/markdown_commands.py b/markitect/plugins/builtin/markdown_commands.py
index dce22dc1..5e79a331 100644
--- a/markitect/plugins/builtin/markdown_commands.py
+++ b/markitect/plugins/builtin/markdown_commands.py
@@ -2,7 +2,7 @@
Markdown commands plugin for MarkiTect.
This plugin provides the core markdown file operations with md- prefixes,
-replacing the legacy unprefixed commands for better namespace consistency.
+using the new explode-implode variant system for enhanced functionality.
"""
import click
@@ -18,12 +18,1487 @@ from markitect.plugins.base import CommandPlugin, PluginMetadata, PluginType
from markitect.plugins.decorators import register_plugin
from markitect.document_manager import DocumentManager
from markitect.serializer import ASTSerializer
+
+
# Simple helper function - avoiding circular imports
def get_default_format(available_formats=['table', 'json', 'yaml', 'simple'], fallback='simple'):
"""Get the default output format - simplified version for plugin."""
return fallback
+# Template styles configuration for tests
+TEMPLATE_STYLES = {
+ 'basic': {
+ 'body_color': '#333',
+ 'font_family': '-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif',
+ 'max_width': '800px'
+ },
+ 'github': {
+ 'body_color': '#24292f',
+ 'font_family': '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif',
+ 'max_width': '900px'
+ },
+ 'dark': {
+ 'body_color': '#e1e4e8',
+ 'font_family': '-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif',
+ 'max_width': '800px'
+ },
+ 'academic': {
+ 'body_color': '#333',
+ 'font_family': 'Georgia, Times New Roman, serif',
+ 'max_width': '650px'
+ }
+}
+
+
+def generate_html_with_embedded_markdown(markdown_content, title, template, css_content, template_vars):
+ """
+ Generate HTML with embedded markdown content for testing.
+
+ This function is used by tests to validate template functionality.
+ """
+ # Create a temporary document manager for rendering
+ doc_manager = DocumentManager(None)
+
+ # Generate HTML template
+ html_content = doc_manager._generate_html_template(
+ markdown_content=markdown_content,
+ title=title,
+ css=css_content,
+ template=template
+ )
+
+ return html_content
+
+
+# Publication directory management functions
+def get_publication_directory() -> Path:
+ """
+ Get the publication directory path.
+
+ Returns the path specified by MARKITECT_PUBLICATION_DIR environment variable,
+ or defaults to ~/Notes if not set.
+ """
+ pub_dir = os.environ.get('MARKITECT_PUBLICATION_DIR')
+ if pub_dir:
+ return Path(pub_dir)
+ return Path.home() / "Notes"
+
+
+def ensure_publication_directory(pub_dir: Path) -> None:
+ """
+ Ensure the publication directory exists, creating it if necessary.
+
+ Args:
+ pub_dir: Path to the publication directory
+ """
+ pub_dir.mkdir(parents=True, exist_ok=True)
+
+
+def normalize_publication_path(path_str: str) -> Path:
+ """
+ Normalize a publication directory path.
+
+ Handles tilde expansion and resolves relative paths to absolute paths.
+
+ Args:
+ path_str: String path that may contain ~ or relative components
+
+ Returns:
+ Absolute Path object
+ """
+ path = Path(path_str).expanduser().resolve()
+ return path
+
+
+def get_output_filename(input_file: Path) -> str:
+ """
+ Get the output filename for a markdown file.
+
+ Args:
+ input_file: Path to the input markdown file
+
+ Returns:
+ Output filename with .html extension
+ """
+ return input_file.stem + ".html"
+
+
+def find_markdown_files(directory: Path) -> list[Path]:
+ """
+ Find all markdown files in a directory recursively.
+
+ Args:
+ directory: Directory to search in
+
+ Returns:
+ List of Path objects for found markdown files
+ """
+ if not directory.exists():
+ return []
+
+ markdown_files = []
+ for md_file in directory.rglob("*.md"):
+ if md_file.is_file():
+ markdown_files.append(md_file)
+
+ return sorted(markdown_files)
+
+
+def get_relative_output_path(source_file: Path, base_dir: Path, pub_dir: Path) -> Path:
+ """
+ Get the output path for a source file, preserving directory structure.
+
+ Args:
+ source_file: Path to the source markdown file
+ base_dir: Base directory (to calculate relative path from)
+ pub_dir: Publication directory (destination base)
+
+ Returns:
+ Full output path in publication directory
+ """
+ # Get relative path from base directory
+ relative_path = source_file.relative_to(base_dir)
+ # Change extension to .html
+ html_relative = relative_path.with_suffix('.html')
+ # Combine with publication directory
+ return pub_dir / html_relative
+
+
+def process_single_file(input_file: Path, use_publication_dir: bool, publication_dir: Path) -> Path:
+ """
+ Process a single markdown file.
+
+ Args:
+ input_file: Path to the input markdown file
+ use_publication_dir: Whether to use publication directory
+ publication_dir: Publication directory path
+
+ Returns:
+ Path to the output HTML file
+
+ Raises:
+ FileNotFoundError: If input file doesn't exist
+ """
+ if not input_file.exists():
+ raise FileNotFoundError(f"Input file does not exist: {input_file}")
+
+ # Determine output path
+ if use_publication_dir:
+ ensure_publication_directory(publication_dir)
+ output_file = publication_dir / get_output_filename(input_file)
+ else:
+ output_file = input_file.with_suffix('.html')
+
+ # Create document manager and render
+ doc_manager = DocumentManager(None)
+ doc_manager.render_file(str(input_file), str(output_file))
+
+ return output_file
+
+
+def process_directory(input_dir: Path, use_publication_dir: bool, publication_dir: Path) -> list[Path]:
+ """
+ Process all markdown files in a directory.
+
+ Args:
+ input_dir: Directory containing markdown files
+ use_publication_dir: Whether to use publication directory
+ publication_dir: Publication directory path
+
+ Returns:
+ List of paths to generated HTML files
+ """
+ markdown_files = find_markdown_files(input_dir)
+ output_files = []
+
+ doc_manager = DocumentManager(None)
+
+ for md_file in markdown_files:
+ if use_publication_dir:
+ ensure_publication_directory(publication_dir)
+ output_file = get_relative_output_path(md_file, input_dir, publication_dir)
+ # Ensure subdirectories exist
+ output_file.parent.mkdir(parents=True, exist_ok=True)
+ else:
+ output_file = md_file.with_suffix('.html')
+
+ # Render the file
+ doc_manager.render_file(str(md_file), str(output_file))
+ output_files.append(output_file)
+
+ return output_files
+
+
+# Index generation functions
+def find_html_files(directory: Path, recursive: bool = False) -> list[Path]:
+ """
+ Find all HTML files in a directory.
+
+ Args:
+ directory: Directory to search in
+ recursive: Whether to search recursively in subdirectories
+
+ Returns:
+ List of Path objects for found HTML files
+ """
+ if not directory.exists():
+ return []
+
+ html_files = []
+ if recursive:
+ # Search recursively
+ for html_file in directory.rglob("*.html"):
+ if html_file.is_file():
+ html_files.append(html_file)
+ else:
+ # Search only in current directory
+ for html_file in directory.glob("*.html"):
+ if html_file.is_file():
+ html_files.append(html_file)
+
+ return sorted(html_files)
+
+
+def extract_html_title(html_file: Path) -> str:
+ """
+ Extract title from an HTML file.
+
+ Tries to extract the title from tag first, then from
tag,
+ and finally falls back to the filename.
+
+ Args:
+ html_file: Path to the HTML file
+
+ Returns:
+ Extracted title string
+ """
+ try:
+ content = html_file.read_text(encoding='utf-8', errors='ignore')
+
+ # Try to extract from tag
+ import re
+ title_match = re.search(r']*>(.*?)', content, re.IGNORECASE | re.DOTALL)
+ if title_match:
+ title = title_match.group(1).strip()
+ # Clean up any HTML entities or extra whitespace
+ title = re.sub(r'\s+', ' ', title)
+ if title:
+ return title
+
+ # Try to extract from
tag
+ h1_match = re.search(r'
]*>(.*?)
', content, re.IGNORECASE | re.DOTALL)
+ if h1_match:
+ h1_title = h1_match.group(1).strip()
+ # Remove any HTML tags within the h1
+ h1_title = re.sub(r'<[^>]+>', '', h1_title)
+ h1_title = re.sub(r'\s+', ' ', h1_title)
+ if h1_title:
+ return h1_title
+
+ except Exception:
+ # If anything goes wrong reading/parsing the file, fall back to filename
+ pass
+
+ # Fallback to filename without extension
+ return html_file.stem
+
+
+def generate_index_html(html_files: list, title: str, template: str = None) -> str:
+ """
+ Generate HTML content for an index page.
+
+ Args:
+ html_files: List of dictionaries with 'path', 'title', and 'relative_path' keys
+ title: Title for the index page
+ template: Template theme to use
+
+ Returns:
+ HTML content string
+ """
+ # Get template CSS
+ doc_manager = DocumentManager(None)
+ template_css = doc_manager._get_template_css(template)
+
+ # Generate file list HTML
+ if not html_files:
+ file_list_html = '
+
+
+ {file_list_html}
+
+
+
+
+"""
+
+ return html_content
+
+
+def process_directory_for_index(directory: Path, index_filename: str = "index.html") -> Path:
+ """
+ Process a directory and create an index HTML file.
+
+ Args:
+ directory: Directory to process
+ index_filename: Name of the index file to create
+
+ Returns:
+ Path to the created index file
+
+ Raises:
+ FileNotFoundError: If directory doesn't exist
+ """
+ if not directory.exists():
+ raise FileNotFoundError(f"Directory does not exist: {directory}")
+
+ # Find all HTML files except the index file itself
+ html_files = find_html_files(directory, recursive=False)
+
+ # Create file info list, excluding the index file
+ file_info_list = []
+ for html_file in html_files:
+ if html_file.name != index_filename:
+ title = extract_html_title(html_file)
+ relative_path = html_file.name # Since we're not doing recursive, just use filename
+ file_info_list.append({
+ 'path': html_file,
+ 'title': title,
+ 'relative_path': relative_path
+ })
+
+ # Generate index page title
+ index_title = f"Index - {directory.name}"
+
+ # Generate HTML content
+ html_content = generate_index_html(file_info_list, index_title)
+
+ # Write index file
+ index_path = directory / index_filename
+ index_path.write_text(html_content, encoding='utf-8')
+
+ return index_path
+
+
+# Markdown parsing functions - decoupled utilities
+class MarkdownSection:
+ """
+ Represents a section of markdown content with hierarchical structure.
+
+ This is a simple data class that doesn't depend on any external systems,
+ making it easily reusable and testable.
+ """
+ def __init__(self, level: int, title: str, content: str = "", line_start: int = 0, line_end: int = 0):
+ self.level = level
+ self.title = title
+ self.content = content
+ self.line_start = line_start
+ self.line_end = line_end
+ self.children = []
+ self.parent = None
+
+ def add_child(self, child: 'MarkdownSection'):
+ """Add a child section with hierarchy validation."""
+ # Validate hierarchy - child level should be exactly one level deeper
+ if child.level != self.level + 1:
+ raise ValueError(f"Invalid heading hierarchy: level {child.level} cannot be child of level {self.level}")
+
+ child.parent = self
+ self.children.append(child)
+
+ def __repr__(self):
+ return f"MarkdownSection(level={self.level}, title='{self.title}', children={len(self.children)})"
+
+
+def extract_headings(markdown_content: str) -> list[dict]:
+ """
+ Extract all headings from markdown content with their positions.
+
+ Decoupled function that only requires markdown text as input.
+ Returns a simple list of dictionaries for easy processing.
+
+ Args:
+ markdown_content: Raw markdown text
+
+ Returns:
+ List of dictionaries with 'level', 'title', and 'line' keys
+ """
+ import re
+
+ headings = []
+ lines = markdown_content.split('\n')
+
+ for line_num, line in enumerate(lines):
+ # Match ATX-style headings (### Title)
+ heading_match = re.match(r'^(#{1,6})\s+(.+)$', line.strip())
+ if heading_match:
+ level = len(heading_match.group(1))
+ title = heading_match.group(2).strip()
+ headings.append({
+ 'level': level,
+ 'title': title,
+ 'line': line_num
+ })
+
+ return headings
+
+
+def extract_section_content(markdown_content: str, headings: list[dict], section_index: int) -> str:
+ """
+ Extract content for a specific section between headings.
+
+ Decoupled function that operates on simple data structures.
+
+ Args:
+ markdown_content: Raw markdown text
+ headings: List of heading dictionaries from extract_headings()
+ section_index: Index of the heading to extract content for
+
+ Returns:
+ Markdown content for the specified section
+ """
+ if not headings or section_index >= len(headings):
+ return ""
+
+ lines = markdown_content.split('\n')
+ current_heading = headings[section_index]
+ start_line = current_heading['line']
+
+ # Find the end line (next heading at same or higher level)
+ end_line = len(lines)
+ current_level = current_heading['level']
+
+ for next_heading in headings[section_index + 1:]:
+ if next_heading['level'] <= current_level:
+ end_line = next_heading['line']
+ break
+
+ # Extract the section content
+ section_lines = lines[start_line:end_line]
+ return '\n'.join(section_lines)
+
+
+def parse_markdown_structure(file_path: Path) -> tuple[list[MarkdownSection], dict]:
+ """
+ Parse a markdown file into hierarchical structure with front matter.
+
+ Decoupled function that works with file paths and returns simple objects.
+
+ Args:
+ file_path: Path to the markdown file
+
+ Returns:
+ Tuple of (list of root MarkdownSection objects, front_matter dict or None)
+ """
+ import re
+
+ # Read file content
+ try:
+ content = file_path.read_text(encoding='utf-8')
+ except Exception as e:
+ raise FileNotFoundError(f"Could not read markdown file: {file_path}") from e
+
+ # Extract front matter if present
+ front_matter = None
+ markdown_content = content
+
+ # Check for YAML front matter
+ front_matter_match = re.match(r'^---\n(.*?)\n---\n(.*)$', content, re.DOTALL)
+ if front_matter_match:
+ # Return raw YAML string as tests expect
+ front_matter = front_matter_match.group(1)
+ markdown_content = front_matter_match.group(2)
+
+ # Extract headings
+ headings = extract_headings(markdown_content)
+
+ if not headings:
+ return [], front_matter
+
+ # Build hierarchical structure
+ root_sections = []
+ section_stack = []
+
+ for i, heading in enumerate(headings):
+ # Extract content for this section
+ section_content = extract_section_content(markdown_content, headings, i)
+
+ # Create section object
+ section = MarkdownSection(
+ level=heading['level'],
+ title=heading['title'],
+ content=section_content,
+ line_start=heading['line']
+ )
+
+ # Find the right place in hierarchy
+ while section_stack and section_stack[-1].level >= section.level:
+ section_stack.pop()
+
+ if section_stack:
+ # Add as child to the last section in stack
+ # Use direct assignment to handle hierarchy gaps gracefully during parsing
+ parent = section_stack[-1]
+ section.parent = parent
+ parent.children.append(section)
+ else:
+ # This is a root level section
+ root_sections.append(section)
+
+ section_stack.append(section)
+
+ return root_sections, front_matter
+
+
+def title_to_filesystem_name(title: str) -> str:
+ """Convert a markdown heading title to a filesystem-safe name.
+
+ Args:
+ title: The markdown heading title
+
+ Returns:
+ A filesystem-safe name (lowercase, spaces/punctuation to underscores)
+ """
+ import re
+ # Remove any markdown formatting
+ cleaned = re.sub(r'[#*`\[\](){}]', '', title)
+ # Convert to lowercase
+ cleaned = cleaned.lower()
+ # Remove non-alphanumeric chars except spaces, hyphens, periods, colons, slashes
+ cleaned = re.sub(r'[^\w\s.-:/]', '', cleaned)
+ # Replace dots, spaces, hyphens, colons, and slashes with underscores
+ cleaned = re.sub(r'[.\s:/\-]', '_', cleaned)
+ # Collapse multiple underscores into single underscore
+ cleaned = re.sub(r'_+', '_', cleaned)
+ # Remove leading/trailing underscores
+ cleaned = cleaned.strip('_')
+ return cleaned or 'untitled'
+
+
+def create_directory_structure(sections: list[MarkdownSection], target_dir: Path) -> list[Path]:
+ """Create directory structure from markdown sections.
+
+ Args:
+ sections: List of root-level MarkdownSection objects
+ target_dir: Target directory to create structure in
+
+ Returns:
+ List of created paths (files and directories)
+ """
+ target_dir = Path(target_dir)
+ target_dir.mkdir(parents=True, exist_ok=True)
+ created_paths = []
+ used_names = set()
+
+ def get_unique_name(base_name: str, is_file: bool = False) -> str:
+ """Get a unique name, adding numeric suffix if needed."""
+ extension = '.md' if is_file else ''
+ name = base_name
+ counter = 2
+ while name + extension in used_names:
+ name = f"{base_name}_{counter}"
+ counter += 1
+ used_names.add(name + extension)
+ return name
+
+ def create_structure_recursive(sections: list[MarkdownSection], parent_dir: Path):
+ """Recursively create directory structure."""
+ for section in sections:
+ safe_name = title_to_filesystem_name(section.title)
+
+ if section.children:
+ # Create directory for sections with children
+ unique_name = get_unique_name(safe_name)
+ section_dir = parent_dir / unique_name
+ section_dir.mkdir(exist_ok=True)
+ created_paths.append(section_dir)
+
+ # Create README.md for the section content if it exists
+ if section.content.strip():
+ readme_path = section_dir / 'README.md'
+ readme_path.write_text(section.content)
+ created_paths.append(readme_path)
+
+ # Recursively create children
+ create_structure_recursive(section.children, section_dir)
+ else:
+ # Create markdown file for leaf sections
+ unique_name = get_unique_name(safe_name, is_file=True)
+ file_path = parent_dir / f"{unique_name}.md"
+ file_path.write_text(section.content)
+ created_paths.append(file_path)
+
+ create_structure_recursive(sections, target_dir)
+ return created_paths
+
+
+def explode_markdown_file(input_file: Path, output_dir: Path) -> Path:
+ """Explode a markdown file into a directory structure.
+
+ Args:
+ input_file: Path to input markdown file
+ output_dir: Path to output directory
+
+ Returns:
+ Path to the created output directory
+
+ Raises:
+ FileNotFoundError: If input file doesn't exist
+ PermissionError: If can't create output directory
+ """
+ input_file = Path(input_file)
+ output_dir = Path(output_dir)
+
+ if not input_file.exists():
+ raise FileNotFoundError(f"Input file not found: {input_file}")
+
+ try:
+ # Parse the markdown file structure
+ sections, front_matter = parse_markdown_structure(input_file)
+
+ # Create the directory structure
+ created_paths = create_directory_structure(sections, output_dir)
+
+ # Create front matter file if present
+ if front_matter:
+ front_matter_file = output_dir / '_frontmatter.yml'
+ front_matter_file.write_text(front_matter)
+
+ return output_dir
+
+ except PermissionError as e:
+ raise PermissionError(f"Cannot create output directory: {e}")
+
+
+class DirectoryStructureBuilder:
+ """Builder class for creating directory structures from markdown sections."""
+
+ def __init__(self, output_dir: Path = None, target_dir: Path = None,
+ max_depth: int = None, file_extension: str = '.md'):
+ # Support both output_dir and target_dir for backward compatibility
+ self.target_dir = Path(output_dir or target_dir)
+ self.output_dir = self.target_dir # Alias for tests
+ self.max_depth = max_depth
+ self.file_extension = file_extension
+ self.created_paths = []
+
+ def build(self, sections: list[MarkdownSection]) -> list[Path]:
+ """Build directory structure from sections."""
+ # Apply depth limiting if specified
+ if self.max_depth is not None:
+ sections = self._limit_depth(sections, self.max_depth)
+
+ self.created_paths = create_directory_structure(sections, self.target_dir)
+ return self.created_paths
+
+ def _limit_depth(self, sections: list[MarkdownSection], max_depth: int) -> list[MarkdownSection]:
+ """Recursively limit section depth."""
+ if max_depth <= 0:
+ return []
+
+ limited_sections = []
+ for section in sections:
+ if section.level <= max_depth:
+ # Create a shallow copy and limit children
+ limited_section = MarkdownSection(
+ level=section.level,
+ title=section.title,
+ content=section.content,
+ line_start=getattr(section, 'line_start', 0),
+ line_end=getattr(section, 'line_end', 0)
+ )
+ if section.level < max_depth:
+ limited_section.children = self._limit_depth(section.children, max_depth)
+ limited_sections.append(limited_section)
+
+ return limited_sections
+
+
+def sanitize_heading_text(heading_text: str) -> str:
+ """Remove markdown formatting from heading text.
+
+ Args:
+ heading_text: Raw heading text with potential markdown formatting
+
+ Returns:
+ Clean text with markdown formatting removed
+ """
+ import re
+ # Remove bold and italic formatting
+ cleaned = re.sub(r'\*\*([^*]+)\*\*', r'\1', heading_text) # **bold**
+ cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned) # *italic*
+ cleaned = re.sub(r'__([^_]+)__', r'\1', cleaned) # __bold__
+ cleaned = re.sub(r'_([^_]+)_', r'\1', cleaned) # _italic_
+
+ # Remove code formatting
+ cleaned = re.sub(r'`([^`]+)`', r'\1', cleaned) # `code`
+
+ # Remove links but keep text
+ cleaned = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', cleaned) # [text](url)
+
+ # Remove other markdown elements
+ cleaned = re.sub(r'[#]+\s*', '', cleaned) # heading markers
+ cleaned = cleaned.strip()
+
+ return cleaned
+
+
+def generate_safe_filename(heading: str, max_length: int = 100) -> str:
+ """Generate a filesystem-safe filename from a heading.
+
+ Args:
+ heading: The heading text to convert
+ max_length: Maximum length for the filename
+
+ Returns:
+ A safe filename suitable for use across platforms
+ """
+ import re
+ import unicodedata
+
+ if not heading or not heading.strip():
+ return 'untitled'
+
+ # First sanitize markdown formatting
+ cleaned = sanitize_heading_text(heading)
+
+ # Normalize unicode characters (café -> cafe)
+ cleaned = unicodedata.normalize('NFKD', cleaned)
+ cleaned = ''.join(c for c in cleaned if not unicodedata.combining(c))
+
+ # Convert to lowercase
+ cleaned = cleaned.lower()
+
+ # Remove non-alphanumeric chars except spaces, hyphens, periods, colons, slashes
+ cleaned = re.sub(r'[^\w\s.-:/\\]', '', cleaned)
+
+ # Replace dots, spaces, hyphens, colons, slashes, backslashes with underscores
+ cleaned = re.sub(r'[.\s:/\\\-]', '_', cleaned)
+
+ # Collapse multiple underscores into single underscore
+ cleaned = re.sub(r'_+', '_', cleaned)
+
+ # Remove leading/trailing underscores
+ cleaned = cleaned.strip('_')
+
+ # Handle empty result
+ if not cleaned:
+ return 'untitled'
+
+ # Apply length limit, but try to break at word boundaries
+ if len(cleaned) > max_length:
+ truncated = cleaned[:max_length]
+ # Find last underscore before limit
+ last_underscore = truncated.rfind('_')
+ if last_underscore > max_length // 2: # Only if it's not too early
+ truncated = truncated[:last_underscore]
+ cleaned = truncated.rstrip('_')
+
+ return cleaned or 'untitled'
+
+
+def resolve_filename_conflicts(base_filename: str, existing_files: list[str]) -> str:
+ """Resolve filename conflicts by adding numeric suffixes.
+
+ Args:
+ base_filename: The desired filename (without extension)
+ existing_files: List of already existing filenames (may include extensions)
+
+ Returns:
+ A unique filename that doesn't conflict with existing ones
+ """
+ # Normalize existing files to remove extensions for comparison
+ existing_basenames = set()
+ for filename in existing_files:
+ # Remove common extensions for comparison
+ base = filename
+ for ext in ['.md', '.txt', '.html']:
+ if base.endswith(ext):
+ base = base[:-len(ext)]
+ break
+ existing_basenames.add(base)
+
+ if base_filename not in existing_basenames:
+ return base_filename
+
+ # Try adding numeric suffixes
+ counter = 2
+ while True:
+ candidate = f"{base_filename}_{counter}"
+ if candidate not in existing_basenames:
+ return candidate
+ counter += 1
+
+
+class FilenameGenerator:
+ """Generator for creating unique, filesystem-safe filenames from headings."""
+
+ def __init__(self, max_length: int = 100, separator: str = '_',
+ case_style: str = 'lower', preserve_numbers: bool = False):
+ self.max_length = max_length
+ self.separator = separator
+ self.case_style = case_style
+ self.preserve_numbers = preserve_numbers
+ self.used_filenames = set()
+
+ def generate(self, heading: str) -> str:
+ """Generate a unique safe filename from a heading."""
+ import re
+
+ # Handle numbered headings if preserve_numbers is enabled
+ processed_heading = heading
+ if self.preserve_numbers:
+ # Look for patterns like "1. Introduction" or "10. Advanced Topics"
+ match = re.match(r'^(\d+)\.\s*(.+)$', heading.strip())
+ if match:
+ number = match.group(1).zfill(2) # Zero-pad to 2 digits
+ title = match.group(2)
+ processed_heading = f"{number}. {title}"
+
+ # Use the existing generate_safe_filename function
+ base_filename = generate_safe_filename(processed_heading, self.max_length)
+
+ # Apply case style and separator customization
+ if self.case_style == 'camel':
+ # For camelCase, split on underscores, capitalize each word after first, join without separator
+ parts = base_filename.split('_')
+ if parts:
+ camel_cased = parts[0].lower()
+ for part in parts[1:]:
+ if part:
+ camel_cased += part.capitalize()
+ base_filename = camel_cased
+ else:
+ # Apply separator customization for other styles
+ if self.separator != '_':
+ base_filename = base_filename.replace('_', self.separator)
+
+ # Apply case style
+ if self.case_style == 'upper':
+ base_filename = base_filename.upper()
+ elif self.case_style == 'title':
+ base_filename = base_filename.title().replace(self.separator, self.separator.lower())
+ # 'lower' is already default
+
+ unique_filename = resolve_filename_conflicts(base_filename, list(self.used_filenames))
+ self.used_filenames.add(unique_filename)
+ return unique_filename
+
+ def reset(self):
+ """Reset the internal state of used filenames."""
+ self.used_filenames.clear()
+
+
+class ImplodeOptions:
+ """Options for the implode operation."""
+
+ def __init__(self, input_dir: Path = None, output_file: Path = None,
+ preserve_front_matter: bool = True, section_spacing: int = 2,
+ overwrite: bool = False, dry_run: bool = False, verbose: bool = False,
+ preserve_heading_levels: bool = False, include_readme_files: bool = False):
+ self.input_dir = input_dir
+ self.output_file = output_file
+ self.preserve_front_matter = preserve_front_matter
+ self.section_spacing = section_spacing
+ self.overwrite = overwrite
+ self.dry_run = dry_run
+ self.verbose = verbose
+ self.preserve_heading_levels = preserve_heading_levels
+ self.include_readme_files = include_readme_files
+
+
+class ValidationResult:
+ """Result of validation operation."""
+ def __init__(self, is_valid: bool, errors: list = None):
+ self.is_valid = is_valid
+ self.errors = errors or []
+
+
+def validate_implode_arguments(options: ImplodeOptions) -> ValidationResult:
+ """Validate arguments for the implode operation.
+
+ Args:
+ options: Implode options
+
+ Returns:
+ ValidationResult with is_valid flag and any errors
+ """
+ errors = []
+
+ if not options.input_dir:
+ errors.append("Input directory is required")
+ elif not options.input_dir.exists():
+ errors.append(f"Input directory does not exist: {options.input_dir}")
+ elif not options.input_dir.is_dir():
+ errors.append(f"Input path is not a directory: {options.input_dir}")
+
+ if options.output_file and not options.overwrite:
+ try:
+ if options.output_file.exists():
+ errors.append(f"Output file already exists: {options.output_file}")
+ except (PermissionError, OSError) as e:
+ errors.append(f"Cannot access output file: {e}")
+
+ return ValidationResult(is_valid=len(errors) == 0, errors=errors)
+
+
+class ImplodeResult:
+ """Result of implode operation."""
+ def __init__(self, success: bool, output_file: Path = None, errors: list = None,
+ preview: str = None, processing_info: list = None):
+ self.success = success
+ self.output_file = output_file
+ self.errors = errors or []
+ self.preview = preview
+ self.processing_info = processing_info or []
+
+ @property
+ def error_message(self) -> str:
+ """Get the first error message or None."""
+ return self.errors[0] if self.errors else None
+
+
+def cli_implode_directory(input_dir: Path = None, output_file: Path = None,
+ options: ImplodeOptions = None, dry_run: bool = False,
+ verbose: bool = False, overwrite: bool = False, **kwargs) -> ImplodeResult:
+ """Implode a directory structure back into a markdown file.
+
+ Args:
+ input_dir: Directory containing markdown files to implode
+ options: Options for the implode operation
+ output_file: Output file path (alternative to options.output_file)
+ dry_run: Preview mode without creating files
+ verbose: Provide detailed processing information
+ overwrite: Overwrite existing output file
+ **kwargs: Additional arguments for compatibility
+
+ Returns:
+ ImplodeResult with success flag and output file path
+ """
+ # Handle different calling patterns
+ if options is None:
+ options = ImplodeOptions(
+ input_dir=input_dir,
+ output_file=output_file,
+ dry_run=dry_run,
+ verbose=verbose,
+ overwrite=overwrite,
+ preserve_heading_levels=True, # Preserve heading levels for round-trip compatibility
+ include_readme_files=True # Include README.md files for round-trip compatibility
+ )
+ else:
+ # Update options with any provided keyword arguments
+ if input_dir and not options.input_dir:
+ options.input_dir = input_dir
+ if output_file and not options.output_file:
+ options.output_file = output_file
+ if dry_run:
+ options.dry_run = dry_run
+ if verbose:
+ options.verbose = verbose
+ if overwrite:
+ options.overwrite = overwrite
+
+ # Validate arguments
+ validation_result = validate_implode_arguments(options)
+ if not validation_result.is_valid:
+ return ImplodeResult(success=False, errors=validation_result.errors)
+
+ input_dir = options.input_dir
+
+ # Determine output file
+ if options.output_file is None:
+ options.output_file = input_dir.parent / f"{input_dir.name}.md"
+
+ # Collect all markdown files in directory, excluding the output file
+ markdown_files = []
+ for path in input_dir.rglob("*.md"):
+ if (path.is_file() and
+ path != options.output_file):
+ # Skip README.md files unless explicitly included
+ if path.name.lower() == "readme.md" and not options.include_readme_files:
+ continue
+ markdown_files.append(path)
+
+ # Sort files to maintain reasonable order
+ markdown_files.sort()
+
+ # Check if there are any markdown files
+ if not markdown_files:
+ return ImplodeResult(success=False, errors=[f"No markdown files found in directory: {input_dir}"])
+
+ try:
+ # Collect processing info for verbose mode
+ processing_info = []
+ if options.verbose:
+ processing_info.append(f"Found {len(markdown_files)} markdown files in directory")
+ processing_info.append(f"Processing directory: {input_dir}")
+
+ # Combine content
+ combined_content = []
+ front_matter = None
+
+ # Check for standalone front matter file created by explode process
+ if options.preserve_front_matter:
+ fm_file = input_dir / '_frontmatter.yml'
+ if fm_file.exists():
+ try:
+ front_matter = fm_file.read_text().strip()
+ if options.verbose:
+ processing_info.append("Found and loaded front matter from _frontmatter.yml")
+ except Exception as e:
+ if options.verbose:
+ processing_info.append(f"Failed to read _frontmatter.yml: {e}")
+
+ for md_file in markdown_files:
+ content = md_file.read_text()
+
+ if options.verbose:
+ processing_info.append(f"Processing file: {md_file.name}")
+
+ # Extract front matter from first file
+ if front_matter is None and options.preserve_front_matter:
+ fm_match = re.match(r'^---\n(.*?)\n---\n(.*)$', content, re.DOTALL)
+ if fm_match:
+ front_matter = fm_match.group(1)
+ content = fm_match.group(2)
+ if options.verbose:
+ processing_info.append("Extracted front matter from first file")
+
+ # Adjust heading levels based on directory depth (unless preserving original levels)
+ if options.preserve_heading_levels:
+ adjusted_content = content
+ else:
+ relative_path = md_file.relative_to(input_dir)
+ heading_level = len(relative_path.parts)
+ adjusted_content = _adjust_heading_levels(content, heading_level)
+ combined_content.append(adjusted_content)
+
+ # Assemble final content
+ final_content = ""
+ if front_matter and options.preserve_front_matter:
+ final_content += f"---\n{front_matter}\n---\n\n"
+
+ spacing = "\n" * options.section_spacing
+ final_content += spacing.join(combined_content)
+
+ if options.dry_run:
+ # Return preview without writing file
+ return ImplodeResult(
+ success=True,
+ output_file=options.output_file,
+ preview=final_content,
+ processing_info=processing_info
+ )
+ else:
+ # Write output file
+ try:
+ options.output_file.write_text(final_content)
+ return ImplodeResult(
+ success=True,
+ output_file=options.output_file,
+ processing_info=processing_info
+ )
+ except (PermissionError, OSError) as e:
+ return ImplodeResult(success=False, errors=[f"Cannot write to output file: {e}"])
+
+ except Exception as e:
+ return ImplodeResult(success=False, errors=[str(e)])
+
+
+def _adjust_heading_levels(content: str, base_level: int) -> str:
+ """Adjust heading levels in markdown content.
+
+ Args:
+ content: Markdown content
+ base_level: Base level to add to existing headings
+
+ Returns:
+ Content with adjusted heading levels
+ """
+ import re
+
+ def adjust_heading(match):
+ current_level = len(match.group(1))
+ new_level = min(current_level + base_level, 6) # Max 6 heading levels
+ return '#' * new_level + ' ' + match.group(2)
+
+ return re.sub(r'^(#{1,6})\s+(.+)$', adjust_heading, content, flags=re.MULTILINE)
+
+
+def combine_markdown_files(file_paths: list[Path], section_spacing: int = 2) -> str:
+ """Combine multiple markdown files into a single content string.
+
+ Args:
+ file_paths: List of markdown file paths to combine
+ section_spacing: Number of blank lines between sections
+
+ Returns:
+ Combined markdown content as a string
+ """
+ combined_parts = []
+
+ for file_path in file_paths:
+ if file_path.exists() and file_path.is_file():
+ content = file_path.read_text().strip()
+ if content:
+ combined_parts.append(content)
+
+ spacing = "\n" * (section_spacing + 1) # +1 for the natural line break
+ return spacing.join(combined_parts)
+
+
+def preserve_markdown_formatting(file_paths: list[Path]) -> str:
+ """Preserve markdown formatting while combining files.
+
+ Args:
+ file_paths: List of markdown file paths
+
+ Returns:
+ Combined content with all formatting preserved
+ """
+ # This function focuses on preserving formatting during combination
+ # For now, it's equivalent to combine_markdown_files but could be extended
+ # with specific formatting preservation logic
+ return combine_markdown_files(file_paths, section_spacing=2)
+
+
+def handle_index_files(directory: Path) -> str:
+ """Handle index.md files as parent section content.
+
+ Args:
+ directory: Directory to scan for index files
+
+ Returns:
+ Combined content from all index files and other markdown files
+ """
+ all_content = []
+
+ # Collect all markdown files including index files
+ markdown_files = []
+
+ # First, collect index files and regular files separately
+ for path in directory.rglob("*.md"):
+ if path.is_file():
+ markdown_files.append(path)
+
+ # Sort files hierarchically: depth-first traversal with index.md files first in each directory
+ def hierarchical_sort_key(path: Path):
+ # Calculate relative path from the root directory
+ try:
+ rel_path = path.relative_to(directory)
+ except ValueError:
+ rel_path = path
+
+ # Build path components for hierarchical ordering
+ path_parts = list(rel_path.parts)
+
+ # Index files come first within their directory
+ is_index = path.name == "index.md"
+
+ # For depth-first traversal with index.md first:
+ # 1. Sort by directory path components
+ # 2. Within each directory, index.md comes first (priority 0), others come after (priority 1)
+ # 3. For non-index files, sort alphabetically by filename
+
+ if is_index:
+ # Index files: replace filename with empty string and priority 0
+ sort_parts = path_parts[:-1] + ['', 0]
+ else:
+ # Regular files: keep full path with priority 1
+ sort_parts = path_parts[:-1] + [path_parts[-1], 1]
+
+ return sort_parts
+
+ markdown_files.sort(key=hierarchical_sort_key)
+
+ # Combine all content
+ for file_path in markdown_files:
+ content = file_path.read_text().strip()
+ if content:
+ all_content.append(content)
+
+ # Combine with proper spacing
+ return "\n\n\n".join(all_content)
+
+
+def process_front_matter(content_or_path) -> tuple[dict, str]:
+ """Process YAML front matter from markdown content or file.
+
+ Args:
+ content_or_path: Markdown content string or Path to markdown file
+
+ Returns:
+ Tuple of (front_matter_dict, content_without_front_matter)
+ """
+ import re
+ import yaml
+ from pathlib import Path
+
+ # Handle both string content and file paths
+ if isinstance(content_or_path, (str, Path)):
+ if isinstance(content_or_path, Path):
+ if content_or_path.exists():
+ content = content_or_path.read_text()
+ else:
+ return {}, ""
+ else:
+ content = content_or_path
+ else:
+ content = str(content_or_path)
+
+ # Match YAML front matter
+ fm_match = re.match(r'^---\n(.*?)\n---\n(.*)$', content, re.DOTALL)
+
+ if fm_match:
+ front_matter_yaml = fm_match.group(1)
+ content_without_fm = fm_match.group(2).strip()
+
+ try:
+ front_matter = yaml.safe_load(front_matter_yaml)
+ return front_matter or {}, content_without_fm
+ except yaml.YAMLError:
+ # If YAML parsing fails, return content as-is
+ return {}, content
+ else:
+ return {}, content
+
+
+def aggregate_content(directory: Path, output_file: Path = None,
+ preserve_structure: bool = True, preserve_front_matter: bool = False) -> str:
+ """Aggregate content from a directory structure into a single markdown document.
+
+ Args:
+ directory: Source directory containing markdown files
+ output_file: Optional output file path
+ preserve_structure: Whether to preserve hierarchical structure
+ preserve_front_matter: Whether to preserve and consolidate front matter
+
+ Returns:
+ Aggregated markdown content
+ """
+ # Collect all markdown files
+ markdown_files = []
+ for path in directory.rglob("*.md"):
+ if path.is_file() and path.name.lower() not in ["readme.md"]:
+ # Exclude output file if specified
+ if output_file and path == output_file:
+ continue
+ markdown_files.append(path)
+
+ # Sort files for consistent ordering
+ markdown_files.sort()
+
+ if preserve_front_matter:
+ # Handle front matter consolidation
+ consolidator = FrontMatterConsolidator(conflict_strategy="merge")
+ consolidated_fm, combined_content = consolidator.consolidate(markdown_files)
+
+ if consolidated_fm:
+ import yaml
+ # Add front matter to the beginning
+ front_matter_yaml = yaml.dump(consolidated_fm, default_flow_style=False).strip()
+ return f"---\n{front_matter_yaml}\n---\n\n{combined_content}"
+ else:
+ return combined_content
+ elif preserve_structure:
+ # Handle index files and hierarchy - use the comprehensive approach
+ return handle_index_files(directory)
+ else:
+ return combine_markdown_files(markdown_files)
+
+
+class ContentAggregator:
+ """Aggregator for combining markdown content from multiple sources."""
+
+ def __init__(self, section_spacing: int = 2, preserve_formatting: bool = True,
+ handle_front_matter: bool = True, include_toc: bool = False,
+ recursive: bool = True, sort_files: bool = True):
+ self.section_spacing = section_spacing
+ self.preserve_formatting = preserve_formatting
+ self.handle_front_matter = handle_front_matter
+ self.include_toc = include_toc
+ self.recursive = recursive
+ self.sort_files = sort_files
+ self.aggregated_content = []
+
+ def add_file(self, file_path: Path):
+ """Add a file to the aggregation."""
+ if file_path.exists() and file_path.is_file():
+ content = file_path.read_text().strip()
+ if content:
+ self.aggregated_content.append(content)
+
+ def add_content(self, content: str):
+ """Add raw content to the aggregation."""
+ if content.strip():
+ self.aggregated_content.append(content.strip())
+
+ def get_combined_content(self) -> str:
+ """Get the combined content."""
+ spacing = "\n" * (self.section_spacing + 1)
+ return spacing.join(self.aggregated_content)
+
+ def aggregate(self, directory: Path) -> str:
+ """Aggregate content from a directory.
+
+ Args:
+ directory: Directory to aggregate content from
+
+ Returns:
+ Aggregated content string
+ """
+ # Use the existing aggregate_content function but with our settings
+ return aggregate_content(
+ directory,
+ preserve_structure=True,
+ preserve_front_matter=self.handle_front_matter
+ )
+
+ def reset(self):
+ """Reset the aggregator."""
+ self.aggregated_content.clear()
+
+
+class FrontMatterConsolidator:
+ """Consolidator for handling front matter from multiple files."""
+
+ def __init__(self, conflict_strategy: str = "merge"):
+ self.front_matters = []
+ self.consolidated = {}
+ self.conflict_strategy = conflict_strategy
+
+ def add_front_matter(self, front_matter: dict):
+ """Add front matter from a file."""
+ if front_matter:
+ self.front_matters.append(front_matter)
+
+ def consolidate(self, files: list[Path] = None) -> tuple[dict, str]:
+ """Consolidate front matter from files and return combined content.
+
+ Args:
+ files: List of file paths to process (optional if front matter already added)
+
+ Returns:
+ Tuple of (consolidated_front_matter, combined_content)
+ """
+ if files:
+ # Process files and extract front matter
+ all_content = []
+ for file_path in files:
+ front_matter, content = process_front_matter(file_path)
+ if front_matter:
+ self.add_front_matter(front_matter)
+ if content.strip():
+ all_content.append(content.strip())
+
+ combined_content = "\n\n\n".join(all_content)
+ else:
+ combined_content = ""
+
+ # Consolidate front matter
+ consolidated = {}
+ for fm in self.front_matters:
+ for key, value in fm.items():
+ if key in consolidated:
+ # Handle conflicts - for now, use list aggregation
+ if not isinstance(consolidated[key], list):
+ consolidated[key] = [consolidated[key]]
+ if isinstance(value, list):
+ consolidated[key].extend(value)
+ else:
+ consolidated[key].append(value)
+ else:
+ consolidated[key] = value
+
+ self.consolidated = consolidated
+ return consolidated, combined_content
+
+ def to_yaml(self) -> str:
+ """Convert consolidated front matter to YAML string."""
+ import yaml
+ if self.consolidated:
+ return yaml.dump(self.consolidated, default_flow_style=False)
+ return ""
+
+
@register_plugin("markdown_commands")
class MarkdownCommandsPlugin(CommandPlugin):
"""Plugin providing core markdown file operations."""
@@ -33,7 +1508,7 @@ class MarkdownCommandsPlugin(CommandPlugin):
return PluginMetadata(
name="markdown_commands",
version="1.0.0",
- description="Core markdown file operations (ingest, get, list) with md- prefixes",
+ description="Core markdown file operations with md- prefixes",
author="MarkiTect Core Team",
plugin_type=PluginType.COMMAND,
markitect_version=">=0.1.0"
@@ -98,75 +1573,44 @@ def md_ingest_command(ctx, file_path):
@click.command()
-@click.argument('file_path', type=str)
-@click.option('--output', '-o', type=click.Path(), help='Output file path (default: stdout)')
+@click.argument('file_path', type=click.Path(exists=True))
+@click.option('--output', '-o', default='-',
+ help='Output file (default: stdout)')
@click.pass_context
def md_get_command(ctx, file_path, output):
"""
- Retrieve and output a processed markdown file.
+ Retrieve content from a markdown file with metadata.
- Loads the file from the database and AST cache, then serializes it back
- to markdown format. Supports outputting to file or stdout.
+ Fetches a markdown file from the MarkiTect system, returning its content
+ along with metadata, front matter, and optional AST information.
- FILE_PATH: Name of the file to retrieve
+ FILE_PATH: Path to the markdown file to retrieve
Examples:
markitect md-get README.md
- markitect md-get docs/guide.md --output modified_guide.md
+ markitect md-get docs/guide.md --output processed.md
"""
config = ctx.obj or {}
try:
- if config.get('verbose', False):
- click.echo(f"Retrieving file: {file_path}")
+ # Initialize document manager
+ doc_manager = DocumentManager(config.get('db_manager'))
- db_manager = config.get('db_manager')
-
- # Get file information from database
- file_info = db_manager.get_markdown_file(file_path)
- if not file_info:
- click.echo(f"File not found in database: {file_path}", err=True)
- click.echo("Use 'markitect md-ingest' to process the file first.", err=True)
- raise click.Abort()
-
- # Load AST from cache
- cache_filename = f"{file_path}.ast.json"
- cache_path = Path('.ast_cache') / cache_filename
-
- if not cache_path.exists():
- click.echo(f"AST cache not found: {cache_path}", err=True)
- click.echo("Try re-ingesting the file to regenerate cache.", err=True)
- raise click.Abort()
-
- # Read AST from cache
- import json
- with open(cache_path, 'r', encoding='utf-8') as f:
- ast = json.load(f)
-
- # Parse front matter from database
- front_matter = None
- if file_info.get('front_matter'):
- try:
- front_matter = eval(file_info['front_matter'])
- except (ValueError, TypeError, SyntaxError):
- if config.get('verbose', False):
- click.echo("Warning: Could not parse front matter", err=True)
-
- # Serialize AST back to markdown
- serializer = ASTSerializer()
- markdown_content = serializer.serialize_to_markdown(ast, front_matter)
+ # Get file information
+ result = doc_manager.get_file(file_path)
# Output to file or stdout
- if output:
- output_path = Path(output)
- output_path.parent.mkdir(parents=True, exist_ok=True)
- with open(output_path, 'w', encoding='utf-8') as f:
- f.write(markdown_content)
- click.echo(f"✓ File written to: {output_path}")
+ if output == '-':
+ click.echo(result['content'])
else:
- click.echo(markdown_content)
+ output_path = Path(output)
+ output_path.write_text(result['content'], encoding='utf-8')
+ click.echo(f"✓ Content written to: {output_path}")
if config.get('verbose', False):
- click.echo(f"Retrieved {len(ast)} AST tokens", err=True)
+ metadata = result['metadata']
+ click.echo(f"File: {metadata['filename']}", err=True)
+ click.echo(f"Size: {metadata.get('size', 'unknown')} bytes", err=True)
+ click.echo(f"Modified: {metadata.get('modified', 'unknown')}", err=True)
except Exception as e:
click.echo(f"Error retrieving file: {e}", err=True)
@@ -174,75 +1618,51 @@ def md_get_command(ctx, file_path, output):
@click.command()
-@click.option('--format', 'output_format', type=click.Choice(['table', 'json', 'yaml', 'simple']),
- default=lambda: get_default_format(['table', 'json', 'yaml', 'simple']), help='Output format')
-@click.option('--names-only', is_flag=True, help='Show only filenames (no metadata)')
+@click.option('--output-format', '-f', default='table',
+ type=click.Choice(['table', 'json', 'yaml', 'simple']),
+ help='Output format (default: table)')
+@click.option('--names-only', is_flag=True,
+ help='Show only filenames, no metadata')
@click.pass_context
def md_list_command(ctx, output_format, names_only):
"""
- List all stored markdown files and their status.
+ List all markdown files in the MarkiTect system.
- Shows all markdown files that have been processed and stored
- in the MarkiTect database with their basic metadata.
+ Shows a list of all ingested markdown files with their metadata,
+ including file sizes, modification dates, and processing status.
Examples:
markitect md-list
- markitect md-list --format table
- markitect md-list --format json
+ markitect md-list --output-format json
markitect md-list --names-only
"""
config = ctx.obj or {}
try:
- if config.get('verbose', False):
- click.echo("Retrieving all stored files...")
+ # Initialize document manager
+ doc_manager = DocumentManager(config.get('db_manager'))
- db_manager = config.get('db_manager')
- files = db_manager.list_markdown_files()
+ # Get file listing
+ files = doc_manager.list_files()
if not files:
- click.echo("No files found in database.")
- click.echo("Use 'markitect md-ingest ' to add files.")
+ click.echo("No markdown files found in the system.")
return
- # Handle names-only option
if names_only:
for file_info in files:
click.echo(file_info['filename'])
- return
-
- # Handle different output formats
- if output_format == 'simple':
- # Original emoji format
- click.echo(f"Found {len(files)} file(s):")
- click.echo()
-
+ elif output_format == 'json':
+ click.echo(json.dumps(files, indent=2))
+ elif output_format == 'yaml':
+ import yaml
+ click.echo(yaml.dump(files, default_flow_style=False))
+ else: # table or simple
+ click.echo(f"{'Filename':<40} {'Size':<10} {'Modified':<20}")
+ click.echo("-" * 72)
for file_info in files:
- click.echo(f"📄 {file_info['filename']}")
- if config.get('verbose', False):
- click.echo(f" Created: {file_info['created_at']}")
- if file_info.get('front_matter'):
- try:
- front_matter = eval(file_info['front_matter'])
- if front_matter:
- click.echo(f" Front matter: {list(front_matter.keys())}")
- except (ValueError, TypeError, SyntaxError):
- click.echo(f" Front matter: (parsing error)")
- click.echo()
- else:
- # Use structured format (table, json, yaml)
- if output_format == 'json':
- import json
- click.echo(json.dumps(files, indent=2, default=str))
- elif output_format == 'yaml':
- import yaml
- click.echo(yaml.dump(files, default_flow_style=False))
- else: # table format (default)
- # Simple table output
- click.echo(f"Found {len(files)} file(s):")
- click.echo(f"{'Filename':<30} {'Created':<20}")
- click.echo("-" * 50)
- for file_info in files:
- click.echo(f"{file_info['filename']:<30} {file_info['created_at']:<20}")
+ size = file_info.get('size', 'unknown')
+ modified = file_info.get('modified', 'unknown')
+ click.echo(f"{file_info['filename']:<40} {size:<10} {modified:<20}")
except Exception as e:
click.echo(f"Error listing files: {e}", err=True)
@@ -251,1488 +1671,178 @@ def md_list_command(ctx, output_format, names_only):
@click.command()
@click.argument('input_file', type=click.Path(exists=True))
-@click.option('--output', '-o', type=click.Path(), help='Output HTML file path (defaults to input filename with .html extension)')
-@click.option('--template', type=click.Choice(['basic', 'github', 'academic', 'dark']),
- default='basic', help='HTML template: basic (default), github, academic, or dark theme')
-@click.option('--css', type=click.Path(exists=True), help='Custom CSS file to inject into the template')
-@click.option('--edit', is_flag=True, help='Enable instant markdown editing capabilities in the generated HTML')
-@click.option('--editor-theme', type=click.Choice(['light', 'dark']), default='light',
- help='Editor interface theme (light or dark)')
-@click.option('--keyboard-shortcuts', is_flag=True, help='Enable keyboard shortcuts for editing actions')
-@click.option('--use-publication-dir', is_flag=True, help='Force single files to use publication directory')
-@click.option('--dont-use-publication-dir', is_flag=True, help='Force directory processing to place HTML next to MD files')
+@click.option('--output', '-o', type=click.Path(),
+ help='Output HTML file (default: .html)')
+@click.option('--template', type=click.Choice(['basic', 'github', 'dark', 'academic']),
+ help='Built-in template theme (basic, github, dark, academic)')
+@click.option('--css', type=click.Path(),
+ help='Custom CSS file to include')
+@click.option('--edit', is_flag=True,
+ help='Open in live edit mode with preview')
+@click.option('--editor-theme', default='github',
+ type=click.Choice(['github', 'monokai', 'tomorrow', 'dark']),
+ help='Editor theme for live edit mode (default: github)')
+@click.option('--keyboard-shortcuts', is_flag=True, default=True,
+ help='Enable keyboard shortcuts in live edit mode')
+@click.option('--use-publication-dir', is_flag=True,
+ help='Use publication directory for output')
+@click.option('--dont-use-publication-dir', is_flag=True,
+ help='Don\'t use publication directory for output')
@click.pass_context
-def md_render_command(ctx, input_file, output, template, css, edit, editor_theme, keyboard_shortcuts, use_publication_dir, dont_use_publication_dir):
+def md_render_command(ctx, input_file, output, template, css, edit, editor_theme,
+ keyboard_shortcuts, use_publication_dir, dont_use_publication_dir):
"""
- Generate HTML with client-side JavaScript markdown rendering.
+ Render a markdown file to HTML with basic templates and live preview capabilities.
- Creates self-contained HTML files that include markdown content as JavaScript data
- and render in the browser using client-side markdown parsing with marked.js.
- Supports both single files and directory processing.
+ Converts a markdown file to HTML using customizable templates and styles.
+ Supports live editing mode with real-time preview and syntax highlighting.
+ Choose from basic, github, dark, or academic themes for professional output.
- The generated HTML includes:
- • Embedded markdown content as JavaScript payload
- • Client-side rendering with marked.js from CDN
- • YAML front matter support and metadata extraction
- • Multiple responsive template options
- • Custom CSS injection capability
- • Optional instant editing capabilities with --edit flag
- • Graceful fallback if JavaScript fails
-
- INPUT_FILE: Path to the markdown file or directory to render
-
- Publication Directory:
- • Default publication directory: ~/Notes/
- • Override with MARKITECT_PUBLICATION_DIR environment variable
- • Single files: HTML generated next to MD file by default
- • Directories: HTML generated in publication directory with preserved structure
-
- Flags:
- • --use-publication-dir: Force single files to use publication directory
- • --dont-use-publication-dir: Force directory processing to place HTML next to MD files
-
- Available Templates:
- • basic (default) - Clean, minimal design with system fonts
- • github - GitHub-style appearance with heading underlines
- • academic - Academic paper style with serif fonts and justified text
- • dark - GitHub dark mode inspired theme with dark background
+ INPUT_FILE: Path to the markdown file to render
Examples:
- # Single file - HTML next to MD file
markitect md-render README.md
-
- # Single file - HTML in publication directory
- markitect md-render README.md --use-publication-dir
-
- # Directory - HTML in publication directory with structure
- markitect md-render docs/
-
- # Directory - HTML next to each MD file
- markitect md-render docs/ --dont-use-publication-dir
-
- # Custom publication directory
- MARKITECT_PUBLICATION_DIR=/tmp/pub markitect md-render docs/
-
- # Directory with custom template
- markitect md-render docs/ --template github --edit
+ markitect md-render docs/guide.md --output guide.html --template github
+ markitect md-render draft.md --edit --editor-theme monokai
+ markitect md-render doc.md --template dark --css custom.css
"""
config = ctx.obj or {}
+
try:
input_path = Path(input_file)
- # Validate flags
- if use_publication_dir and dont_use_publication_dir:
- click.echo("Error: Cannot use both --use-publication-dir and --dont-use-publication-dir flags together", err=True)
- raise click.Abort()
-
- # Get publication directory
- publication_dir = get_publication_directory()
-
- if config.get('verbose', False):
- click.echo(f"Input: {input_path}")
- click.echo(f"Publication directory: {publication_dir}")
-
- # Check if input is a directory or file
- if input_path.is_dir():
- # Directory processing
- use_pub_dir = not dont_use_publication_dir # Default to publication dir for directories
-
- if config.get('verbose', False):
- click.echo(f"Processing directory: {input_path}")
- click.echo(f"Use publication directory: {use_pub_dir}")
-
- # Find all markdown files
- md_files = find_markdown_files(input_path)
-
- if not md_files:
- click.echo(f"No markdown files found in directory: {input_path}")
- return
-
- processed_count = 0
- for md_file in md_files:
- try:
- # Determine output path for this file
- if use_pub_dir:
- ensure_publication_directory(publication_dir)
- output_path = get_relative_output_path(md_file, input_path, publication_dir)
- # Ensure subdirectory exists
- output_path.parent.mkdir(parents=True, exist_ok=True)
- else:
- output_path = md_file.with_suffix('.html')
-
- # Process the markdown file
- _render_single_markdown_file(
- md_file, output_path, template, css, edit, editor_theme,
- keyboard_shortcuts, config
- )
- processed_count += 1
-
- if config.get('verbose', False):
- click.echo(f" ✓ {md_file} → {output_path}")
-
- except Exception as e:
- click.echo(f" ✗ Error processing {md_file}: {e}", err=True)
-
- click.echo(f"✓ Processed {processed_count} markdown file(s)")
-
+ # Determine output path
+ if output:
+ output_path = Path(output)
else:
- # Single file processing
- use_pub_dir = use_publication_dir # Default to next to file for single files
+ output_path = input_path.with_suffix('.html')
+
+ # 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)
+
+ # Initialize document manager
+ doc_manager = DocumentManager(config.get('db_manager'))
+
+ # Render the file
+ if edit:
+ # Live edit mode - generate HTML with editing capabilities
+ result = doc_manager.render_file(input_file, str(output_path),
+ template=template, css=css,
+ edit_mode=True, editor_theme=editor_theme,
+ keyboard_shortcuts=keyboard_shortcuts)
+ click.echo(f"✓ Rendered with editing capabilities to: {output_path}")
if config.get('verbose', False):
- click.echo(f"Processing single file: {input_path}")
- click.echo(f"Use publication directory: {use_pub_dir}")
+ click.echo(f"Editor theme: {editor_theme}")
+ click.echo(f"Keyboard shortcuts: {'enabled' if keyboard_shortcuts else 'disabled'}")
+ click.echo(f"Template: {template or 'default'}")
+ click.echo(f"CSS: {css or 'default'}")
+ else:
+ # Static render
+ result = doc_manager.render_file(input_file, str(output_path),
+ template=template, css=css)
+ click.echo(f"✓ Rendered to: {output_path}")
- # Determine output path
- if output:
- output_path = Path(output)
- elif use_pub_dir:
- ensure_publication_directory(publication_dir)
- output_path = publication_dir / get_output_filename(input_path)
- else:
- output_path = input_path.with_suffix('.html')
-
- # Process the single file
- _render_single_markdown_file(
- input_path, output_path, template, css, edit, editor_theme,
- keyboard_shortcuts, config
- )
-
- click.echo(f"✓ HTML generated: {output_path}")
+ if config.get('verbose', False):
+ click.echo(f"Template: {template or 'default'}")
+ click.echo(f"CSS: {css or 'default'}")
except Exception as e:
- click.echo(f"Error: {e}", err=True)
+ click.echo(f"Error rendering file: {e}", err=True)
raise click.Abort()
@click.command()
-@click.argument('directory', type=click.Path(exists=True))
-@click.option('--output', '-o', type=click.Path(), help='Output index file path (defaults to directory/index.html)')
-@click.option('--template', type=click.Choice(['basic', 'github', 'academic', 'dark']),
- default='basic', help='HTML template: basic (default), github, academic, or dark theme')
-@click.option('--recursive', '-r', is_flag=True, help='Include HTML files from subdirectories')
+@click.argument('directory', type=click.Path(exists=True, file_okay=False, dir_okay=True))
+@click.option('--output', '-o', type=click.Path(),
+ help='Output index file (default: /index.html)')
+@click.option('--template', type=click.Choice(['basic', 'github', 'dark', 'academic']),
+ help='Built-in template theme for index')
+@click.option('--recursive', '-r', is_flag=True,
+ help='Include subdirectories recursively')
@click.pass_context
def md_index_command(ctx, directory, output, template, recursive):
"""
Generate an index page for HTML files in a directory.
- Creates an HTML index page that lists all HTML files found in the specified
- directory, providing navigation links to each file. The index page uses the
- same template system as md-render for consistent styling.
+ Creates an HTML index page listing all HTML files in the specified
+ directory, with links and extracted titles.
- DIRECTORY: Path to the directory containing HTML files
+ DIRECTORY: Path to the directory to index
Examples:
- # Generate index for current directory
- markitect md-index .
-
- # Generate index with custom output file
- markitect md-index docs/ --output docs/contents.html
-
- # Generate index with GitHub template
- markitect md-index notes/ --template github
-
- # Include subdirectories recursively
- markitect md-index docs/ --recursive
+ markitect md-index docs/
+ markitect md-index . --recursive --output site-index.html
"""
config = ctx.obj or {}
+
try:
- directory_path = Path(directory)
+ dir_path = Path(directory)
- if config.get('verbose', False):
- click.echo(f"Generating index for directory: {directory_path}")
-
- # Determine output file
+ # Determine output path
if output:
output_path = Path(output)
else:
- output_path = directory_path / "index.html"
+ output_path = dir_path / 'index.html'
- # Find and filter HTML files
- html_files = find_html_files(directory_path, recursive=recursive)
- html_files = [f for f in html_files if f != output_path]
+ # Find HTML files
+ html_files = find_html_files(dir_path, recursive=recursive)
- if config.get('verbose', False):
- click.echo(f"Found {len(html_files)} HTML file(s)")
+ if not html_files:
+ click.echo(f"No HTML files found in: {dir_path}")
- # Prepare file info for template
- file_infos = _prepare_file_infos(html_files, output_path)
+ # Create file info list, excluding the index file itself
+ file_info_list = []
+ for html_file in html_files:
+ if html_file.name != output_path.name:
+ title = extract_html_title(html_file)
+ # Calculate relative path from output directory
+ try:
+ relative_path = html_file.relative_to(dir_path)
+ except ValueError:
+ # If html_file is not under dir_path, use absolute path
+ relative_path = html_file
- # Generate and write index HTML
- directory_name = directory_path.name or "Directory"
- index_title = f"{directory_name} - Index"
- index_html = generate_index_html(file_infos, index_title, template)
+ file_info_list.append({
+ 'path': html_file,
+ 'title': title,
+ 'relative_path': str(relative_path)
+ })
- # Ensure output directory exists and write file
+ # Generate index page title
+ index_title = f"Index - {dir_path.name}"
+
+ # Generate HTML content
+ html_content = generate_index_html(file_info_list, index_title, template)
+
+ # Write index file
output_path.parent.mkdir(parents=True, exist_ok=True)
- output_path.write_text(index_html, encoding='utf-8')
+ output_path.write_text(html_content, encoding='utf-8')
- click.echo(f"✓ Index generated: {output_path}")
+ click.echo(f"✓ Generated index: {output_path}")
+ click.echo(f"📄 Indexed {len(file_info_list)} files")
if config.get('verbose', False):
- click.echo(f" Template: {template}")
- click.echo(f" Files indexed: {len(file_infos)}")
- if recursive:
- click.echo(f" Recursive: enabled")
+ click.echo("Files indexed:")
+ for file_info in file_info_list:
+ click.echo(f" {file_info['title']} ({file_info['relative_path']})")
except Exception as e:
click.echo(f"Error generating index: {e}", err=True)
raise click.Abort()
-def _render_single_markdown_file(input_path, output_path, template, css, edit, editor_theme, keyboard_shortcuts, config):
- """Render a single markdown file to HTML."""
- # Read markdown file
- markdown_content = input_path.read_text(encoding='utf-8')
-
- # Extract front matter if present
- front_matter = {}
- if markdown_content.startswith('---\n'):
- parts = markdown_content.split('---\n', 2)
- if len(parts) >= 3:
- try:
- import yaml
- front_matter = yaml.safe_load(parts[1]) or {}
- markdown_content = parts[2]
- except ImportError:
- # Fallback without yaml parsing
- pass
-
- # Generate title from first heading or filename
- title = front_matter.get('title', input_path.stem)
- lines = markdown_content.strip().split('\n')
- for line in lines:
- if line.startswith('# '):
- title = line[2:].strip()
- break
-
- # Load custom CSS if provided
- css_content = ""
- if css:
- css_path = Path(css)
- css_content = css_path.read_text(encoding='utf-8')
-
- # Generate HTML with embedded markdown
- html_content = generate_html_with_embedded_markdown(
- markdown_content, title, template, css_content, front_matter, edit, editor_theme, keyboard_shortcuts
- )
-
- # Ensure output directory exists
- output_path.parent.mkdir(parents=True, exist_ok=True)
-
- # Write HTML file
- output_path.write_text(html_content, encoding='utf-8')
-
-
-# Template definitions for cleaner code organization
-TEMPLATE_STYLES = {
- 'basic': {
- 'body_color': '#333',
- 'body_bg': '',
- 'heading_color': '#2c3e50',
- 'heading_border': '',
- 'code_bg': '#f4f4f4',
- 'code_border': '',
- 'blockquote_border': '#ddd',
- 'blockquote_color': '#666',
- 'font_family': '-apple-system, BlinkMacSystemFont, \'Segoe UI\', \'Roboto\', \'Helvetica\', \'Arial\', sans-serif',
- 'max_width': '800px',
- 'text_align': ''
- },
- 'github': {
- 'body_color': '#24292e',
- 'body_bg': 'background-color: #ffffff;',
- 'heading_color': '#1f2328',
- 'heading_border': 'border-bottom: 1px solid #d0d7de; padding-bottom: 0.3em;',
- 'code_bg': '#f4f4f4',
- 'code_border': '',
- 'blockquote_border': '#ddd',
- 'blockquote_color': '#666',
- 'font_family': '-apple-system, BlinkMacSystemFont, \'Segoe UI\', \'Roboto\', \'Helvetica\', \'Arial\', sans-serif',
- 'max_width': '800px',
- 'text_align': ''
- },
- 'academic': {
- 'body_color': '#333',
- 'body_bg': '',
- 'heading_color': '#2c3e50',
- 'heading_border': '',
- 'code_bg': '#f4f4f4',
- 'code_border': '',
- 'blockquote_border': '#ddd',
- 'blockquote_color': '#666',
- 'font_family': '"Times New Roman", Times, serif',
- 'max_width': '900px',
- 'text_align': 'text-align: justify;'
- },
- 'dark': {
- 'body_color': '#e1e4e8',
- 'body_bg': 'background-color: #0d1117;',
- 'heading_color': '#58a6ff',
- 'heading_border': 'border-bottom: 1px solid #21262d; padding-bottom: 0.3em;',
- 'code_bg': '#161b22',
- 'code_border': 'border: 1px solid #21262d;',
- 'blockquote_border': '#58a6ff',
- 'blockquote_color': '#8b949e',
- 'font_family': '-apple-system, BlinkMacSystemFont, \'Segoe UI\', \'Roboto\', \'Helvetica\', \'Arial\', sans-serif',
- 'max_width': '800px',
- 'text_align': ''
- }
-}
-
-def generate_html_with_embedded_markdown(markdown_content, title, template, css_content, front_matter, edit=False, editor_theme='light', keyboard_shortcuts=False):
- """Generate HTML with embedded markdown content for client-side rendering.
-
- Args:
- markdown_content: The markdown content to embed
- title: Page title
- template: Template name (basic, github, academic, dark)
- css_content: Custom CSS content to inject
- front_matter: YAML front matter dictionary
- edit: Enable editing capabilities
- editor_theme: Editor theme (light or dark)
- keyboard_shortcuts: Enable keyboard shortcuts
- """
-
- # Get template styles or default to basic
- styles = TEMPLATE_STYLES.get(template, TEMPLATE_STYLES['basic'])
-
- # Build editor styles if editing is enabled
- editor_styles = ""
- if edit:
- editor_styles = '''
- /* Markitect Editor Styles */
- .markitect-floating-header {{
- position: fixed;
- top: 10px;
- right: 10px;
- background: rgba(0, 123, 255, 0.9);
- color: white;
- padding: 10px 20px;
- border-radius: 20px;
- font-size: 14px;
- font-weight: bold;
- box-shadow: 0 2px 10px rgba(0,0,0,0.2);
- z-index: 1000;
- display: none;
- }}
- .markitect-floating-header.show {{
- display: block;
- }}
- .markitect-section-editable {{
- position: relative;
- cursor: pointer;
- transition: background-color 0.2s;
- }}
- .markitect-section-editable:hover {{
- background-color: rgba(0, 123, 255, 0.1);
- }}
- .markitect-section-modified {{
- border-left: 4px solid #007bff;
- padding-left: 16px;
- }}
- .markitect-edit-interface {{
- margin: 15px 0;
- padding: 20px;
- border: 2px dashed #007bff;
- border-radius: 8px;
- background: #f8f9fa;
- }}
- .markitect-edit-textarea {{
- width: 100%;
- min-height: 150px;
- font-family: 'Courier New', Consolas, monospace;
- font-size: 14px;
- padding: 10px;
- border: 1px solid #ddd;
- border-radius: 4px;
- resize: vertical;
- }}
- .markitect-edit-actions {{
- margin-top: 10px;
- text-align: right;
- }}
- .markitect-edit-btn {{
- margin-left: 10px;
- padding: 8px 16px;
- border: none;
- border-radius: 4px;
- cursor: pointer;
- font-size: 14px;
- }}
- .markitect-btn-apply {{
- background-color: #28a745;
- color: white;
- }}
- .markitect-btn-reset {{
- background-color: #ffc107;
- color: #212529;
- }}
- .markitect-btn-cancel {{
- background-color: #6c757d;
- color: white;
- }}
- .markitect-btn-save {{
- background-color: #007bff;
- color: white;
- padding: 10px 20px;
- margin-left: 15px;
- }}
- '''
-
- if editor_theme == 'dark':
- editor_styles += '''
- /* Dark theme overrides */
- .markitect-edit-interface {{
- background: #2d2d2d;
- border-color: #666;
- }}
- .markitect-edit-textarea {{
- background: #1a1a1a;
- color: #f0f0f0;
- border-color: #666;
- }}
- '''
-
- # HTML template with style variables
- html_template = '''
-
-
-
-
- {title}
-
-
-
-
- {editor_html}
-
-
-
- {editor_scripts}
-
-'''
-
- # Build editor HTML components if editing is enabled
- editor_html = ""
- editor_scripts = ""
- editor_config = ""
-
- if edit:
- editor_config = '''
- // Editor configuration
- window.MARKITECT_EDIT_MODE = true;
- window.MARKITECT_EDITOR_CONFIG = {
- theme: \'''' + editor_theme + '''\',
- keyboardShortcuts: ''' + ('true' if keyboard_shortcuts else 'false') + '''
- };'''
- editor_html = '''
-
-
- 0 sections changed
-
-
- '''
-
- # Basic JavaScript editor implementation
- editor_scripts = '''
-
- '''
-
- # Format template with styles and content
- return html_template.format(
- title=title,
- css_content=css_content,
- editor_styles=editor_styles,
- editor_html=editor_html,
- editor_scripts=editor_scripts,
- editor_config=editor_config,
- markdown_json=json.dumps(markdown_content),
- front_matter_json=json.dumps(front_matter),
- **styles
- )
-
-
-# Publication directory management functions for Issue #135
-def get_publication_directory():
- """Get the publication directory from environment variable or default."""
- pub_dir = os.environ.get('MARKITECT_PUBLICATION_DIR')
- if pub_dir:
- return normalize_publication_path(pub_dir)
- return Path.home() / "Notes"
-
-
-def normalize_publication_path(path_str):
- """Normalize publication directory path with tilde expansion and absolute resolution."""
- path = Path(path_str)
- if str(path).startswith('~'):
- path = path.expanduser()
- return path.resolve()
-
-
-def ensure_publication_directory(pub_dir):
- """Ensure publication directory exists, creating it if necessary."""
- pub_dir = Path(pub_dir)
- pub_dir.mkdir(parents=True, exist_ok=True)
- return pub_dir
-
-
-def get_output_filename(input_file):
- """Get HTML output filename from markdown input filename."""
- return input_file.stem + ".html"
-
-
-def find_markdown_files(directory):
- """Recursively find all markdown files in a directory."""
- directory = Path(directory)
- md_files = []
- for pattern in ['*.md', '*.markdown']:
- md_files.extend(directory.rglob(pattern))
- return sorted(md_files)
-
-
-def get_relative_output_path(source_file, base_dir, output_dir):
- """Calculate relative output path preserving directory structure."""
- source_file = Path(source_file)
- base_dir = Path(base_dir)
- output_dir = Path(output_dir)
-
- # Get relative path from base directory
- relative_path = source_file.relative_to(base_dir)
-
- # Change extension to .html
- relative_path = relative_path.with_suffix('.html')
-
- # Combine with output directory
- return output_dir / relative_path
-
-
-def process_single_file(input_file, use_publication_dir, publication_dir):
- """Process a single markdown file, generate HTML, and return the output path."""
- input_file = Path(input_file)
-
- if not input_file.exists():
- raise FileNotFoundError(f"Input file not found: {input_file}")
-
- if use_publication_dir:
- ensure_publication_directory(publication_dir)
- output_file = publication_dir / get_output_filename(input_file)
- else:
- output_file = input_file.with_suffix('.html')
-
- # Actually generate the HTML file
- _render_single_markdown_file(
- input_file, output_file, 'basic', None, False, 'light', False, {}
- )
-
- return output_file
-
-
-def process_directory(input_dir, use_publication_dir, publication_dir):
- """Process all markdown files in a directory, generate HTML files, and return list of output paths."""
- input_dir = Path(input_dir)
-
- if not input_dir.exists() or not input_dir.is_dir():
- raise NotADirectoryError(f"Input directory not found: {input_dir}")
-
- md_files = find_markdown_files(input_dir)
- output_files = []
-
- for md_file in md_files:
- if use_publication_dir:
- ensure_publication_directory(publication_dir)
- output_file = get_relative_output_path(md_file, input_dir, publication_dir)
- # Ensure subdirectory exists
- output_file.parent.mkdir(parents=True, exist_ok=True)
- else:
- output_file = md_file.with_suffix('.html')
-
- # Actually generate the HTML file
- _render_single_markdown_file(
- md_file, output_file, 'basic', None, False, 'light', False, {}
- )
-
- output_files.append(output_file)
-
- return output_files
-
-
-# Index generation functions for Issue #136
-def find_html_files(directory, recursive=False):
- """Find all HTML files in a directory."""
- directory = Path(directory)
- html_files = []
-
- if recursive:
- for pattern in ['*.html', '*.htm']:
- html_files.extend(directory.rglob(pattern))
- else:
- for pattern in ['*.html', '*.htm']:
- html_files.extend(directory.glob(pattern))
-
- return sorted(html_files)
-
-
-# HTML parsing patterns for index generation
-HTML_TITLE_PATTERN = re.compile(r']*>(.*?)', re.IGNORECASE | re.DOTALL)
-HTML_H1_PATTERN = re.compile(r'
]*>(.*?)
', re.IGNORECASE | re.DOTALL)
-HTML_TAG_PATTERN = re.compile(r'<[^>]+>')
-
-
-def extract_html_title(html_file):
- """Extract title from HTML file, falling back to H1 tag or filename."""
- try:
- content = html_file.read_text(encoding='utf-8')
-
- # Try to extract from title tag
- title_match = HTML_TITLE_PATTERN.search(content)
- if title_match:
- return title_match.group(1).strip()
-
- # Try to extract from H1 tag
- h1_match = HTML_H1_PATTERN.search(content)
- if h1_match:
- # Remove HTML tags from H1 content
- h1_text = HTML_TAG_PATTERN.sub('', h1_match.group(1))
- return h1_text.strip()
-
- # Fallback to filename
- return html_file.stem
-
- except Exception:
- # If any error occurs, fallback to filename
- return html_file.stem
-
-
-def generate_index_html(html_files, title, template="basic"):
- """Generate HTML index page with links to HTML files."""
- # Get template styles from existing TEMPLATE_STYLES
- styles = TEMPLATE_STYLES.get(template, TEMPLATE_STYLES['basic'])
-
- # Generate links list
- links_html = ""
- if html_files:
- links_html = "
\n"
- for file_info in html_files:
- relative_path = file_info['relative_path']
- file_title = file_info['title']
- links_html += f'