Files
markitect-main/markitect/plugins/builtin/markdown_commands.py
tegwick 00c4177358 feat: implement md-render command with client-side JavaScript rendering - Issue #132
Add comprehensive client-side markdown rendering functionality with dark theme support:

Core Features:
- md-render command generates self-contained HTML files
- Embedded markdown payload with client-side JavaScript rendering
- marked.js integration from CDN with graceful fallback
- YAML front matter support and title extraction

Template System:
- 4 responsive templates: basic (default), github, academic, dark
- Dark theme with GitHub dark mode inspired colors
- Custom CSS injection capability
- Mobile-responsive design with viewport support

Implementation Details:
- Complete TDD8 workflow: ISSUE→TEST→RED→GREEN→REFACTOR→DOCUMENT→REFINE→PUBLISH
- 11+ comprehensive test scenarios with excellent coverage
- Refactored template system using style dictionaries
- Enhanced CLI help text with usage examples
- Clean code organization and documentation

Usage:
  markitect md-render README.md --template dark
  markitect md-render article.md --template github --css custom.css

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 00:14:56 +02:00

497 lines
17 KiB
Python

"""
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.
"""
import click
import json
import tempfile
from pathlib import Path
from typing import Dict, Any
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
@register_plugin("markdown_commands")
class MarkdownCommandsPlugin(CommandPlugin):
"""Plugin providing core markdown file operations."""
@property
def metadata(self) -> PluginMetadata:
return PluginMetadata(
name="markdown_commands",
version="1.0.0",
description="Core markdown file operations (ingest, get, list) with md- prefixes",
author="MarkiTect Core Team",
plugin_type=PluginType.COMMAND,
markitect_version=">=0.1.0"
)
def get_commands(self) -> Dict[str, Any]:
"""Return the markdown commands with md- prefixes."""
return {
'md-ingest': md_ingest_command,
'md-get': md_get_command,
'md-list': md_list_command,
'md-render': md_render_command
}
# Define commands as standalone functions
@click.command()
@click.argument('file_path', type=click.Path(exists=True))
@click.pass_context
def md_ingest_command(ctx, file_path):
"""
Process and store a markdown file.
Ingests a markdown file into the MarkiTect system, parsing its content,
extracting front matter, generating AST cache, and storing metadata
in the database.
FILE_PATH: Path to the markdown file to process
Examples:
markitect md-ingest README.md
markitect md-ingest docs/guide.md
"""
config = ctx.obj or {}
try:
if config.get('verbose', False):
click.echo(f"Processing file: {file_path}")
# Initialize document manager with database manager
doc_manager = DocumentManager(config.get('db_manager'))
# Process the file
result = doc_manager.ingest_file(file_path)
if config.get('verbose', False):
click.echo(f"Processing results:")
click.echo(f" File: {result['metadata']['filename']}")
click.echo(f" AST nodes: {len(result['ast'])} nodes")
click.echo(f" Cache file: {result['ast_cache_path']}")
click.echo(f" Parse time: {result['parse_time']:.2f}s")
click.echo(f" Cache time: {result['cache_time']:.2f}s")
click.echo(f"✓ Successfully ingested: {Path(file_path).name}")
except Exception as e:
click.echo(f"Error processing file: {e}", err=True)
raise click.Abort()
@click.command()
@click.argument('file_path', type=str)
@click.option('--output', '-o', type=click.Path(), help='Output file path (default: stdout)')
@click.pass_context
def md_get_command(ctx, file_path, output):
"""
Retrieve and output a processed markdown file.
Loads the file from the database and AST cache, then serializes it back
to markdown format. Supports outputting to file or stdout.
FILE_PATH: Name of the file to retrieve
Examples:
markitect md-get README.md
markitect md-get docs/guide.md --output modified_guide.md
"""
config = ctx.obj or {}
try:
if config.get('verbose', False):
click.echo(f"Retrieving file: {file_path}")
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)
# 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}")
else:
click.echo(markdown_content)
if config.get('verbose', False):
click.echo(f"Retrieved {len(ast)} AST tokens", err=True)
except Exception as e:
click.echo(f"Error retrieving file: {e}", err=True)
raise click.Abort()
@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.pass_context
def md_list_command(ctx, output_format, names_only):
"""
List all stored markdown files and their status.
Shows all markdown files that have been processed and stored
in the MarkiTect database with their basic metadata.
Examples:
markitect md-list
markitect md-list --format table
markitect md-list --format json
markitect md-list --names-only
"""
config = ctx.obj or {}
try:
if config.get('verbose', False):
click.echo("Retrieving all stored files...")
db_manager = config.get('db_manager')
files = db_manager.list_markdown_files()
if not files:
click.echo("No files found in database.")
click.echo("Use 'markitect md-ingest <file>' to add files.")
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()
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}")
except Exception as e:
click.echo(f"Error listing files: {e}", err=True)
raise click.Abort()
@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.pass_context
def md_render_command(ctx, input_file, output, template, css):
"""
Generate HTML with client-side JavaScript markdown rendering.
Creates a self-contained HTML file that includes the markdown content
as JavaScript data and renders it in the browser using client-side
markdown parsing with marked.js.
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
• Graceful fallback if JavaScript fails
INPUT_FILE: Path to the markdown file to render
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
Examples:
# Basic usage with default template
markitect md-render README.md
# Specify output file and template
markitect md-render README.md --output index.html --template github
# Dark theme for night reading
markitect md-render docs/guide.md --template dark
# Academic paper with custom styling
markitect md-render paper.md --template academic --css custom.css
# Front matter will be parsed and available to JavaScript
# Files with YAML front matter are fully supported
"""
config = ctx.obj or {}
try:
if config.get('verbose', False):
click.echo(f"Rendering file: {input_file}")
# Read markdown file
input_path = Path(input_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
)
# Determine output path
if not output:
output = input_path.with_suffix('.html')
else:
output = Path(output)
# Ensure output directory exists
output.parent.mkdir(parents=True, exist_ok=True)
# Write HTML file
output.write_text(html_content, encoding='utf-8')
click.echo(f"✓ HTML generated: {output}")
if config.get('verbose', False):
click.echo(f" Template: {template}")
click.echo(f" Title: {title}")
if css:
click.echo(f" Custom CSS: {css}")
except Exception as e:
click.echo(f"Error rendering file: {e}", err=True)
raise click.Abort()
# 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):
"""Generate HTML with embedded markdown content for client-side rendering."""
# Get template styles or default to basic
styles = TEMPLATE_STYLES.get(template, TEMPLATE_STYLES['basic'])
# HTML template with style variables
html_template = '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
body {{
font-family: {font_family};
line-height: 1.6;
max-width: {max_width};
margin: 0 auto;
padding: 20px;
color: {body_color};
{body_bg}
{text_align}
}}
#markdown-content {{
margin: 0;
}}
h1, h2, h3, h4, h5, h6 {{
color: {heading_color};
{heading_border}
}}
pre {{
background-color: {code_bg};
{code_border}
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}}
code {{
background-color: {code_bg};
{code_border}
padding: 2px 4px;
border-radius: 3px;
}}
blockquote {{
border-left: 4px solid {blockquote_border};
margin: 0;
padding-left: 20px;
color: {blockquote_color};
}}
{css_content}
</style>
</head>
<body>
<div id="markdown-content"></div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
// Embedded markdown payload
const markdownContent = {markdown_json};
const frontMatter = {front_matter_json};
// Render markdown on page load
document.addEventListener('DOMContentLoaded', function() {{
if (typeof marked !== 'undefined') {{
document.getElementById('markdown-content').innerHTML = marked.parse(markdownContent);
}} else {{
// Fallback if marked.js fails to load
document.getElementById('markdown-content').innerHTML =
'<pre>' + markdownContent.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>';
}}
}});
</script>
</body>
</html>'''
# Format template with styles and content
return html_template.format(
title=title,
css_content=css_content,
markdown_json=json.dumps(markdown_content),
front_matter_json=json.dumps(front_matter),
**styles
)