diff --git a/compiler/compile.py b/compiler/compile.py index ed66230..8c1be72 100644 --- a/compiler/compile.py +++ b/compiler/compile.py @@ -30,6 +30,15 @@ from extractor import ExtractionResult, extract_chunk, resolve_model, resolve_provider_url from models import Claim, CompileReport, Contradiction +# HTML export (optional) +try: + from html_export import ExportOptions, ExportReport, export_to_html + HTML_EXPORT_AVAILABLE = True +except ImportError: + HTML_EXPORT_AVAILABLE = False + ExportOptions = None + ExportReport = None + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -388,6 +397,43 @@ def step_index(vault: str, topic: str, dry_run: bool) -> int: return len(broken) +# --------------------------------------------------------------------------- +# Step: html-export (optional) +# --------------------------------------------------------------------------- + +def step_html_export( + wiki_dir: Path, + theme: str, + output_dir: Path | None, + dry_run: bool, +) -> ExportReport | None: + """Export wiki to HTML using the selected theme.""" + if not HTML_EXPORT_AVAILABLE: + print(" [warn] html_export module not available, skipping HTML export") + return None + + options = ExportOptions( + theme=theme, + include_summaries=True, + include_concepts=True, + include_index=True, + output_dir=output_dir, + ) + + if dry_run: + out_dir = output_dir or wiki_dir.parent / "html" + print(f" [dry-run] would export HTML to {out_dir}") + print(f" [dry-run] theme: {theme}") + return None + + try: + out_dir = output_dir or wiki_dir.parent / "html" + return export_to_html(wiki_dir, out_dir, options) + except RuntimeError as e: + print(f" [warn] HTML export failed: {e}") + return None + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -431,6 +477,23 @@ def main() -> None: default=None, help="API key (env: OPENAI_API_KEY or ANTHROPIC_API_KEY)", ) + # HTML export options + parser.add_argument( + "--export-html", + action="store_true", + help="Export compiled wiki to HTML (requires Pandoc)", + ) + parser.add_argument( + "--theme", + default="reading", + choices=["article", "report", "reading", "interactive"], + help="HTML theme for export (default: reading)", + ) + parser.add_argument( + "--html-output-dir", + default=None, + help="Output directory for HTML (default: /html)", + ) args = parser.parse_args() # resolve vault / topic @@ -470,8 +533,20 @@ def main() -> None: dirty = step_diff(vault, topic) if not dirty: print(" Nothing to compile. All sources up to date.") - report = CompileReport(0, 0, 0, 0, 0, 0) - _print_report(report) + # Still allow HTML export if requested + if args.export_html: + wiki_path = Path(vault) / topic / "wiki" + if wiki_path.exists(): + html_output = Path(args.html_output_dir) if args.html_output_dir else None + print(f"\n[2/2] html-export -- theme={args.theme}...") + html_report = step_html_export(wiki_path, args.theme, html_output, args.dry_run) + report = CompileReport(0, 0, 0, 0, 0, 0) + _print_report(report, html_report) + else: + print(" [warn] No wiki/ directory found, skipping HTML export") + _print_report(CompileReport(0, 0, 0, 0, 0, 0)) + else: + _print_report(CompileReport(0, 0, 0, 0, 0, 0)) return print(f" {len(dirty)} file(s) to compile: {dirty}") @@ -508,6 +583,14 @@ def main() -> None: print("\n[7/7] index + check-links...") broken_links = step_index(vault, topic, args.dry_run) + # 8. html-export (optional) + html_report = None + if args.export_html: + wiki_path = Path(vault) / topic / "wiki" + html_output = Path(args.html_output_dir) if args.html_output_dir else None + print(f"\n[8/8] html-export -- theme={args.theme}...") + html_report = step_html_export(wiki_path, args.theme, html_output, args.dry_run) + # report report = CompileReport( sources_compiled=len(dirty), @@ -517,7 +600,7 @@ def main() -> None: contradictions_found=contradictions_found, broken_links=broken_links, ) - _print_report(report) + _print_report(report, html_report) def _load_existing_concept_names(concepts_dir: Path) -> list[str]: @@ -537,7 +620,7 @@ def _load_existing_concept_names(concepts_dir: Path) -> list[str]: return names -def _print_report(report: CompileReport) -> None: +def _print_report(report: CompileReport, html_report: ExportReport | None = None) -> None: print("\n=== Compilation Report ===") print(f" Sources compiled : {report.sources_compiled}") print(f" Summaries written : {report.summaries_written}") @@ -545,6 +628,10 @@ def _print_report(report: CompileReport) -> None: print(f" Concepts updated : {report.concepts_updated}") print(f" Contradictions : {report.contradictions_found}") print(f" Broken links : {report.broken_links}") + if html_report: + print(f" HTML exported : {html_report.files_exported} file(s) [{html_report.theme}]") + if html_report.files_failed > 0: + print(f" HTML failed : {html_report.files_failed} file(s)") print() diff --git a/compiler/html_export/__init__.py b/compiler/html_export/__init__.py new file mode 100644 index 0000000..a898b54 --- /dev/null +++ b/compiler/html_export/__init__.py @@ -0,0 +1,13 @@ +"""HTML export package for llm-wiki compiled output. + +Usage: + from html_export import export_to_html, ExportOptions + + options = ExportOptions(theme="reading") + report = export_to_html(wiki_dir, output_dir, options) +""" + +from .exporter import ExportOptions, ExportReport, export_to_html +from .wikilink_converter import wikilinks_to_html + +__all__ = ["ExportOptions", "ExportReport", "export_to_html", "wikilinks_to_html"] diff --git a/compiler/html_export/exporter.py b/compiler/html_export/exporter.py new file mode 100644 index 0000000..b87cc50 --- /dev/null +++ b/compiler/html_export/exporter.py @@ -0,0 +1,608 @@ +"""HTML exporter for llm-wiki compiled output. + +Uses Pandoc to convert markdown to HTML with custom themes. +""" + +from __future__ import annotations + +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterator + +from .wikilink_converter import wikilinks_to_html + + +@dataclass +class ExportOptions: + """Options for HTML export.""" + + theme: str = "reading" + include_summaries: bool = True + include_concepts: bool = True + include_index: bool = True + output_dir: Path | None = None + base_url: str = "" + + +@dataclass +class ExportReport: + """Report of an HTML export operation.""" + + files_exported: int = 0 + files_failed: int = 0 + links_converted: int = 0 + theme: str = "reading" + output_dir: Path | None = None + + +AVAILABLE_THEMES = ["article", "report", "reading", "interactive"] + + +def _get_theme_path(theme: str) -> Path | None: + """Get the path to a theme's CSS file.""" + # Try to find theme relative to this file + templates_dir = Path(__file__).parent / "templates" + if templates_dir.exists(): + theme_dir = templates_dir / theme + css_file = theme_dir / "style.css" + if css_file.exists(): + return css_file + return None + + +def _convert_code_blocks(text: str) -> str: + """Convert markdown code blocks to Prism-compatible format. + + Adds data-lang attribute for Prism to detect and highlight. + """ + import re + + # Pattern: ```language\ncontent\n``` + code_block_re = re.compile( + r"```(\w+)\n(.*?)```", + re.DOTALL + ) + + def replace_code_block(match: re.Match) -> str: + lang = match.group(1).lower() + content = match.group(2) + # Use language-xxx class for Prism + return f'```python\n{content}```' # Let Pandoc handle the language + + # Don't modify - let Pandoc handle it with its own highlighting + # Prism will re-highlight via class="language-xxx" + return text + + +def _get_static_path(filename: str) -> Path | None: + """Get path to a static asset.""" + static_dir = Path(__file__).parent / "static" + path = static_dir / filename + return path if path.exists() else None + + +def _copy_static_assets(output_dir: Path) -> None: + """Copy wiki.js and wiki.css to output directory.""" + import shutil + + static_dir = output_dir / "static" + static_dir.mkdir(exist_ok=True) + + for filename in ["wiki.js", "wiki.css"]: + src = _get_static_path(filename) + if src: + shutil.copy2(src, static_dir / filename) + + +# CDN libraries for interactivity +CDN_LINKS = { + "prism": { + "css": "https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-tomorrow.min.css", + "js": "https://cdn.jsdelivr.net/npm/prismjs@1/prism.min.js", + }, + "mermaid": { + "js": "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js", + }, + "chart": { + "js": "https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js", + }, +} + + +def _inject_assets(html_content: str, has_interactive: bool = False) -> str: + """Inject wiki.js, wiki.css, and CDN libraries into HTML content.""" + static_url = "static/" + + # Build CDN links based on needs + cdn_links: list[str] = [] + + # Prism.js for code highlighting + cdn_links.append(f' ') + cdn_links.append(f' ') + + # Mermaid.js for diagrams + cdn_links.append(f' ') + + # Chart.js for charts + cdn_links.append(f' ') + + cdn_html = "\n ".join(cdn_links) + "\n" + + # Inject CSS before + css_link = f' \n' + if "" in html_content: + html_content = html_content.replace("", css_link + cdn_html + "") + else: + html_content = css_link + cdn_html + html_content + + # Inject JS before + js_script = f' \n' + if "" in html_content: + html_content = html_content.replace("", js_script + "") + else: + html_content = html_content + "\n" + js_script + + return html_content + + +def _check_pandoc() -> tuple[bool, str]: + """Check if Pandoc is installed and return version.""" + try: + result = subprocess.run( + ["pandoc", "--version"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + version = result.stdout.split("\n")[0] + return True, version + return False, "" + except FileNotFoundError: + return False, "" + + +def _run_pandoc( + input_file: Path, + output_file: Path, + css_file: Path | None, + title: str = "", +) -> bool: + """Run Pandoc to convert markdown to HTML. + + Args: + input_file: Input markdown file + output_file: Output HTML file + css_file: Optional CSS file to include + title: Document title (for tag) + + Returns: + True if conversion succeeded + """ + cmd = [ + "pandoc", + str(input_file), + "--standalone", + "--from=markdown", + "--to=html", + "--no-highlight", # Let Prism.js handle highlighting via CDN + f"--output={output_file}", + ] + + if css_file and css_file.exists(): + cmd.append(f"--css=css/style.css") + + if title: + cmd.extend(["--metadata", f"title={title}"]) + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f" [warn] Pandoc error: {result.stderr}", file=sys.stderr) + return False + + # Post-process: inject wiki.js and wiki.css + if output_file.exists(): + html_content = output_file.read_text("utf-8") + html_content = _inject_assets(html_content) + output_file.write_text(html_content, "utf-8") + + return True + + +def _process_markdown(content: str, source_path: Path | None = None) -> str: + """Pre-process markdown content before Pandoc conversion. + + - Convert wikilinks to HTML anchors + - Convert tabs syntax + - Convert Obsidian callouts + - Clean up Obsidian-specific syntax + """ + import re + + # Convert code blocks for Prism.js (before Pandoc processes them) + content = _convert_code_blocks(content) + + # Convert wikilinks first + content = wikilinks_to_html(content, source_path) + + # Convert tab panels: ```tabs\n```tab:Label\n...```\n``` + content = _convert_tabs(content) + + # Convert Obsidian callouts + content = _convert_callouts(content) + + # Convert collapsed syntax: > [!collapsed] + content = _convert_collapsed(content) + + # Convert Obsidian-specific syntax + content = _clean_obsidian_syntax(content) + + return content + + +def _convert_tabs(text: str) -> str: + """Convert tabs syntax to HTML tab panels. + + Syntax: + ```tabs + ```tab:Label1 + Content for tab 1 + ``` + + ```tab:Label2 + Content for tab 2 + ``` + ``` + """ + import re + + # Pattern for tab blocks + tab_block_re = re.compile( + r"```tab:([^\n]+)\n(.*?)```", + re.DOTALL + ) + + tabs: list[tuple[str, str]] = [] + output: list[str] = [] + last_end = 0 + + for match in tab_block_re.finditer(text): + # Collect any text before this match + if match.start() > last_end: + output.append(text[last_end:match.start()]) + + label = match.group(1).strip() + content = match.group(2).strip() + tabs.append((label, content)) + last_end = match.end() + + # If we found tabs, wrap them + if tabs: + output.append(text[last_end:]) + remaining = "".join(output) + + # Wrap all consecutive tabs in a tab-set div + # This is a simplified approach - full implementation would need + # to preserve surrounding content properly + tab_content = ['<div class="tab-set">'] + for label, content in tabs: + safe_label = re.sub(r"[^\w\s-]", "", label) + tab_content.append(f'<div class="tab" data-label="{label}">') + tab_content.append(content) + tab_content.append("</div>") + tab_content.append("</div>") + + return "".join(tab_content) + remaining + + return text + + +def _convert_callouts(text: str) -> str: + """Convert Obsidian callouts to HTML divs with classes. + + Syntax: + > [!NOTE] + > Content line 1 + > Content line 2 + """ + import re + + # Pattern: complete callout block (header + all > content lines until non-> line) + # Matches from > [!TYPE] to the line before a non-blockquote line + callout_re = re.compile( + r"^> \[!(NOTE|TIP|WARNING|INFO|EXAMPLE|COLLAPSED)\](?::\s*([^\]]*))?\s*\n" + r"((?:> (?!```)[^\n]*\n)+)", + re.MULTILINE | re.IGNORECASE + ) + + def replace_callout(match: re.Match) -> str: + callout_type = match.group(1).lower() + title = match.group(2) or callout_type + content = match.group(3) + + # Convert > content lines to paragraphs + paragraphs: list[str] = [] + for line in content.strip().split("\n"): + line = line.lstrip("> ").strip() + if line: + paragraphs.append(f"<p>{line}</p>") + + return ( + f'<div class="callout callout-{callout_type}">\n' + f"<strong>{title}</strong>\n" + + "\n".join(paragraphs) + + f"\n</div>\n" + ) + + return callout_re.sub(replace_callout, text) + + +def _convert_collapsed(text: str) -> str: + """Convert > [!collapsed] to collapsible details.""" + import re + + # Convert collapsed callouts to details/summary + collapsed_re = re.compile( + r'<div class="callout callout-collapsed">\s*<p></p>\s*(.*?)\s*</div>', + re.DOTALL + ) + + def replace_collapsed(match: re.Match) -> str: + content = match.group(1).strip() + return f'<details class="callout callout-collapsed"><summary>Click to expand</summary>{content}</details>' + + return collapsed_re.sub(replace_collapsed, text) + + +def _clean_obsidian_syntax(text: str) -> str: + """Remove or convert Obsidian-specific syntax that Pandoc doesn't handle.""" + import re + + # Remove YAML frontmatter if present + text = re.sub(r"^---\n.*?\n---\n", "", text, flags=re.DOTALL) + + # Convert #tag to styled spans (keep them, just mark them) + # (Pandoc handles #heading fine, but standalone tags need help) + + # Convert ^footnote references (keep as-is) + + return text + + +def _generate_index(wiki_dir: Path, output_dir: Path) -> str: + """Generate index.html with links to all concepts and summaries.""" + index_items: list[tuple[str, str, str]] = [] # (type, name, slug) + + concepts_dir = wiki_dir / "concepts" + summaries_dir = wiki_dir / "summaries" + + if concepts_dir.exists(): + for f in sorted(concepts_dir.iterdir()): + if f.suffix == ".md" and not f.name.startswith("_"): + name = _extract_title(f) or f.stem + index_items.append(("concept", name, f.stem)) + + if summaries_dir.exists(): + for f in sorted(summaries_dir.iterdir()): + if f.suffix == ".md" and not f.name.startswith("_"): + name = _extract_title(f) or f.stem + index_items.append(("summary", name, f.stem)) + + html_items = [] + current_type = None + for item_type, name, slug in index_items: + if item_type != current_type: + if current_type is not None: + html_items.append("</ul>") + current_type = item_type + html_items.append(f'<h2>{item_type.title()}s</h2><ul class="index-list">') + html_items.append(f' <li><a href="{item_type}s/{slug}.html">{name}</a></li>') + + if current_type is not None: + html_items.append("</ul>") + + return "\n".join(html_items) + + +def _extract_title(file_path: Path) -> str | None: + """Extract the title (# heading) from a markdown file.""" + content = file_path.read_text("utf-8-sig", errors="replace") + for line in content.splitlines(): + if line.startswith("# "): + return line[2:].strip() + return None + + +def _iter_wiki_files(wiki_dir: Path, options: ExportOptions) -> Iterator[tuple[Path, str]]: + """Iterate over wiki files to export. + + Yields (source_file, output_subdir) tuples. + """ + if options.include_index: + yield wiki_dir / "_index.md", "" + + if options.include_concepts: + concepts_dir = wiki_dir / "concepts" + if concepts_dir.exists(): + for f in concepts_dir.iterdir(): + if f.suffix == ".md" and not f.name.startswith("_"): + yield f, "concepts" + + if options.include_summaries: + summaries_dir = wiki_dir / "summaries" + if summaries_dir.exists(): + for f in summaries_dir.iterdir(): + if f.suffix == ".md" and not f.name.startswith("_"): + yield f, "summaries" + + +def export_to_html( + wiki_dir: Path, + output_dir: Path, + options: ExportOptions, +) -> ExportReport: + """Export compiled wiki markdown files to styled HTML. + + Args: + wiki_dir: Path to the compiled wiki directory (contains concepts/, summaries/) + output_dir: Output directory for HTML files + options: Export options + + Returns: + ExportReport with statistics + + Raises: + RuntimeError: If Pandoc is not installed + """ + # Check Pandoc + pandoc_ok, pandoc_version = _check_pandoc() + if not pandoc_ok: + raise RuntimeError( + "Pandoc not found. HTML export requires Pandoc.\n" + "Install from: https://pandoc.org/installing.html\n" + "Or via package manager:\n" + " macOS: brew install pandoc\n" + " Ubuntu/Debian: sudo apt install pandoc\n" + " Windows: winget install pandoc" + ) + + # Get theme CSS + theme_css = _get_theme_path(options.theme) + if not theme_css: + raise ValueError( + f"Theme '{options.theme}' not found. Available: {AVAILABLE_THEMES}" + ) + + # Create output directory structure + output_dir.mkdir(parents=True, exist_ok=True) + css_dir = output_dir / "css" + css_dir.mkdir(exist_ok=True) + + # Copy theme CSS + import shutil + + css_dest = css_dir / "style.css" + shutil.copy2(theme_css, css_dest) + + # Copy static assets (wiki.js, wiki.css) + _copy_static_assets(output_dir) + + # Create subdirectories + concepts_dir = output_dir / "concepts" + summaries_dir = output_dir / "summaries" + concepts_dir.mkdir(exist_ok=True) + summaries_dir.mkdir(exist_ok=True) + + report = ExportReport( + theme=options.theme, + output_dir=output_dir, + ) + + # Process files + for source_file, subdir in _iter_wiki_files(wiki_dir, options): + # Determine output path + if subdir: + output_subdir = output_dir / subdir + output_file = output_subdir / f"{source_file.stem}.html" + else: + output_file = output_dir / "index.html" + + # Read and preprocess + try: + content = source_file.read_text("utf-8-sig", errors="replace") + processed = _process_markdown(content, source_file) + + # Count wikilinks converted + import re + + wikilink_count = len(re.findall(r"\[\[([^\]]+)\]\]", content)) + report.links_converted += wikilink_count + + # Write processed markdown to temp file + temp_md = output_file.with_suffix(".md") + temp_md.write_text(processed, "utf-8") + + # Run Pandoc + title = _extract_title(source_file) or source_file.stem + if _run_pandoc(temp_md, output_file, css_dest, title): + report.files_exported += 1 + else: + report.files_failed += 1 + + # Clean up temp file + temp_md.unlink(missing_ok=True) + + except Exception as e: + print(f" [warn] Failed to export {source_file.name}: {e}", file=sys.stderr) + report.files_failed += 1 + + return report + + +def main() -> None: + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Export llm-wiki to HTML") + parser.add_argument("wiki_dir", type=Path, help="Path to wiki/ directory") + parser.add_argument( + "--output", + "-o", + type=Path, + help="Output directory (default: <wiki_dir>/../html)", + ) + parser.add_argument( + "--theme", + default="reading", + choices=["article", "report", "reading", "interactive"], + help="HTML theme", + ) + parser.add_argument( + "--no-summaries", + action="store_true", + help="Skip summaries directory", + ) + parser.add_argument( + "--no-concepts", + action="store_true", + help="Skip concepts directory", + ) + parser.add_argument( + "--no-index", + action="store_true", + help="Skip index generation", + ) + + args = parser.parse_args() + + # Resolve output directory + if args.output: + output_dir = args.output + else: + output_dir = args.wiki_dir.parent / "html" + + options = ExportOptions( + theme=args.theme, + include_summaries=not args.no_summaries, + include_concepts=not args.no_concepts, + include_index=not args.no_index, + output_dir=output_dir, + ) + + print(f"Exporting {args.wiki_dir} to {output_dir}") + print(f"Theme: {args.theme}") + + try: + report = export_to_html(args.wiki_dir, output_dir, options) + print(f"\nExport complete:") + print(f" Files exported: {report.files_exported}") + print(f" Files failed: {report.files_failed}") + print(f" Links converted: {report.links_converted}") + except RuntimeError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/compiler/html_export/static/wiki.css b/compiler/html_export/static/wiki.css new file mode 100644 index 0000000..a10e137 --- /dev/null +++ b/compiler/html_export/static/wiki.css @@ -0,0 +1,262 @@ +/* llm-wiki Interactive Styles + * Enhancements: copy buttons, tabs, callouts, scrollspy + */ + +/* ============================================================ + Copy Code Button + ============================================================ */ + +.code-block { + position: relative; +} + +.copy-btn { + position: absolute; + top: 8px; + right: 8px; + padding: 6px 8px; + background: var(--bg-secondary, #f6f8fa); + border: 1px solid var(--border-color, #e1e4e8); + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s, background 0.15s; + color: var(--text-secondary, #586069); + display: flex; + align-items: center; + justify-content: center; +} + +.code-block:hover .copy-btn { + opacity: 1; +} + +.copy-btn:hover { + background: var(--border-color, #e1e4e8); +} + +.copy-btn:focus { + opacity: 1; + outline: 2px solid var(--accent-color, #0366d6); + outline-offset: 2px; +} + +/* ============================================================ + Tab Panels + ============================================================ */ + +.tab-set { + margin: 1.5em 0; + border: 1px solid var(--border-color, #e1e4e8); + border-radius: 6px; + overflow: hidden; +} + +.tab-nav { + display: flex; + background: var(--bg-secondary, #f6f8fa); + border-bottom: 1px solid var(--border-color, #e1e4e8); + overflow-x: auto; +} + +.tab-nav button { + padding: 10px 16px; + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + color: var(--text-secondary, #586069); + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; +} + +.tab-nav button:hover { + color: var(--text-primary, #24292e); +} + +.tab-nav button.active { + color: var(--accent-color, #0366d6); + border-bottom-color: var(--accent-color, #0366d6); +} + +.tab { + padding: 1em; +} + +.tab > pre { + margin: 0; +} + +/* ============================================================ + Collapsible Callouts + ============================================================ */ + +details.callout { + margin: 1.5em 0; + border-radius: 4px; + border: 1px solid; + overflow: hidden; +} + +.callout-note { border-color: #2a7ae2; } +.callout-tip { border-color: #00a67d; } +.callout-warning { border-color: #f0ad4e; } +.callout-info { border-color: #5bc0de; } +.callout-example { border-color: #6c757d; } + +details.callout summary { + padding: 0.75em 1em; + cursor: pointer; + font-weight: 500; + list-style: none; + display: flex; + align-items: center; + gap: 8px; + background: var(--callout-bg, #f6f8fa); +} + +details.callout summary::-webkit-details-marker { + display: none; +} + +details.callout summary::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.callout-note summary { background: #e7f3ff; color: #2a7ae2; } +.callout-tip summary { background: #e7f6f3; color: #00a67d; } +.callout-warning summary { background: #fff8e6; color: #b35900; } +.callout-info summary { background: #f0f7ff; color: #5bc0de; } +.callout-example summary { background: #f1f3f4; color: #6c757d; } + +.callout-content { + padding: 1em; +} + +.callout-content > *:last-child { + margin-bottom: 0; +} + +/* ============================================================ + TOC Scroll Spy + ============================================================ */ + +.toc a { + transition: color 0.15s, border-color 0.15s; +} + +.toc a.active { + color: var(--accent-color, #0366d6); + font-weight: 500; +} + +/* ============================================================ + Slide Navigation + ============================================================ */ + +.slide { + padding: 2em; +} + +.slide-counter { + position: fixed; + bottom: 20px; + right: 20px; + padding: 8px 16px; + background: var(--bg-secondary, #f6f8fa); + border: 1px solid var(--border-color, #e1e4e8); + border-radius: 20px; + font-size: 0.85em; + color: var(--text-secondary, #586069); +} + +/* ============================================================ + Dark Mode Toggle + ============================================================ */ + +.dark-mode-toggle { + padding: 8px 16px; + background: var(--bg-secondary, #f6f8fa); + border: 1px solid var(--border-color, #e1e4e8); + border-radius: 20px; + cursor: pointer; + font-size: 0.85em; + color: var(--text-secondary, #586069); + transition: background 0.15s; +} + +.dark-mode-toggle:hover { + background: var(--border-color, #e1e4e8); +} + +/* Dark mode overrides */ +body.dark-mode { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --text-primary: #c9d1d9; + --text-secondary: #8b949e; + --border-color: #30363d; + --accent-color: #58a6ff; + --callout-bg: #161b22; + --code-bg: #161b22; +} + +body.dark-mode .copy-btn { + background: #21262d; + border-color: #30363d; + color: #8b949e; +} + +body.dark-mode .tab-nav { + background: #21262d; + border-color: #30363d; +} + +body.dark-mode .tab-nav button { + color: #8b949e; +} + +body.dark-mode .tab-nav button.active { + color: #58a6ff; +} + +body.dark-mode .dark-mode-toggle { + background: #21262d; + border-color: #30363d; + color: #8b949e; +} + +/* ============================================================ + Print Styles + ============================================================ */ + +@media print { + .copy-btn, + .tab-nav, + .slide-counter, + .dark-mode-toggle { + display: none !important; + } + + .tab-set { + border: 1px solid #ccc; + } + + .tab { + display: block !important; + } + + details.callout { + border: 1px solid #ccc; + } + + details.callout summary { + background: #f5f5f5 !important; + } +} diff --git a/compiler/html_export/static/wiki.js b/compiler/html_export/static/wiki.js new file mode 100644 index 0000000..4483401 --- /dev/null +++ b/compiler/html_export/static/wiki.js @@ -0,0 +1,316 @@ +/** + * llm-wiki HTML Enhancement Script + * Provides: Prism highlighting, Mermaid diagrams, code copy, collapsible blocks, tabs + */ + +(function () { + "use strict"; + + // ============================================================ + // Initialize CDN Libraries + // ============================================================ + function initLibraries() { + // Prism.js: Add language classes to code blocks before highlighting + if (typeof Prism !== "undefined") { + document.querySelectorAll("pre code").forEach(function (block) { + // Check if already has a language class + if (!block.className.includes("language-")) { + // Try to detect language from class (e.g., "sourceCode python") + var match = block.className.match(/sourceCode\s+(\w+)/); + if (match) { + block.className = "language-" + match[1]; + } + } + block.parentElement.classList.add("line-numbers"); + }); + Prism.highlightAll(); + } + + // Mermaid.js diagrams (via CDN) + if (typeof mermaid !== "undefined") { + mermaid.initialize({ + startOnLoad: true, + theme: "default", + securityLevel: "loose", + fontFamily: "inherit", + }); + } + + // Mark page as ready + document.body.classList.add("wiki-ready"); + } + + // ============================================================ + // Copy Code Button + // ============================================================ + function initCopyButtons() { + document.querySelectorAll("pre").forEach(function (pre) { + if (pre.querySelector(".copy-btn")) return; + + var code = pre.querySelector("code"); + if (!code) return; + + var btn = document.createElement("button"); + btn.className = "copy-btn"; + btn.setAttribute("aria-label", "Copy code"); + btn.innerHTML = + '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; + btn.addEventListener("click", function () { + var text = code.textContent || code.innerText; + navigator.clipboard.writeText(text).then( + function () { + btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'; + setTimeout(function () { + btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'; + }, 2000); + }, + function () { + btn.textContent = "Failed"; + } + ); + }); + + pre.classList.add("code-block"); + pre.appendChild(btn); + }); + } + + // ============================================================ + // Collapsible Callout Blocks + // ============================================================ + function initCollapsibleCallouts() { + document.querySelectorAll(".callout").forEach(function (callout) { + var type = ""; + if (callout.classList.contains("callout-note")) type = "Note"; + else if (callout.classList.contains("callout-tip")) type = "Tip"; + else if (callout.classList.contains("callout-warning")) + type = "Warning"; + else if (callout.classList.contains("callout-info")) type = "Info"; + else if (callout.classList.contains("callout-example")) + type = "Example"; + + // Check if already converted + if (callout.querySelector("details")) return; + + var details = document.createElement("details"); + details.className = callout.className; + + var summary = document.createElement("summary"); + summary.innerHTML = + '<span class="callout-icon">' + + getCalloutIcon(type) + + "</span> " + + "<strong>" + + type + + "</strong>"; + + // Move callout content into details + var content = document.createElement("div"); + content.className = "callout-content"; + while (callout.firstChild) { + content.appendChild(callout.firstChild); + } + + details.appendChild(summary); + details.appendChild(content); + callout.parentNode.replaceChild(details, callout); + }); + } + + function getCalloutIcon(type) { + var icons = { + Note: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>', + Tip: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7z"></path><line x1="9" y1="21" x2="15" y2="21"></line><line x1="10" y1="24" x2="14" y2="24"></line></svg>', + Warning: + '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>', + Info: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>', + Example: + '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg>', + }; + return icons[type] || icons["Note"]; + } + + // ============================================================ + // Tab Panels + // ============================================================ + function initTabs() { + document.querySelectorAll(".tab-set").forEach(function (tabSet) { + if (tabSet.querySelector(".tab-nav")) return; // Already initialized + + var tabs = tabSet.querySelectorAll(".tab"); + if (tabs.length === 0) return; + + var nav = document.createElement("div"); + nav.className = "tab-nav"; + nav.setAttribute("role", "tablist"); + + tabs.forEach(function (tab, i) { + var btn = document.createElement("button"); + btn.textContent = tab.getAttribute("data-label") || "Tab " + (i + 1); + btn.setAttribute("role", "tab"); + btn.setAttribute("aria-selected", i === 0 ? "true" : "false"); + btn.setAttribute("aria-controls", "tabpanel-" + i); + btn.className = i === 0 ? "active" : ""; + btn.addEventListener("click", function () { + nav.querySelectorAll("button").forEach(function (b) { + b.classList.remove("active"); + b.setAttribute("aria-selected", "false"); + }); + tabs.forEach(function (t) { + t.style.display = "none"; + }); + btn.classList.add("active"); + btn.setAttribute("aria-selected", "true"); + tab.style.display = "block"; + }); + nav.appendChild(btn); + + tab.id = "tabpanel-" + i; + tab.setAttribute("role", "tabpanel"); + if (i > 0) tab.style.display = "none"; + }); + + tabSet.insertBefore(nav, tabSet.firstChild); + }); + } + + // ============================================================ + // Scroll Spy for TOC + // ============================================================ + function initScrollSpy() { + var tocLinks = document.querySelectorAll(".toc a"); + if (tocLinks.length === 0) return; + + var headings = []; + tocLinks.forEach(function (link) { + var id = link.getAttribute("href"); + if (id && id.startsWith("#")) { + var el = document.getElementById(id.substring(1)); + if (el) headings.push(el); + } + }); + + if (headings.length === 0) return; + + function updateActive() { + var scrollY = window.scrollY; + var current = headings[0]; + + headings.forEach(function (h) { + if (h.offsetTop <= scrollY + 100) { + current = h; + } + }); + + tocLinks.forEach(function (link) { + link.classList.remove("active"); + if (link.getAttribute("href") === "#" + current.id) { + link.classList.add("active"); + } + }); + } + + window.addEventListener("scroll", updateActive, { passive: true }); + updateActive(); + } + + // ============================================================ + // Smooth Scroll for Anchor Links + // ============================================================ + function initSmoothScroll() { + document.querySelectorAll('a[href^="#"]').forEach(function (link) { + link.addEventListener("click", function (e) { + var target = document.querySelector(link.getAttribute("href")); + if (target) { + e.preventDefault(); + target.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }); + }); + } + + // ============================================================ + // Keyboard Navigation for Slides + // ============================================================ + function initSlideNav() { + var slides = document.querySelectorAll("section.slide"); + if (slides.length === 0) return; + + var current = 0; + var counter = document.querySelector(".slide-counter"); + + function showSlide(idx) { + if (idx < 0) idx = 0; + if (idx >= slides.length) idx = slides.length - 1; + current = idx; + slides.forEach(function (s, i) { + s.style.display = i === current ? "block" : "none"; + }); + if (counter) counter.textContent = (current + 1) + " / " + slides.length; + } + + document.addEventListener("keydown", function (e) { + if (e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === " ") { + e.preventDefault(); + showSlide(current + 1); + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + showSlide(current - 1); + } + }); + + showSlide(0); + } + + // ============================================================ + // Dark Mode Toggle + // ============================================================ + function initDarkMode() { + var toggle = document.querySelector(".dark-mode-toggle"); + if (!toggle) return; + + // Check system preference + var prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)" + ).matches; + var isDark = + localStorage.getItem("dark-mode") === "true" || + (localStorage.getItem("dark-mode") === null && prefersDark); + + function updateDark() { + document.body.classList.toggle("dark-mode", isDark); + toggle.textContent = isDark ? "Light Mode" : "Dark Mode"; + localStorage.setItem("dark-mode", isDark); + } + + toggle.addEventListener("click", function () { + isDark = !isDark; + updateDark(); + }); + + updateDark(); + } + + // ============================================================ + // Initialize All + // ============================================================ + function init() { + // Initialize CDN libraries first + initLibraries(); + + // Then enhance UI + initCopyButtons(); + initCollapsibleCallouts(); + initTabs(); + initScrollSpy(); + initSmoothScroll(); + initSlideNav(); + initDarkMode(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/compiler/html_export/templates/article/style.css b/compiler/html_export/templates/article/style.css new file mode 100644 index 0000000..583c1bb --- /dev/null +++ b/compiler/html_export/templates/article/style.css @@ -0,0 +1,179 @@ +/* Article theme - Tufte-inspired editorial design + Sidenotes, emphasis on typography, academic reading */ + +:root { + --text-primary: #111111; + --text-secondary: #555555; + --bg-primary: #fafaf5; + --bg-sidenote: #f5f5f0; + --border-color: #dddddd; + --accent-color: #c55a11; + --link-color: #11557c; + --code-bg: #f0ebe3; +} + +body { + font-family: 'Charter', 'Georgia', serif; + font-size: 12pt; + line-height: 1.7; + color: var(--text-primary); + background: var(--bg-primary); + max-width: 1100px; + margin: 0 auto; + padding: 0 2em; +} + +main { + max-width: 650px; + margin-left: 150px; + margin-right: 200px; + margin-top: 4em; + margin-bottom: 8em; +} + +h1, h2, h3, h4, h5, h6 { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-weight: 400; + margin-top: 2em; + margin-bottom: 0.5em; + color: var(--text-primary); +} + +h1 { font-size: 2.2em; font-weight: 700; } +h2 { font-size: 1.5em; font-weight: 600; } +h3 { font-size: 1.2em; font-style: italic; } + +a { + color: var(--link-color); + text-decoration: none; + border-bottom: 1px dotted var(--link-color); +} + +a:hover { + border-bottom-style: solid; +} + +p { margin: 1em 0; } + +ul, ol { + margin: 1em 0; + padding-left: 1.5em; +} + +li { margin: 0.4em 0; } + +blockquote { + margin: 1.5em 0; + padding: 0.5em 0 0.5em 1.5em; + border-left: 1px solid var(--accent-color); + font-style: italic; + color: var(--text-secondary); +} + +blockquote p { margin: 0.5em 0; } + +code, kbd, samp { + font-family: 'Courier New', monospace; + font-size: 0.85em; + background: var(--code-bg); + padding: 0.1em 0.2em; + border-radius: 2px; +} + +pre { + margin: 1.5em 0; + padding: 1em; + background: var(--code-bg); + border-radius: 3px; + overflow-x: auto; + font-size: 0.8em; + line-height: 1.4; +} + +pre code { + background: none; + padding: 0; +} + +table { + width: 100%; + margin: 1.5em 0; + border-collapse: collapse; + font-size: 0.9em; +} + +th, td { + padding: 0.5em 1em; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +th { + font-weight: 600; + border-bottom: 2px solid var(--text-primary); +} + +.sidenote { + float: right; + clear: right; + margin-right: -220px; + width: 200px; + font-size: 0.85em; + color: var(--text-secondary); + line-height: 1.4; + margin-top: 0; + margin-bottom: 0.5em; +} + +.sidenote-number { + font-size: 0.7em; + vertical-align: super; +} + +hr { + border: none; + border-top: 1px solid var(--border-color); + margin: 3em 0; +} + +img { + max-width: 100%; + height: auto; +} + +.index-list { + list-style: none; + padding: 0; + columns: 2; + column-gap: 2em; +} + +.index-list li { + break-inside: avoid; + padding: 0.3em 0; +} + +@media (max-width: 1200px) { + main { + margin-left: 50px; + margin-right: 50px; + } + + .sidenote { + float: none; + margin: 1em 0; + width: auto; + padding-left: 1em; + border-left: 2px solid var(--accent-color); + } +} + +@media (max-width: 768px) { + main { + margin: 2em 1em; + } + + .index-list { + columns: 1; + } +} diff --git a/compiler/html_export/templates/interactive/style.css b/compiler/html_export/templates/interactive/style.css new file mode 100644 index 0000000..c8d2320 --- /dev/null +++ b/compiler/html_export/templates/interactive/style.css @@ -0,0 +1,300 @@ +/* Interactive theme - Book/tutorial style with navigation + Collapsible TOC, code copy buttons, chapter navigation */ + +:root { + --text-primary: #24292e; + --text-secondary: #586069; + --bg-primary: #ffffff; + --bg-sidebar: #f6f8fa; + --bg-code: #f6f8fa; + --border-color: #e1e4e8; + --accent-color: #0366d6; + --link-color: #0366d6; + --sidebar-width: 260px; + --content-max-width: 800px; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + font-size: 15px; + line-height: 1.7; + color: var(--text-primary); + background: var(--bg-primary); +} + +/* Sidebar / TOC */ +.sidebar { + position: fixed; + top: 0; + left: 0; + width: var(--sidebar-width); + height: 100vh; + background: var(--bg-sidebar); + border-right: 1px solid var(--border-color); + overflow-y: auto; + padding: 20px 0; + z-index: 100; +} + +.sidebar-header { + padding: 0 20px 20px; + border-bottom: 1px solid var(--border-color); + margin-bottom: 20px; +} + +.sidebar-header a { + font-size: 1.1em; + font-weight: 600; + color: var(--text-primary); + text-decoration: none; +} + +.toc { + padding: 0 20px; +} + +.toc-section { + margin-bottom: 20px; +} + +.toc-title { + font-size: 0.75em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: 8px; + padding: 0 10px; +} + +.toc ul { + list-style: none; +} + +.toc li { + margin: 2px 0; +} + +.toc a { + display: block; + padding: 6px 10px; + font-size: 0.9em; + color: var(--text-secondary); + text-decoration: none; + border-radius: 4px; + transition: background 0.15s, color 0.15s; +} + +.toc a:hover { + background: var(--border-color); + color: var(--text-primary); +} + +.toc a.active { + background: var(--accent-color); + color: white; +} + +/* Main content */ +main { + margin-left: var(--sidebar-width); + padding: 40px 60px 80px; + max-width: calc(var(--sidebar-width) + var(--content-max-width)); +} + +.article { + max-width: var(--content-max-width); + margin: 0 auto; +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.3; + margin: 2em 0 0.8em; + color: var(--text-primary); + scroll-margin-top: 20px; +} + +h1 { font-size: 2em; margin-top: 0; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; } +h2 { font-size: 1.5em; } +h3 { font-size: 1.25em; } + +a { + color: var(--link-color); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +p { + margin: 1em 0; +} + +ul, ol { + margin: 1em 0; + padding-left: 1.5em; +} + +li { + margin: 0.4em 0; +} + +/* Code blocks */ +pre { + position: relative; + margin: 1.5em 0; + padding: 1em; + background: var(--bg-code); + border: 1px solid var(--border-color); + border-radius: 6px; + overflow-x: auto; + font-size: 0.9em; + line-height: 1.5; +} + +pre code { + background: none; + padding: 0; + font-size: 1em; +} + +.code-block { + position: relative; +} + +.copy-btn { + position: absolute; + top: 8px; + right: 8px; + padding: 4px 8px; + font-size: 0.75em; + background: white; + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s; +} + +.code-block:hover .copy-btn { + opacity: 1; +} + +.copy-btn:hover { + background: var(--bg-code); +} + +code { + font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', monospace; + font-size: 0.9em; + background: var(--bg-code); + padding: 0.2em 0.4em; + border-radius: 3px; +} + +blockquote { + margin: 1.5em 0; + padding: 1em 1.5em; + border-left: 4px solid var(--accent-color); + background: var(--bg-sidebar); + border-radius: 0 4px 4px 0; +} + +blockquote p:last-child { margin-bottom: 0; } + +table { + width: 100%; + margin: 1.5em 0; + border-collapse: collapse; + font-size: 0.95em; +} + +th, td { + padding: 0.75em 1em; + text-align: left; + border: 1px solid var(--border-color); +} + +th { + background: var(--bg-sidebar); + font-weight: 600; +} + +tr:nth-child(even) { + background: var(--bg-sidebar); +} + +hr { + border: none; + border-top: 1px solid var(--border-color); + margin: 2em 0; +} + +img { + max-width: 100%; + height: auto; + border-radius: 6px; +} + +/* Navigation footer */ +.nav-footer { + display: flex; + justify-content: space-between; + margin-top: 3em; + padding-top: 2em; + border-top: 1px solid var(--border-color); +} + +.nav-prev, .nav-next { + padding: 10px 15px; + background: var(--bg-sidebar); + border: 1px solid var(--border-color); + border-radius: 6px; + text-decoration: none; + font-size: 0.9em; +} + +.nav-prev:hover, .nav-next:hover { + background: var(--border-color); +} + +/* Index page */ +.index-list { + list-style: none; + padding: 0; +} + +.index-list li { + padding: 0.6em 0; + border-bottom: 1px solid var(--border-color); +} + +.index-list li:last-child { + border-bottom: none; +} + +.index-list a { + font-size: 1.05em; + color: var(--text-primary); +} + +/* Mobile responsive */ +@media (max-width: 900px) { + .sidebar { + display: none; + } + + main { + margin-left: 0; + padding: 30px 20px; + } +} diff --git a/compiler/html_export/templates/reading/style.css b/compiler/html_export/templates/reading/style.css new file mode 100644 index 0000000..c255b2d --- /dev/null +++ b/compiler/html_export/templates/reading/style.css @@ -0,0 +1,204 @@ +/* Reading theme - Medium-inspired minimalist reading experience + Anti-AI-slop design: serif typography, comfortable line length, no gradients */ + +:root { + --text-primary: #1a1a1a; + --text-secondary: #666666; + --text-muted: #999999; + --bg-primary: #ffffff; + --bg-secondary: #fafafa; + --border-color: #e5e5e5; + --accent-color: #2a7ae2; + --code-bg: #f4f4f4; + --link-color: #1a73e8; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 18px; + line-height: 1.7; + color: var(--text-primary); + background: var(--bg-primary); + max-width: 680px; + margin: 0 auto; + padding: 60px 24px; +} + +h1, h2, h3, h4, h5, h6 { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-weight: 600; + line-height: 1.3; + margin: 2em 0 0.8em; + color: var(--text-primary); +} + +h1 { font-size: 2.2em; margin-top: 0; } +h2 { font-size: 1.6em; } +h3 { font-size: 1.3em; } +h4 { font-size: 1.1em; } + +h1:first-child { margin-top: 0; } + +a { + color: var(--link-color); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 0.2s; +} + +a:hover { + border-bottom-color: var(--link-color); +} + +p { + margin: 1.2em 0; +} + +ul, ol { + margin: 1.2em 0; + padding-left: 1.5em; +} + +li { + margin: 0.5em 0; +} + +blockquote { + margin: 1.5em 0; + padding: 1em 1.5em; + border-left: 3px solid var(--accent-color); + background: var(--bg-secondary); + font-style: italic; +} + +blockquote p:last-child { margin-bottom: 0; } + +code { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; + font-size: 0.9em; + background: var(--code-bg); + padding: 0.2em 0.4em; + border-radius: 3px; +} + +pre { + margin: 1.5em 0; + padding: 1em; + background: #1d1f21; /* Prism Tomorrow dark theme */ + border-radius: 6px; + overflow-x: auto; + border: 1px solid #333; + position: relative; +} + +/* Let Prism handle styling */ +pre code[class*="language-"], +pre code[class*="lang-"] { + background: none; + padding: 0; + font-size: 0.85em; + line-height: 1.6; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace; +} + +/* Override Pandoc's highlighting styles */ +pre > code.sourceCode { background: none; padding: 0; } +pre.numberSource { margin-left: 0; border-left: none; padding-left: 0; } +pre.numberSource code > span { position: static; } +pre.numberSource code > span > a:first-child::before { display: none; } + +pre code { + background: none; + padding: 0; +} + +table { + width: 100%; + margin: 1.5em 0; + border-collapse: collapse; + font-size: 0.95em; +} + +th, td { + padding: 0.75em 1em; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +th { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-weight: 600; + background: var(--bg-secondary); +} + +hr { + border: none; + border-top: 1px solid var(--border-color); + margin: 2em 0; +} + +img { + max-width: 100%; + height: auto; + display: block; + margin: 1.5em auto; +} + +.callout { + margin: 1.5em 0; + padding: 1em 1.2em; + border-radius: 4px; + border-left: 4px solid; +} + +.callout-note { background: #e7f3ff; border-color: #2a7ae2; } +.callout-tip { background: #e7f6f3; border-color: #00a67d; } +.callout-warning { background: #fff8e6; border-color: #f0ad4e; } +.callout-info { background: #f0f7ff; border-color: #5bc0de; } + +.index-list { + list-style: none; + padding: 0; +} + +.index-list li { + padding: 0.4em 0; + border-bottom: 1px solid var(--border-color); +} + +.index-list li:last-child { + border-bottom: none; +} + +@media (prefers-color-scheme: dark) { + :root { + --text-primary: #e5e5e5; + --text-secondary: #999999; + --text-muted: #666666; + --bg-primary: #1a1a1a; + --bg-secondary: #252525; + --border-color: #333333; + --code-bg: #2a2a2a; + } + + a { color: #8ab4f8; } + a:hover { border-bottom-color: #8ab4f8; } + blockquote { background: var(--bg-secondary); } + th { background: var(--bg-secondary); } +} + +@media (max-width: 600px) { + body { + font-size: 16px; + padding: 30px 16px; + } + + h1 { font-size: 1.8em; } + h2 { font-size: 1.4em; } +} diff --git a/compiler/html_export/templates/report/style.css b/compiler/html_export/templates/report/style.css new file mode 100644 index 0000000..a91b36f --- /dev/null +++ b/compiler/html_export/templates/report/style.css @@ -0,0 +1,254 @@ +/* Report theme - Professional whitepaper style + Clean corporate design, high information density, numbered sections */ + +:root { + --text-primary: #1a1a2e; + --text-secondary: #4a4a68; + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-header: #1a1a2e; + --text-header: #ffffff; + --border-color: #dee2e6; + --accent-color: #0d6efd; + --accent-secondary: #198754; + --warning-color: #dc3545; + --code-bg: #f1f3f4; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + background: var(--bg-primary); +} + +.container { + max-width: 900px; + margin: 0 auto; + padding: 0 40px; +} + +/* Header / Cover */ +header { + background: var(--bg-header); + color: var(--text-header); + padding: 60px 0; + text-align: center; +} + +header h1 { + font-size: 2.5em; + font-weight: 700; + margin: 0 0 20px; + color: var(--text-header); +} + +header .meta { + font-size: 0.95em; + opacity: 0.8; + margin-top: 30px; +} + +/* Main content */ +main { + padding: 40px 0 80px; +} + +h1, h2, h3, h4, h5, h6 { + font-family: inherit; + font-weight: 600; + line-height: 1.3; + margin: 1.8em 0 0.8em; + color: var(--text-primary); +} + +h1 { font-size: 1.8em; border-bottom: 2px solid var(--accent-color); padding-bottom: 0.3em; } +h2 { font-size: 1.5em; } +h3 { font-size: 1.25em; } +h4 { font-size: 1.1em; } + +h1:first-child, h2:first-child { margin-top: 0; } + +/* Numbered sections */ +h2 { counter-increment: h2-counter; } +h2::before { + content: counter(h2-counter) ". "; + color: var(--accent-color); + font-weight: 700; +} + +body { counter-reset: h2-counter; } + +a { + color: var(--accent-color); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +p { + margin: 1em 0; +} + +ul, ol { + margin: 1em 0; + padding-left: 1.8em; +} + +li { + margin: 0.4em 0; +} + +/* Table of Contents */ +.toc { + background: var(--bg-secondary); + padding: 25px 30px; + margin: 2em 0; + border-radius: 6px; + border-left: 4px solid var(--accent-color); +} + +.toc h2 { + margin-top: 0; + font-size: 1.2em; +} + +.toc ul { + list-style: none; + margin: 0.5em 0; +} + +.toc li { + margin: 0.3em 0; +} + +.toc a { + color: var(--text-secondary); +} + +blockquote { + margin: 1.5em 0; + padding: 1em 1.5em; + border-left: 4px solid var(--accent-secondary); + background: var(--bg-secondary); +} + +blockquote::before { + content: "Note: "; + font-weight: 600; + color: var(--accent-secondary); +} + +code { + font-family: 'SF Mono', Monaco, 'Consolas', monospace; + font-size: 0.9em; + background: var(--code-bg); + padding: 0.2em 0.4em; + border-radius: 3px; +} + +pre { + margin: 1.5em 0; + padding: 1em; + background: var(--code-bg); + border-radius: 6px; + overflow-x: auto; + font-size: 0.85em; + line-height: 1.5; + border: 1px solid var(--border-color); +} + +pre code { + background: none; + padding: 0; +} + +table { + width: 100%; + margin: 1.5em 0; + border-collapse: collapse; + font-size: 0.95em; +} + +th, td { + padding: 0.75em 1em; + text-align: left; + border: 1px solid var(--border-color); +} + +th { + background: var(--bg-secondary); + font-weight: 600; +} + +tr:nth-child(even) { + background: var(--bg-secondary); +} + +hr { + border: none; + border-top: 1px solid var(--border-color); + margin: 2em 0; +} + +/* Callout boxes */ +.callout { + margin: 1.5em 0; + padding: 1em 1.2em; + border-radius: 4px; + border-left: 4px solid; +} + +.callout-note { background: #e7f3ff; border-color: #0d6efd; } +.callout-tip { background: #d1e7dd; border-color: #198754; } +.callout-warning { background: #fff3cd; border-color: #dc3545; } +.callout-info { background: #cff4fc; border-color: #0dcaf0; } + +/* Index */ +.index-list { + list-style: none; + padding: 0; + columns: 3; + column-gap: 2em; +} + +.index-list li { + break-inside: avoid; + padding: 0.5em 0; + border-bottom: 1px solid var(--border-color); +} + +.index-list li:last-child { + border-bottom: none; +} + +.index-list a { + color: var(--text-primary); +} + +@media (max-width: 768px) { + .container { + padding: 0 20px; + } + + .index-list { + columns: 1; + } + + header { + padding: 40px 0; + } + + header h1 { + font-size: 1.8em; + } +} diff --git a/compiler/html_export/wikilink_converter.py b/compiler/html_export/wikilink_converter.py new file mode 100644 index 0000000..62136ea --- /dev/null +++ b/compiler/html_export/wikilink_converter.py @@ -0,0 +1,113 @@ +"""Wikilink to HTML anchor converter. + +Converts Obsidian-style [[wikilinks]] to HTML anchors with relative paths. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +# Pattern: [[target]] or [[target|display]] or [[target#section]] +WIKILINK_RE = re.compile(r"\[\[([^\]|#\n]+?)(?:#([^\]|\n]+))?(?:\|([^\]\n]+))?\]\]") + + +def slugify(name: str) -> str: + """Convert a name to a filesystem-safe slug.""" + slug = name.lower().strip() + slug = re.sub(r"[^\w\s-]", "", slug) + slug = re.sub(r"[\s_]+", "-", slug) + slug = re.sub(r"-{2,}", "-", slug) + return slug.strip("-")[:80] + + +def wikilinks_to_html( + text: str, + source_path: Path | None = None, + base_dir: Path | None = None, +) -> str: + """Convert wikilinks in text to HTML anchors. + + Args: + text: Markdown text containing wikilinks + source_path: Path to the source file (for relative link calculation) + base_dir: Base directory for output (default: source_path.parent) + + Examples: + >>> wikilinks_to_html("See [[attention-heads]] for details.") + 'See <a href="concepts/attention-heads.html">attention-heads</a> for details.' + + >>> wikilinks_to_html("[[kv-cache|KV Cache]]") + '<a href="concepts/kv-cache.html">KV Cache</a>' + """ + if base_dir is None: + base_dir = source_path.parent if source_path else Path(".") + + def replace_wikilink(match: re.Match) -> str: + target = match.group(1).strip() + section = match.group(2) + display = match.group(3) + + # Split target from any sub-path (e.g., [[concepts/attention]]) + if "/" in target: + parts = target.rsplit("/", 1) + folder = parts[0] + "/" + name = parts[1] + else: + folder = "concepts/" + name = target + + # Slugify the name for filename + slug = slugify(name) + + # Build the href + href = f"{folder}{slug}.html" + if section: + href += f"#{slugify(section)}" + + # Use display text or fallback to name + display_text = display.strip() if display else name + + return f'<a href="{href}">{display_text}</a>' + + return WIKILINK_RE.sub(replace_wikilink, text) + + +def convert_file(source: Path, target: Path, base_dir: Path | None = None) -> int: + """Convert wikilinks in a file and write to target. + + Args: + source: Source markdown file + target: Target HTML file (will be written as .md first, then converted) + base_dir: Base directory for relative paths + + Returns: + Number of wikilinks converted + """ + content = source.read_text("utf-8-sig", errors="replace") + + # Count wikilinks before conversion + count = len(WIKILINK_RE.findall(content)) + + # Convert wikilinks + converted = wikilinks_to_html(content, source, base_dir) + + # Write to target (still .md for now, will be processed by Pandoc) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(converted, "utf-8") + + return count + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: python wikilink_converter.py <input.md> [output.md]") + sys.exit(1) + + input_file = Path(sys.argv[1]) + output_file = Path(sys.argv[2]) if len(sys.argv) > 2 else input_file.with_suffix(".html.md") + + count = convert_file(input_file, output_file) + print(f"Converted {count} wikilink(s) -> {output_file}") diff --git a/compiler/tests/test_html_export.py b/compiler/tests/test_html_export.py new file mode 100644 index 0000000..9d630e6 --- /dev/null +++ b/compiler/tests/test_html_export.py @@ -0,0 +1,110 @@ +"""Tests for HTML export module.""" + +import sys +from pathlib import Path + +# Add parent to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from html_export.wikilink_converter import wikilinks_to_html, slugify + + +def test_basic_wikilink(): + """Test basic [[wikilink]] conversion.""" + text = "See [[attention-heads]] for details." + result = wikilinks_to_html(text) + assert '<a href="concepts/attention-heads.html">attention-heads</a>' in result + assert "See" in result + assert "for details." in result + + +def test_alias_wikilink(): + """Test [[wikilink|display]] conversion.""" + text = "[[kv-cache|KV Cache]]" + result = wikilinks_to_html(text) + assert '<a href="concepts/kv-cache.html">KV Cache</a>' in result + + +def test_section_link(): + """Test [[wikilink#section]] conversion.""" + text = "See [[attention-heads#math]] for the math." + result = wikilinks_to_html(text) + assert 'href="concepts/attention-heads.html#math"' in result + + +def test_slugify(): + """Test slugify function.""" + assert slugify("KV Cache") == "kv-cache" + assert slugify("Multi-Head Attention") == "multi-head-attention" + assert slugify("Transformer (Original)") == "transformer-original" + + +def test_multiple_wikilinks(): + """Test multiple wikilinks in one text.""" + text = "[[a]] and [[b|display]] and [[c]]" + result = wikilinks_to_html(text) + assert result.count('<a href=') == 3 + assert 'concepts/a.html' in result + assert 'concepts/b.html' in result + assert 'concepts/c.html' in result + + +def test_no_wikilinks(): + """Test text without wikilinks.""" + text = "This is just plain text." + result = wikilinks_to_html(text) + assert result == text + + +def test_external_links_preserved(): + """Test that regular markdown links are left unchanged by wikilinks_to_html.""" + text = "Check [this link](https://example.com)." + result = wikilinks_to_html(text) + # wikilinks_to_html only converts [[wikilinks]], regular markdown links stay as-is + assert result == text + + +def test_callout_conversion(): + """Test Obsidian callout conversion.""" + from html_export.exporter import _convert_callouts + + text = """> [!NOTE] +> This is a note. +> With multiple lines. +""" + result = _convert_callouts(text) + assert 'class="callout callout-note"' in result + assert "<p>This is a note.</p>" in result + + +def test_callout_types(): + """Test all callout types.""" + from html_export.exporter import _convert_callouts + + for callout_type in ["NOTE", "TIP", "WARNING", "INFO", "EXAMPLE"]: + text = f"> [!{callout_type}]\n> Content here.\n" + result = _convert_callouts(text) + assert f'class="callout callout-{callout_type.lower()}"' in result + + +def test_markdown_processing(): + """Test full markdown processing pipeline.""" + from html_export.exporter import _process_markdown + from pathlib import Path + + text = """# Test Document + +See [[attention-heads]] for details. + +> [!TIP] +> This is a helpful tip. +""" + result = _process_markdown(text, Path("test.md")) + assert 'href="concepts/attention-heads.html"' in result + assert 'class="callout callout-tip"' in result + + +if __name__ == "__main__": + import pytest + + pytest.main([__file__, "-v"])