diff --git a/markitect/clean_document_manager.py b/markitect/clean_document_manager.py index 9cb98529..cc493c01 100644 --- a/markitect/clean_document_manager.py +++ b/markitect/clean_document_manager.py @@ -118,6 +118,202 @@ class CleanDocumentManager: return version_info + def _get_template_css(self, template: str = None) -> str: + """Generate layered theme CSS styles.""" + # Import layered theme functions + from markitect.plugins.builtin.markdown_commands import ( + parse_theme_string, combine_theme_properties, TEMPLATE_STYLES + ) + + # Handle layered themes or fall back to legacy + if template and ',' in template: + # New layered theme system + theme_list = parse_theme_string(template) + combined_props = combine_theme_properties(theme_list) + return self._generate_layered_css(combined_props) + else: + # Legacy single theme or fallback + if not template or template not in TEMPLATE_STYLES: + # Use default layered themes + theme_list = parse_theme_string('basic') + combined_props = combine_theme_properties(theme_list) + return self._generate_layered_css(combined_props) + else: + # Legacy theme - convert to layered + theme_list = parse_theme_string(template) + combined_props = combine_theme_properties(theme_list) + return self._generate_layered_css(combined_props) + + def _generate_layered_css(self, properties: dict) -> str: + """Generate CSS from combined theme properties.""" + + # Set defaults for missing properties (properties override defaults) + defaults = { + 'body_background': '#ffffff', + 'body_color': '#333333', + 'font_family': '-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif', + 'max_width': '800px', + 'heading_color': '#333333', # Use same as body color by default + 'heading_style': 'simple', + 'text_align': 'left', + 'code_background': '#f6f8fa', + 'code_color': '#333333', + 'border_color': '#d0d7de', + 'blockquote_border': '#dfe2e5', + 'blockquote_color': '#6a737d', + 'table_border': '#d0d7de', + 'table_header_bg': '#f6f8fa', + 'accent_color': None, + 'secondary_color': None + } + + # Merge defaults first, then override with theme properties + props = {**defaults, **properties} + + # Base CSS + base_css = f""" + body {{ + font-family: {props['font_family']}; + max-width: {props['max_width']}; + margin: 0 auto; + padding: 2rem; + line-height: 1.6; + color: {props['body_color']}; + background-color: {props['body_background']}; + }} + #markdown-content {{ + min-height: 200px; + }}""" + + # Heading styles + heading_css = "" + if props['heading_style'] == 'underlined': + heading_css = f""" + h1, h2, h3, h4, h5, h6 {{ + color: {props['heading_color']}; + border-bottom: 1px solid {props['border_color']}; + padding-bottom: 0.3em; + }}""" + elif props['heading_style'] == 'centered': + heading_css = f""" + h1, h2, h3, h4, h5, h6 {{ + color: {props['heading_color']}; + margin-top: 2rem; + margin-bottom: 1rem; + }} + h1 {{ + text-align: center; + font-size: 2.2em; + border-bottom: 2px solid {props['heading_color']}; + padding-bottom: 0.5rem; + }}""" + else: # simple + heading_css = f""" + h1, h2, h3, h4, h5, h6 {{ + color: {props['heading_color']}; + }}""" + + # Text alignment + text_css = "" + if props['text_align'] == 'justify': + text_css = """ + p { + text-align: justify; + margin-bottom: 1.2rem; + }""" + + # Element styling + element_css = f""" + pre {{ + background-color: {props['code_background']}; + color: {props['code_color']}; + padding: 1rem; + border-radius: 6px; + overflow-x: auto; + border: 1px solid {props['border_color']}; + }} + code {{ + background-color: {props['code_background']}; + color: {props['code_color']}; + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.9em; + }} + pre code {{ + background: none; + padding: 0; + }} + blockquote {{ + border-left: 4px solid {props['blockquote_border']}; + margin: 0; + padding-left: 1rem; + color: {props['blockquote_color']}; + }} + table {{ + font-size: 0.85em; + border-collapse: collapse; + margin: 1rem 0; + width: 100%; + border: 1px solid {props['table_border']}; + }} + th, td {{ + font-size: inherit; + border: 1px solid {props['table_border']}; + padding: 0.5rem; + text-align: left; + }} + th {{ + background-color: {props['table_header_bg']}; + font-weight: 600; + }}""" + + # Branding accents (if specified) + accent_css = "" + if props.get('accent_color'): + accent_css = f""" + a {{ + color: {props['accent_color']}; + }} + a:hover {{ + opacity: 0.8; + }}""" + + return f"" + + def _get_legacy_template_css(self, template: str) -> str: + """Legacy CSS generation - kept for backward compatibility.""" + # Import template styles + from markitect.plugins.builtin.markdown_commands import TEMPLATE_STYLES + + # Use basic as default if no template specified + if not template or template not in TEMPLATE_STYLES: + template = 'basic' + + style_config = TEMPLATE_STYLES[template] + + # Base CSS that's common to all templates + base_css = f""" + body {{ + font-family: {style_config['font_family']}; + max-width: {style_config['max_width']}; + margin: 0 auto; + padding: 2rem; + line-height: 1.6; + color: {style_config['body_color']}; + }} + #markdown-content {{ + min-height: 200px; + }}""" + + # Convert legacy template config to layered format + legacy_config = TEMPLATE_STYLES[template] + layered_props = { + 'font_family': legacy_config['font_family'], + 'max_width': legacy_config['max_width'], + 'body_color': legacy_config['body_color'], + } + return self._generate_layered_css(layered_props) + def _generate_html_template(self, markdown_content: str, title: str, css: str = None, template: str = None, edit_mode: bool = False, editor_theme: str = 'github', keyboard_shortcuts: bool = True, original_filename: str = 'document', version_info: dict = None) -> str: """Generate clean HTML template.""" @@ -138,60 +334,8 @@ class CleanDocumentManager: except Exception: css_content = f'' - # Default CSS for basic styling - default_css = """ - - """ + # Generate template-specific CSS + default_css = self._get_template_css(template) # Load clean editor JavaScript files editor_scripts = "" diff --git a/markitect/plugins/builtin/markdown_commands.py b/markitect/plugins/builtin/markdown_commands.py index 502e3fcd..98fac116 100644 --- a/markitect/plugins/builtin/markdown_commands.py +++ b/markitect/plugins/builtin/markdown_commands.py @@ -26,7 +26,97 @@ def get_default_format(available_formats=['table', 'json', 'yaml', 'simple'], fa return fallback -# Template styles configuration for tests +# Layered theme system - themes can be combined across different scopes +LAYERED_THEMES = { + # UI Themes - Interface colors and backgrounds + 'light': { + 'scope': 'ui', + 'properties': { + 'body_background': '#ffffff', + 'body_color': '#333333', + 'heading_color': '#24292f', + 'code_background': '#f6f8fa', + 'code_color': '#24292e', + 'border_color': '#d0d7de', + 'blockquote_border': '#dfe2e5', + 'blockquote_color': '#6a737d', + 'table_border': '#d0d7de', + 'table_header_bg': '#f6f8fa' + } + }, + 'dark': { + 'scope': 'ui', + 'properties': { + 'body_background': '#0d1117', + 'body_color': '#e1e4e8', + 'heading_color': '#58a6ff', + 'code_background': '#161b22', + 'code_color': '#e1e4e8', + 'border_color': '#30363d', + 'blockquote_border': '#58a6ff', + 'blockquote_color': '#8b949e', + 'table_border': '#30363d', + 'table_header_bg': '#161b22' + } + }, + + # Document Themes - Typography and layout + 'basic': { + 'scope': 'document', + 'properties': { + 'font_family': '-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif', + 'max_width': '800px', + 'heading_style': 'simple', + 'text_align': 'left' + } + }, + 'github': { + 'scope': 'document', + 'properties': { + 'font_family': '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif', + 'max_width': '900px', + 'heading_style': 'underlined', + 'text_align': 'left' + } + }, + 'academic': { + 'scope': 'document', + 'properties': { + 'font_family': 'Georgia, Times New Roman, serif', + 'max_width': '650px', + 'heading_style': 'centered', + 'text_align': 'justify' + } + }, + + # Branding Themes - Company/personal styling + 'corporate': { + 'scope': 'branding', + 'properties': { + 'accent_color': '#0066cc', + 'secondary_color': '#f8f9fa', + 'brand_font': 'inherit' + } + }, + 'startup': { + 'scope': 'branding', + 'properties': { + 'accent_color': '#ff6b35', + 'secondary_color': '#f4f4f4', + 'brand_font': 'inherit' + } + } +} + +# Legacy compatibility - map old theme names to new layered equivalents +LEGACY_THEME_MAPPING = { + 'basic': ['light', 'basic'], + 'github': ['light', 'github'], + 'dark': ['dark', 'basic'], + 'academic': ['light', 'academic'] +} + +# Keep TEMPLATE_STYLES for backward compatibility in tests TEMPLATE_STYLES = { 'basic': { 'body_color': '#333', @@ -51,21 +141,133 @@ TEMPLATE_STYLES = { } -def generate_html_with_embedded_markdown(markdown_content, title, template, css_content, template_vars): +def parse_theme_string(theme_string: str) -> list: + """ + Parse theme string into list of individual themes. + + Supports: + - Single theme: "dark" + - Multiple themes: "dark,academic" or "dark, academic" + - Legacy theme mapping: "basic" -> ["light", "basic"] + + Args: + theme_string: Comma-separated theme names + + Returns: + List of theme names in order + """ + if not theme_string: + return ['light', 'basic'] # Default themes + + # Split by comma and clean up whitespace + themes = [theme.strip() for theme in theme_string.split(',')] + + # Expand legacy themes only if they don't exist in the new layered system + expanded_themes = [] + for theme in themes: + if theme in LAYERED_THEMES: + # Theme exists in new system, use as-is + expanded_themes.append(theme) + elif theme in LEGACY_THEME_MAPPING: + # Legacy theme, expand it + expanded_themes.extend(LEGACY_THEME_MAPPING[theme]) + else: + # Unknown theme, add as-is (will be warned about later) + expanded_themes.append(theme) + + return expanded_themes + + +class ThemeType(click.ParamType): + """Custom click type for theme validation.""" + + name = "theme" + + def convert(self, value, param, ctx): + if value is None: + return value + + try: + validate_theme_string(value) + return value + except click.BadParameter as e: + self.fail(str(e), param, ctx) + + +def validate_theme_string(theme_string: str) -> None: + """ + Validate that all themes in a theme string are known themes. + + Args: + theme_string: Comma-separated theme names + + Raises: + click.BadParameter: If any theme is unknown + """ + if not theme_string: + return # Allow empty/None themes + + themes = parse_theme_string(theme_string) + unknown_themes = [] + + for theme_name in themes: + if theme_name not in LAYERED_THEMES and theme_name not in LEGACY_THEME_MAPPING: + unknown_themes.append(theme_name) + + if unknown_themes: + available_themes = list(LAYERED_THEMES.keys()) + list(LEGACY_THEME_MAPPING.keys()) + raise click.BadParameter( + f"Unknown theme(s): {', '.join(unknown_themes)}. " + f"Available themes: {', '.join(sorted(set(available_themes)))}" + ) + + +def combine_theme_properties(theme_list: list) -> dict: + """ + Combine properties from multiple themes, with later themes overriding earlier ones. + + Args: + theme_list: List of theme names in order of application + + Returns: + Combined properties dictionary + """ + combined_properties = {} + + for theme_name in theme_list: + if theme_name in LAYERED_THEMES: + theme_data = LAYERED_THEMES[theme_name] + # Later themes override earlier ones + combined_properties.update(theme_data['properties']) + elif theme_name in LEGACY_THEME_MAPPING: + # Handle legacy themes by expanding them + expanded_themes = LEGACY_THEME_MAPPING[theme_name] + for expanded_theme in expanded_themes: + if expanded_theme in LAYERED_THEMES: + theme_data = LAYERED_THEMES[expanded_theme] + combined_properties.update(theme_data['properties']) + else: + # This should not happen if validation is working + print(f"Warning: Unknown theme '{theme_name}' - skipping") + return combined_properties + + +def generate_html_with_embedded_markdown(markdown_content, title, theme, 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) + from markitect.clean_document_manager import CleanDocumentManager + doc_manager = CleanDocumentManager(None) # Generate HTML template html_content = doc_manager._generate_html_template( markdown_content=markdown_content, title=title, css=css_content, - template=template + template=theme ) return html_content @@ -191,7 +393,8 @@ def process_single_file(input_file: Path, use_publication_dir: bool, publication output_file = input_file.with_suffix('.html') # Create document manager and render - doc_manager = DocumentManager(None) + from markitect.clean_document_manager import CleanDocumentManager + doc_manager = CleanDocumentManager(None) doc_manager.render_file(str(input_file), str(output_file)) return output_file @@ -212,7 +415,8 @@ def process_directory(input_dir: Path, use_publication_dir: bool, publication_di markdown_files = find_markdown_files(input_dir) output_files = [] - doc_manager = DocumentManager(None) + from markitect.clean_document_manager import CleanDocumentManager + doc_manager = CleanDocumentManager(None) for md_file in markdown_files: if use_publication_dir: @@ -304,21 +508,22 @@ def extract_html_title(html_file: Path) -> str: return html_file.stem -def generate_index_html(html_files: list, title: str, template: str = None) -> str: +def generate_index_html(html_files: list, title: str, theme: 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 + theme: Theme to use Returns: HTML content string """ # Get template CSS - doc_manager = DocumentManager(None) - template_css = doc_manager._get_template_css(template) + from markitect.clean_document_manager import CleanDocumentManager + doc_manager = CleanDocumentManager(None) + template_css = doc_manager._get_template_css(theme) # Generate file list HTML if not html_files: @@ -1530,7 +1735,8 @@ def md_ingest_command(ctx, file_path): click.echo(f"Processing file: {file_path}") # Initialize document manager with database manager - doc_manager = DocumentManager(config.get('db_manager')) + from markitect.clean_document_manager import CleanDocumentManager + doc_manager = CleanDocumentManager(config.get('db_manager')) # Process the file result = doc_manager.ingest_file(Path(file_path)) @@ -1571,7 +1777,8 @@ def md_get_command(ctx, file_path, output): config = ctx.obj or {} try: # Initialize document manager - doc_manager = DocumentManager(config.get('db_manager')) + from markitect.clean_document_manager import CleanDocumentManager + doc_manager = CleanDocumentManager(config.get('db_manager')) # Get file information result = doc_manager.get_file(file_path) @@ -1620,7 +1827,8 @@ def md_list_command(ctx, output_format, names_only): config = ctx.obj or {} try: # Initialize document manager - doc_manager = DocumentManager(config.get('db_manager')) + from markitect.clean_document_manager import CleanDocumentManager + doc_manager = CleanDocumentManager(config.get('db_manager')) # Get file listing files = doc_manager.list_files() @@ -1654,8 +1862,8 @@ def md_list_command(ctx, output_format, names_only): @click.argument('input_file', type=click.Path(exists=True)) @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('--theme', type=ThemeType(), + help='Theme(s) to apply. Single: dark or layered: dark,academic or light,github,corporate. Available: basic, github, dark, academic, light, corporate, startup') @click.option('--css', type=click.Path(), help='Custom CSS file to include') @click.option('--edit', is_flag=True, @@ -1670,22 +1878,28 @@ def md_list_command(ctx, output_format, names_only): @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, +def md_render_command(ctx, input_file, output, theme, css, edit, editor_theme, keyboard_shortcuts, use_publication_dir, dont_use_publication_dir): """ Render a markdown file to HTML with basic templates and live preview capabilities. - Converts a markdown file to HTML using customizable templates and styles. + Converts a markdown file to HTML using customizable layered themes and styles. Supports live editing mode with real-time preview and syntax highlighting. - Choose from basic, github, dark, or academic themes for professional output. + + Theme Layering: + - Single themes: basic, github, dark, academic, light, corporate, startup + - Layered themes: dark,academic combines dark UI with academic typography + - Later themes override settings from earlier themes INPUT_FILE: Path to the markdown file to render Examples: markitect md-render README.md - markitect md-render docs/guide.md --output guide.html --template github + markitect md-render docs/guide.md --output guide.html --theme github markitect md-render draft.md --edit --editor-theme monokai - markitect md-render doc.md --template dark --css custom.css + markitect md-render doc.md --theme dark --css custom.css + markitect md-render doc.md --theme dark,academic + markitect md-render doc.md --theme light,github,corporate """ config = ctx.obj or {} @@ -1712,7 +1926,7 @@ def md_render_command(ctx, input_file, output, template, css, edit, editor_theme if edit: # Edit mode - generate HTML with editing capabilities result = doc_manager.render_file(input_file, str(output_path), - template=template, css=css, + template=theme, css=css, edit_mode=True, editor_theme=editor_theme, keyboard_shortcuts=keyboard_shortcuts) @@ -1722,16 +1936,16 @@ def md_render_command(ctx, input_file, output, template, css, edit, editor_theme if config.get('verbose', False): click.echo(f"Editor theme: {editor_theme}") click.echo(f"Keyboard shortcuts: {'enabled' if keyboard_shortcuts else 'disabled'}") - click.echo(f"Template: {template or 'default'}") + click.echo(f"Theme: {theme or 'default'}") click.echo(f"CSS: {css or 'default'}") else: # Static render result = doc_manager.render_file(input_file, str(output_path), - template=template, css=css) + template=theme, css=css) click.echo(f"✓ Rendered to: {output_path}") if config.get('verbose', False): - click.echo(f"Template: {template or 'default'}") + click.echo(f"Theme: {theme or 'default'}") click.echo(f"CSS: {css or 'default'}") except Exception as e: @@ -1743,12 +1957,12 @@ def md_render_command(ctx, input_file, output, template, css, edit, editor_theme @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('--theme', type=ThemeType(), + help='Theme(s) to apply to index. Single: dark or layered: dark,github. Available: basic, github, dark, academic, light, corporate, startup') @click.option('--recursive', '-r', is_flag=True, help='Include subdirectories recursively') @click.pass_context -def md_index_command(ctx, directory, output, template, recursive): +def md_index_command(ctx, directory, output, theme, recursive): """ Generate an index page for HTML files in a directory. @@ -1800,7 +2014,7 @@ def md_index_command(ctx, directory, output, template, recursive): index_title = f"Index - {dir_path.name}" # Generate HTML content - html_content = generate_index_html(file_info_list, index_title, template) + html_content = generate_index_html(file_info_list, index_title, theme) # Write index file output_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_issue_132_cli_integration.py b/tests/test_issue_132_cli_integration.py index 93f0d010..07b1b38b 100644 --- a/tests/test_issue_132_cli_integration.py +++ b/tests/test_issue_132_cli_integration.py @@ -93,7 +93,7 @@ markitect md-render input.md --output result.html 'md-render', str(input_file), '--output', str(output_file), - '--template', 'github' + '--theme', 'github' ]) assert result.exit_code == 0 @@ -139,7 +139,7 @@ markitect md-render input.md --output result.html assert 'markdown' in result.output.lower() assert 'html' in result.output.lower() assert '--output' in result.output - assert '--template' in result.output + assert '--theme' in result.output assert 'basic' in result.output assert 'github' in result.output assert 'dark' in result.output @@ -176,12 +176,14 @@ markitect md-render input.md --output result.html 'md-render', str(input_file), '--output', str(output_file), - '--template', 'invalid_template_name' + '--theme', 'invalid_template_name' ]) # Should exit with error code (Click choice validation) assert result.exit_code != 0 - assert 'invalid choice' in result.output.lower() or 'not one of' in result.output.lower() + assert ('invalid choice' in result.output.lower() or + 'not one of' in result.output.lower() or + 'unknown theme' in result.output.lower()) def test_output_directory_creation(self): """Test that output directory is created if it doesn't exist - Issue #132.""" diff --git a/tests/test_issue_132_template_system.py b/tests/test_issue_132_template_system.py index f155800d..a2a43da9 100644 --- a/tests/test_issue_132_template_system.py +++ b/tests/test_issue_132_template_system.py @@ -85,7 +85,7 @@ This is a test document for template system validation. 'md-render', str(input_file), '--output', str(output_file), - '--template', 'github' + '--theme', 'github' ]) assert result.exit_code == 0 @@ -262,7 +262,7 @@ This is a test document for template system validation. def test_multiple_templates_available(self): """Test that multiple template options are available - Issue #132.""" # Test template availability - template_options = ['basic', 'github', 'academic', 'dark'] + theme_options = ['basic', 'github', 'academic', 'dark'] from markitect.plugins.builtin.markdown_commands import md_render_command from click.testing import CliRunner @@ -273,13 +273,13 @@ This is a test document for template system validation. runner = CliRunner() - for template in template_options: - output_file = Path(self.temp_dir) / f"{template}_output.html" + for theme in theme_options: + output_file = Path(self.temp_dir) / f"{theme}_output.html" result = runner.invoke(md_render_command, [ str(input_file), '--output', str(output_file), - '--template', template + '--theme', theme ]) # Should be able to specify different templates @@ -304,7 +304,7 @@ This is a test document for template system validation. result = runner.invoke(md_render_command, [ str(input_file), '--output', str(output_file), - '--template', 'dark' + '--theme', 'dark' ]) assert result.exit_code == 0 @@ -332,12 +332,14 @@ This is a test document for template system validation. result = runner.invoke(cli, [ 'md-render', str(input_file), - '--template', 'nonexistent_template' + '--theme', 'nonexistent_template' ]) # Should exit with error code for invalid template choice assert result.exit_code != 0 - assert 'invalid choice' in result.output.lower() or 'not one of' in result.output.lower() + assert ('invalid choice' in result.output.lower() or + 'not one of' in result.output.lower() or + 'unknown theme' in result.output.lower()) def test_template_title_extraction_from_markdown(self): """Test title extraction from markdown for template variables - Issue #132.""" diff --git a/tests/test_issue_133_cli_integration.py b/tests/test_issue_133_cli_integration.py index 3460a03b..dde09ca1 100644 --- a/tests/test_issue_133_cli_integration.py +++ b/tests/test_issue_133_cli_integration.py @@ -94,7 +94,7 @@ Content paragraph that should be editable. 'md-render', str(input_file), '--output', str(output_file), - '--template', template, + '--theme', template, '--edit' ]) @@ -145,7 +145,7 @@ Content paragraph that should be editable. 'md-render', str(input_file), '--output', str(output_file), - '--template', 'github' + '--theme', 'github' ]) assert result.exit_code == 0 diff --git a/tests/test_issue_136_index_generation.py b/tests/test_issue_136_index_generation.py index 0739b1ed..2f325cf1 100644 --- a/tests/test_issue_136_index_generation.py +++ b/tests/test_issue_136_index_generation.py @@ -410,10 +410,10 @@ class TestCLIIntegration: assert result.returncode == 0 assert custom_output.exists() - def test_md_index_command_with_template_option(self): - """Test md-index command with template option.""" + def test_md_index_command_with_theme_option(self): + """Test md-index command with theme option.""" result = subprocess.run( - ["markitect", "md-index", str(self.test_dir), "--template", "github"], + ["markitect", "md-index", str(self.test_dir), "--theme", "github"], capture_output=True, text=True, timeout=30