feat: implement instant markdown base and publication directory - Issue #135
Complete TDD8 implementation of publication directory support for md-render command: CORE FEATURES: • Publication directory management with ~/Notes/ default • MARKITECT_PUBLICATION_DIR environment variable override • Single file processing with --use-publication-dir flag • Directory processing with --dont-use-publication-dir flag • Recursive directory traversal with structure preservation • Automatic directory creation and path normalization IMPLEMENTATION DETAILS: • Extended md-render command with new CLI flags • Added 9 new helper functions for directory/file processing • Support for both single files and directory inputs • Comprehensive error handling and validation • Maintains backward compatibility CLI FLAGS ADDED: • --use-publication-dir: Force single files to use publication directory • --dont-use-publication-dir: Force directory processing to place HTML next to MD BEHAVIOR: • Single files: HTML next to MD by default, publication dir with flag • Directories: HTML in publication dir by default, next to MD with flag • Environment variable MARKITECT_PUBLICATION_DIR overrides default TESTING: • 18 comprehensive tests covering all functionality • Publication directory management (4 tests) • Single file processing (3 tests) • Directory processing (4 tests) • CLI integration (4 tests) • Edge cases (3 tests) • 100% test pass rate TDD8 Workflow: ISSUE→TEST→RED→GREEN→REFACTOR→DOCUMENT→REFINE→PUBLISH 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ replacing the legacy unprefixed commands for better namespace consistency.
|
||||
|
||||
import click
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
@@ -253,14 +254,16 @@ def md_list_command(ctx, output_format, names_only):
|
||||
@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.pass_context
|
||||
def md_render_command(ctx, input_file, output, template, css, edit, editor_theme, keyboard_shortcuts):
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
The generated HTML includes:
|
||||
• Embedded markdown content as JavaScript payload
|
||||
@@ -271,7 +274,17 @@ def md_render_command(ctx, input_file, output, template, css, edit, editor_theme
|
||||
• Optional instant editing capabilities with --edit flag
|
||||
• Graceful fallback if JavaScript fails
|
||||
|
||||
INPUT_FILE: Path to the markdown file to render
|
||||
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
|
||||
@@ -280,92 +293,157 @@ def md_render_command(ctx, input_file, output, template, css, edit, editor_theme
|
||||
• dark - GitHub dark mode inspired theme with dark background
|
||||
|
||||
Examples:
|
||||
# Basic usage with default template
|
||||
# Single file - HTML next to MD file
|
||||
markitect md-render README.md
|
||||
|
||||
# Specify output file and template
|
||||
markitect md-render README.md --output index.html --template github
|
||||
# Single file - HTML in publication directory
|
||||
markitect md-render README.md --use-publication-dir
|
||||
|
||||
# Dark theme for night reading
|
||||
markitect md-render docs/guide.md --template dark
|
||||
# Directory - HTML in publication directory with structure
|
||||
markitect md-render docs/
|
||||
|
||||
# Academic paper with custom styling
|
||||
markitect md-render paper.md --template academic --css custom.css
|
||||
# Directory - HTML next to each MD file
|
||||
markitect md-render docs/ --dont-use-publication-dir
|
||||
|
||||
# Enable instant editing capabilities
|
||||
markitect md-render README.md --edit
|
||||
# Custom publication directory
|
||||
MARKITECT_PUBLICATION_DIR=/tmp/pub markitect md-render docs/
|
||||
|
||||
# Editing with dark editor theme and keyboard shortcuts
|
||||
markitect md-render docs/guide.md --edit --editor-theme dark --keyboard-shortcuts
|
||||
|
||||
# Front matter will be parsed and available to JavaScript
|
||||
# Files with YAML front matter are fully supported
|
||||
# Directory with custom template
|
||||
markitect md-render docs/ --template github --edit
|
||||
"""
|
||||
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
|
||||
# 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()
|
||||
|
||||
# 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
|
||||
# Get publication directory
|
||||
publication_dir = get_publication_directory()
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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}")
|
||||
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)")
|
||||
|
||||
else:
|
||||
# Single file processing
|
||||
use_pub_dir = use_publication_dir # Default to next to file for single files
|
||||
|
||||
if config.get('verbose', False):
|
||||
click.echo(f"Processing single file: {input_path}")
|
||||
click.echo(f"Use publication directory: {use_pub_dir}")
|
||||
|
||||
# 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}")
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error rendering file: {e}", err=True)
|
||||
click.echo(f"Error: {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': {
|
||||
@@ -838,4 +916,108 @@ def generate_html_with_embedded_markdown(markdown_content, title, template, css_
|
||||
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
|
||||
Reference in New Issue
Block a user