NoteDiscovery/backend/export.py

926 lines
35 KiB
Python
Raw Permalink Normal View History

2026-01-15 09:07:51 +00:00
"""
HTML Export Module for NoteDiscovery
2026-01-18 17:33:56 +00:00
Generates standalone HTML files for notes with embedded images and styling.
Used by both /api/export (download) and /share (public sharing) endpoints.
Note: Only images are embedded as base64. Audio, video, and PDF files are
replaced with placeholder HTML since they would make exports too large.
2026-01-15 09:07:51 +00:00
"""
import base64
2026-06-30 14:32:41 +00:00
import logging
2026-01-15 09:07:51 +00:00
import re
from pathlib import Path
2026-01-18 15:00:01 +00:00
from typing import Optional, Tuple
2026-01-15 09:07:51 +00:00
import mimetypes
# Import shared media type definitions and scanner from utils to avoid duplication
from backend.utils import MEDIA_EXTENSIONS, get_media_type, scan_notes_fast_walk
2026-01-18 15:00:01 +00:00
2026-06-30 14:32:41 +00:00
logger = logging.getLogger("uvicorn.error")
2026-01-18 15:00:01 +00:00
def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]:
"""
Read a media file and return it as a base64 data URL.
Returns tuple of (base64_url, media_type) or None if failed.
"""
if not media_path.exists() or not media_path.is_file():
2026-01-15 09:07:51 +00:00
return None
# Get MIME type
2026-01-18 15:00:01 +00:00
mime_type, _ = mimetypes.guess_type(str(media_path))
if not mime_type:
return None
# Determine media type
media_type = get_media_type(media_path.name)
if not media_type:
2026-01-15 09:07:51 +00:00
return None
try:
2026-01-18 15:00:01 +00:00
with open(media_path, 'rb') as f:
media_data = f.read()
base64_data = base64.b64encode(media_data).decode('utf-8')
return (f"data:{mime_type};base64,{base64_data}", media_type)
2026-01-15 09:07:51 +00:00
except Exception as e:
2026-06-30 14:32:41 +00:00
logger.error("Failed to read media %s: %s", media_path, e)
2026-01-15 09:07:51 +00:00
return None
2026-01-18 15:00:01 +00:00
# Legacy alias for backward compatibility
def get_image_as_base64(image_path: Path) -> Optional[str]:
"""Read an image file and return it as a base64 data URL."""
result = get_media_as_base64(image_path)
2026-04-21 13:22:09 +00:00
if result and result[1] in ('image', 'drawing'):
2026-01-18 15:00:01 +00:00
return result[0]
return None
2026-01-15 09:07:51 +00:00
def strip_frontmatter(content: str) -> str:
"""
Remove YAML frontmatter from markdown content.
Frontmatter is delimited by --- at the start and end.
"""
if not content.strip().startswith('---'):
return content
lines = content.split('\n')
if lines[0].strip() != '---':
return content
# Find closing ---
end_idx = -1
for i in range(1, len(lines)):
if lines[i].strip() == '---':
end_idx = i
break
if end_idx == -1:
return content
# Remove frontmatter and return the rest
return '\n'.join(lines[end_idx + 1:]).strip()
2026-01-18 15:00:01 +00:00
def find_media_in_attachments(media_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]:
2026-01-15 09:07:51 +00:00
"""
2026-01-18 15:00:01 +00:00
Search for a media file in common attachment locations.
2026-01-15 09:07:51 +00:00
Returns the resolved path if found, None otherwise.
"""
2026-01-18 15:00:01 +00:00
# Common locations to search for media (fast path)
2026-01-15 09:07:51 +00:00
search_paths = [
2026-01-18 15:00:01 +00:00
note_folder / media_name, # Same folder as note
note_folder / '_attachments' / media_name, # Note's _attachments folder
notes_dir / '_attachments' / media_name, # Root _attachments folder
2026-01-15 09:07:51 +00:00
]
# Also search in parent folders' _attachments (for nested notes)
current = note_folder
while current != notes_dir and current.parent != current:
2026-01-18 15:00:01 +00:00
search_paths.append(current / '_attachments' / media_name)
2026-01-15 09:07:51 +00:00
current = current.parent
for path in search_paths:
resolved = path.resolve()
if resolved.exists() and resolved.is_file():
# Security: ensure path is within notes_dir
try:
resolved.relative_to(notes_dir.resolve())
return resolved
except ValueError:
continue
# Fallback: search all _attachments folders recursively (slower but thorough)
2026-01-18 15:00:01 +00:00
# This handles cross-folder media references like in Obsidian
2026-01-15 09:07:51 +00:00
try:
2026-02-20 18:41:51 +00:00
_files, folders = scan_notes_fast_walk(str(notes_dir), include_media=False)
for folder in folders:
if folder == '_attachments' or folder.endswith('/_attachments'):
attachment_folder = notes_dir / folder
2026-01-18 15:00:01 +00:00
candidate = attachment_folder / media_name
2026-01-15 09:07:51 +00:00
if candidate.exists() and candidate.is_file():
try:
candidate.resolve().relative_to(notes_dir.resolve())
return candidate.resolve()
except ValueError:
continue
except Exception:
pass # Ignore errors in recursive search
return None
2026-01-18 15:00:01 +00:00
# Legacy alias
def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]:
return find_media_in_attachments(image_name, note_folder, notes_dir)
2026-01-18 16:32:22 +00:00
def generate_media_placeholder(media_type: str, alt_text: str) -> str:
"""Generate a placeholder for non-embeddable media (audio, video, PDF)."""
safe_alt = alt_text.replace('"', '&quot;').replace('<', '&lt;').replace('>', '&gt;')
2026-01-18 15:00:01 +00:00
2026-01-18 16:32:22 +00:00
icons = {'audio': '🎵', 'video': '🎬', 'document': '📄'}
labels = {'audio': 'Audio file', 'video': 'Video file', 'document': 'PDF document'}
icon = icons.get(media_type, '📎')
label = labels.get(media_type, 'Media file')
return f'''<div style="margin:1.5rem 0;padding:1.5rem;background:linear-gradient(135deg,var(--bg-tertiary,#f8f9fa) 0%,var(--bg-secondary,#e9ecef) 100%);border:1px solid var(--border-primary,#dee2e6);border-radius:0.5rem;display:flex;align-items:center;gap:1rem;">
<span style="font-size:2rem;">{icon}</span>
<div>
<div style="font-weight:600;color:var(--text-primary,#212529);">{safe_alt}</div>
<div style="font-size:0.875rem;color:var(--text-secondary,#6c757d);">{label} not available in exported view</div>
</div>
2026-01-18 15:00:01 +00:00
</div>'''
2026-01-18 17:33:56 +00:00
def process_media_for_export(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
2026-01-15 09:07:51 +00:00
"""
2026-01-18 17:33:56 +00:00
Process all media references in markdown for standalone HTML export.
2026-01-15 09:07:51 +00:00
Handles:
- Standard markdown images: ![alt](path)
2026-01-18 15:00:01 +00:00
- Wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
2026-01-18 17:33:56 +00:00
Behavior by media type:
- Images (jpg, png, gif, webp): Embedded as base64 data URLs
- Audio/Video/PDF: Replaced with styled placeholder HTML (not embedded - too large)
2026-01-15 09:07:51 +00:00
"""
2026-01-18 15:00:01 +00:00
# First, handle wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
wikilink_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
2026-01-15 09:07:51 +00:00
2026-01-18 15:00:01 +00:00
def replace_wikilink_media(match):
media_name = match.group(1).strip()
alt_text = match.group(2).strip() if match.group(2) else media_name.split('/')[-1].rsplit('.', 1)[0]
2026-01-15 09:07:51 +00:00
2026-01-18 16:32:22 +00:00
# Check media type first
media_type = get_media_type(media_name)
# For non-image media (audio, video, PDF), show placeholder without embedding
if media_type in ('audio', 'video', 'document'):
return generate_media_placeholder(media_type, alt_text)
# For images, embed as base64
2026-01-18 15:00:01 +00:00
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
2026-01-15 09:07:51 +00:00
if resolved_path:
2026-01-18 16:32:22 +00:00
base64_url = get_image_as_base64(resolved_path)
if base64_url:
return f'![{alt_text}]({base64_url})'
2026-01-15 09:07:51 +00:00
2026-01-18 16:32:22 +00:00
# Image not found
return f'<span style="color:var(--text-tertiary,#999);opacity:0.7;" title="Image not found">🖼️ {alt_text}</span>'
2026-01-15 09:07:51 +00:00
2026-01-18 15:00:01 +00:00
markdown_content = re.sub(wikilink_pattern, replace_wikilink_media, markdown_content)
2026-01-15 09:07:51 +00:00
# Then, handle standard markdown images: ![alt](path)
img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
2026-01-18 15:00:01 +00:00
def replace_media(match):
2026-01-15 09:07:51 +00:00
alt_text = match.group(1)
2026-01-18 15:00:01 +00:00
media_path = match.group(2)
2026-01-15 09:07:51 +00:00
# Handle external URLs
if media_path.startswith(('http://', 'https://')):
# Check if it's a PDF - generate styled external link
media_type = get_media_type(media_path)
if media_type == 'document':
display_name = alt_text or Path(media_path).stem
safe_name = display_name.replace('"', '&quot;').replace('<', '&lt;').replace('>', '&gt;')
safe_url = media_path.replace('"', '&quot;')
return f'''<a href="{safe_url}" target="_blank" rel="noopener noreferrer" style="display:flex;flex-direction:column;gap:0.25rem;padding:1rem 1.25rem;margin:1rem 0;background:linear-gradient(135deg,var(--bg-tertiary,#f8f9fa) 0%,var(--bg-secondary,#e9ecef) 100%);border:1px solid var(--border-primary,#dee2e6);border-radius:0.5rem;color:var(--text-primary,#212529);text-decoration:none;">
<span style="font-weight:600;">📄 {safe_name}</span>
<span style="font-size:0.75rem;color:var(--text-secondary,#6c757d);">Opens in new tab</span>
</a>'''
# Other external media: keep as-is (will show as broken image)
return match.group(0)
# Skip already-embedded base64
if media_path.startswith('data:'):
2026-01-15 09:07:51 +00:00
return match.group(0)
# Skip empty paths (from failed wikilink conversion)
2026-01-18 15:00:01 +00:00
if not media_path:
2026-01-15 09:07:51 +00:00
return match.group(0)
2026-01-18 16:32:22 +00:00
# Check media type first
media_type = get_media_type(media_path)
display_alt = alt_text or Path(media_path).stem
# For non-image media (audio, video, PDF), show placeholder without embedding
if media_type in ('audio', 'video', 'document'):
return generate_media_placeholder(media_type, display_alt)
# For images, proceed with base64 embedding
2026-01-18 15:00:01 +00:00
# Handle /api/media/ or legacy /api/images/ paths (convert to filesystem paths)
if media_path.startswith('/api/media/'):
relative_path = media_path[len('/api/media/'):]
resolved_path = (notes_dir / relative_path).resolve()
elif media_path.startswith('/api/images/'):
# Legacy path support for backward compatibility
relative_path = media_path[len('/api/images/'):]
2026-01-15 09:07:51 +00:00
resolved_path = (notes_dir / relative_path).resolve()
else:
2026-01-18 15:00:01 +00:00
# Try to resolve the media path relative to note folder
resolved_path = (note_folder / media_path).resolve()
2026-01-15 09:07:51 +00:00
# If not found, try the attachment search
if not resolved_path.exists():
# Extract just the filename and search
2026-01-18 15:00:01 +00:00
media_name = Path(media_path).name
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
2026-01-15 09:07:51 +00:00
if not resolved_path:
return match.group(0) # Keep original if not found
# Security: ensure path is within notes_dir
try:
resolved_path.relative_to(notes_dir.resolve())
except ValueError:
# Path is outside notes_dir, skip
return match.group(0)
2026-01-18 16:32:22 +00:00
# Get base64 data for image
base64_url = get_image_as_base64(resolved_path)
if base64_url:
return f'![{display_alt}]({base64_url})'
2026-01-15 09:07:51 +00:00
2026-01-18 16:32:22 +00:00
# Image not found, keep original
2026-01-15 09:07:51 +00:00
return match.group(0)
2026-01-18 15:00:01 +00:00
markdown_content = re.sub(img_pattern, replace_media, markdown_content)
2026-01-15 09:07:51 +00:00
return markdown_content
2026-01-18 17:33:56 +00:00
# Legacy alias for backward compatibility
2026-01-18 15:00:01 +00:00
# Legacy alias for backward compatibility
def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
2026-01-18 17:33:56 +00:00
"""Alias for process_media_for_export (legacy name kept for compatibility)."""
return process_media_for_export(markdown_content, note_folder, notes_dir)
2026-01-18 15:00:01 +00:00
2026-01-15 09:07:51 +00:00
def convert_wikilinks_to_html(markdown_content: str) -> str:
"""
Convert wikilinks [[note]] or [[note|display text]] to HTML links.
In standalone export mode, these are non-functional decorative links.
2026-07-06 07:55:05 +00:00
Wikilinks inside fenced or inline code are left literal, mirroring the
in-app preview pipeline.
2026-01-15 09:07:51 +00:00
"""
wikilink_pattern = r'(?<!!)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
2026-07-06 07:55:05 +00:00
2026-01-15 09:07:51 +00:00
def replace_wikilink(match):
target = match.group(1).strip()
display = match.group(2).strip() if match.group(2) else target
return f'<a href="#" class="wikilink" title="{target}" style="color: var(--accent-primary, #0366d6); text-decoration: none; border-bottom: 1px dashed currentColor;">{display}</a>'
2026-07-06 07:55:05 +00:00
code_blocks: list = []
def stash(match):
code_blocks.append(match.group(0))
return f"\x00CODEBLOCK{len(code_blocks) - 1}\x00"
protected = re.sub(r'```[\s\S]*?```', stash, markdown_content)
protected = re.sub(r'`[^`]+`', stash, protected)
converted = re.sub(wikilink_pattern, replace_wikilink, protected)
return re.sub(r'\x00CODEBLOCK(\d+)\x00', lambda m: code_blocks[int(m.group(1))], converted)
2026-01-15 09:07:51 +00:00
def generate_export_html(
title: str,
content: str,
theme_css: str,
2026-04-06 10:58:38 +00:00
is_dark: bool = False,
show_print_button: bool = False
2026-01-15 09:07:51 +00:00
) -> str:
"""
Generate a standalone HTML document for a note.
Uses marked.js for client-side markdown rendering.
2026-04-06 10:58:38 +00:00
2026-01-15 09:07:51 +00:00
Args:
title: The note title (for <title> and display)
content: Raw markdown content (images should already be base64 embedded)
theme_css: CSS content for theming
is_dark: Whether using a dark theme (for Mermaid/Highlight.js)
2026-04-06 10:58:38 +00:00
show_print_button: Whether to show a print button (for preview mode)
2026-01-15 09:07:51 +00:00
Returns:
Complete HTML document as string
"""
# Escape content for JavaScript string
escaped_content = (
content
.replace('\\', '\\\\')
.replace('`', '\\`')
.replace('$', '\\$')
.replace('</', '<\\/') # Prevent </script> breaking
)
highlight_theme = 'github-dark' if is_dark else 'github'
mermaid_theme = 'dark' if is_dark else 'default'
2026-04-06 10:58:38 +00:00
# Print toolbar HTML (only shown in preview mode)
print_toolbar_html = '''
<div class="print-toolbar">
<button onclick="window.print()" title="Print">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"></path>
</svg>
Print
</button>
<button onclick="window.close()" title="Close">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Close
</button>
</div>
''' if show_print_button else ''
2026-01-15 09:07:51 +00:00
html = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<!-- Highlight.js for code syntax highlighting -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/{highlight_theme}.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<!-- Marked.js for markdown parsing -->
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
2026-03-03 08:27:01 +00:00
<!-- DOMPurify for HTML sanitization (XSS prevention) -->
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
2026-01-15 09:07:51 +00:00
<!-- MathJax for LaTeX math rendering -->
<script>
MathJax = {{
tex: {{
inlineMath: [['\\\\(', '\\\\)'], ['$', '$']],
displayMath: [['\\\\[', '\\\\]'], ['$$', '$$']],
2026-01-15 09:07:51 +00:00
processEscapes: true,
processEnvironments: true
}},
options: {{
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code']
}},
startup: {{
pageReady: () => {{
return MathJax.startup.defaultPageReady().then(() => {{
// Highlight code blocks after MathJax is done
document.querySelectorAll('pre code:not(.language-mermaid)').forEach((block) => {{
hljs.highlightElement(block);
}});
}});
}}
}}
}};
</script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js"></script>
<!-- Mermaid.js for diagrams -->
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.esm.min.mjs';
mermaid.initialize({{
startOnLoad: false,
theme: '{mermaid_theme}',
securityLevel: 'strict',
fontFamily: 'inherit',
flowchart: {{ useMaxWidth: true }},
sequence: {{ useMaxWidth: true }},
gantt: {{ useMaxWidth: true }},
state: {{ useMaxWidth: true }},
er: {{ useMaxWidth: true }},
pie: {{ useMaxWidth: true }},
mindmap: {{ useMaxWidth: true }},
gitGraph: {{ useMaxWidth: true }}
}});
// Render Mermaid diagrams after page load
document.addEventListener('DOMContentLoaded', async () => {{
const mermaidBlocks = document.querySelectorAll('pre code.language-mermaid');
for (let i = 0; i < mermaidBlocks.length; i++) {{
const block = mermaidBlocks[i];
const pre = block.parentElement;
try {{
const code = block.textContent;
const id = 'mermaid-diagram-' + i;
const {{ svg }} = await mermaid.render(id, code);
const container = document.createElement('div');
container.className = 'mermaid-rendered';
container.style.cssText = 'background-color: transparent; padding: 20px; text-align: center; overflow-x: auto;';
container.innerHTML = svg;
pre.parentElement.replaceChild(container, pre);
}} catch (error) {{
console.error('Mermaid rendering error:', error);
}}
}}
}});
</script>
<style>
/* Theme CSS */
{theme_css}
/* Base styles */
* {{
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 2rem;
max-width: 900px;
margin-left: auto;
margin-right: auto;
background-color: var(--bg-primary, #ffffff);
color: var(--text-primary, #333333);
}}
/* Markdown content styles */
.markdown-preview {{
line-height: 1.6;
}}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3,
.markdown-preview h4,
.markdown-preview h5,
.markdown-preview h6 {{
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
}}
.markdown-preview h1 {{ font-size: 2em; border-bottom: 1px solid var(--border-color, #e1e4e8); padding-bottom: 0.3em; }}
.markdown-preview h2 {{ font-size: 1.5em; border-bottom: 1px solid var(--border-color, #e1e4e8); padding-bottom: 0.3em; }}
.markdown-preview h3 {{ font-size: 1.25em; }}
.markdown-preview h4 {{ font-size: 1em; }}
.markdown-preview p {{
margin: 1em 0;
}}
.markdown-preview a {{
color: var(--accent-primary, #0366d6);
text-decoration: none;
}}
.markdown-preview a:hover {{
text-decoration: underline;
}}
.markdown-preview img {{
max-width: 100%;
height: auto;
border-radius: 4px;
}}
/* Inline code */
.markdown-preview code:not(pre code) {{
background-color: var(--bg-tertiary, #f6f8fa);
color: var(--accent-primary, #0366d6);
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-weight: 500;
}}
/* Code blocks */
.markdown-preview pre {{
background-color: var(--bg-tertiary, #f6f8fa);
margin-bottom: 1.5rem;
border-radius: 0.5rem;
overflow-x: auto;
border: 1px solid var(--border-primary, #e1e4e8);
}}
.markdown-preview pre code {{
background: transparent;
padding: 1rem;
display: block;
font-size: 0.875rem;
line-height: 1.6;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
color: inherit;
}}
.markdown-preview blockquote {{
margin: 1em 0;
padding: 0 1em;
border-left: 4px solid var(--accent-primary, #0366d6);
color: var(--text-secondary, #6a737d);
}}
2026-06-29 10:19:06 +00:00
2026-06-29 11:07:00 +00:00
/* Callouts mirror the in-app preview. */
2026-06-29 10:19:06 +00:00
.markdown-preview .callout {{
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid var(--callout-color, var(--accent-primary, #0366d6));
border-radius: 0.375rem;
background: var(--callout-bg, var(--bg-secondary, #f6f8fa));
}}
.markdown-preview .callout-title {{
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--callout-color, var(--accent-primary, #0366d6));
margin-bottom: 0.25rem;
}}
.markdown-preview .callout-icon {{
font-size: 1.1em;
line-height: 1;
}}
.markdown-preview .callout-body > :first-child {{ margin-top: 0; }}
.markdown-preview .callout-body > :last-child {{ margin-bottom: 0; }}
.markdown-preview .callout-note {{ --callout-color: #0969da; --callout-bg: rgba(9, 105, 218, 0.08); }}
.markdown-preview .callout-tip {{ --callout-color: #1a7f37; --callout-bg: rgba(26, 127, 55, 0.08); }}
.markdown-preview .callout-important {{ --callout-color: #8250df; --callout-bg: rgba(130, 80, 223, 0.08); }}
.markdown-preview .callout-warning {{ --callout-color: #9a6700; --callout-bg: rgba(154, 103, 0, 0.08); }}
.markdown-preview .callout-caution {{ --callout-color: #d1242f; --callout-bg: rgba(209, 36, 47, 0.08); }}
2026-01-15 09:07:51 +00:00
.markdown-preview ul,
.markdown-preview ol {{
padding-left: 2em;
margin: 1em 0;
}}
.markdown-preview li {{
margin: 0.25em 0;
}}
.markdown-preview table {{
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}}
.markdown-preview th,
.markdown-preview td {{
border: 1px solid var(--border-color, #e1e4e8);
padding: 0.5em 1em;
text-align: left;
}}
2026-06-01 13:50:27 +00:00
.markdown-preview th[align="left"], .markdown-preview td[align="left"] {{ text-align: left; }}
.markdown-preview th[align="right"], .markdown-preview td[align="right"] {{ text-align: right; }}
.markdown-preview th[align="center"], .markdown-preview td[align="center"] {{ text-align: center; }}
2026-01-15 09:07:51 +00:00
.markdown-preview th {{
background-color: var(--bg-secondary, #f6f8fa);
font-weight: 600;
}}
.markdown-preview hr {{
border: none;
border-top: 1px solid var(--border-color, #e1e4e8);
margin: 2em 0;
}}
/* Task list styling */
.markdown-preview input[type="checkbox"] {{
margin-right: 0.5em;
}}
2026-07-06 07:55:05 +00:00
.markdown-preview li:has(> input[type="checkbox"]) {{
list-style: none;
margin-left: -1.25em;
}}
2026-01-15 09:07:51 +00:00
/* Enhanced Shell/Bash Syntax Highlighting */
.markdown-preview pre code.language-shell .hljs-meta,
.markdown-preview pre code.language-bash .hljs-meta,
.markdown-preview pre code.language-sh .hljs-meta {{
color: #7c3aed !important;
font-weight: 600;
}}
.markdown-preview pre code.language-shell .hljs-built_in,
.markdown-preview pre code.language-bash .hljs-built_in,
.markdown-preview pre code.language-sh .hljs-built_in {{
color: #10b981 !important;
font-weight: 500;
}}
.markdown-preview pre code.language-shell .hljs-string,
.markdown-preview pre code.language-bash .hljs-string,
.markdown-preview pre code.language-sh .hljs-string {{
color: #f59e0b !important;
}}
.markdown-preview pre code.language-shell .hljs-variable,
.markdown-preview pre code.language-bash .hljs-variable,
.markdown-preview pre code.language-sh .hljs-variable {{
color: #06b6d4 !important;
font-weight: 500;
}}
.markdown-preview pre code.language-shell .hljs-comment,
.markdown-preview pre code.language-bash .hljs-comment,
.markdown-preview pre code.language-sh .hljs-comment {{
color: #6b7280 !important;
font-style: italic;
}}
.markdown-preview pre code.language-shell .hljs-keyword,
.markdown-preview pre code.language-bash .hljs-keyword,
.markdown-preview pre code.language-sh .hljs-keyword {{
color: #ec4899 !important;
font-weight: 600;
}}
/* Enhanced PowerShell Syntax Highlighting */
.markdown-preview pre code.language-powershell .hljs-built_in,
.markdown-preview pre code.language-ps1 .hljs-built_in {{
color: #10b981 !important;
font-weight: 600;
}}
.markdown-preview pre code.language-powershell .hljs-variable,
.markdown-preview pre code.language-ps1 .hljs-variable {{
color: #06b6d4 !important;
font-weight: 500;
}}
.markdown-preview pre code.language-powershell .hljs-string,
.markdown-preview pre code.language-ps1 .hljs-string {{
color: #f59e0b !important;
}}
.markdown-preview pre code.language-powershell .hljs-keyword,
.markdown-preview pre code.language-ps1 .hljs-keyword {{
color: #ec4899 !important;
font-weight: 600;
}}
.markdown-preview pre code.language-powershell .hljs-comment,
.markdown-preview pre code.language-ps1 .hljs-comment {{
color: #6b7280 !important;
font-style: italic;
}}
2026-01-15 10:23:43 +00:00
/* Copy button for code blocks */
.markdown-preview pre {{
position: relative;
}}
.copy-btn {{
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background-color: var(--bg-secondary, #e1e4e8);
color: var(--text-secondary, #586069);
border: 1px solid var(--border-primary, #d0d7de);
border-radius: 0.25rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}}
.markdown-preview pre:hover .copy-btn {{
opacity: 1;
}}
.copy-btn:hover {{
background-color: var(--accent-primary, #0366d6);
color: white;
border-color: var(--accent-primary, #0366d6);
}}
.copy-btn.copied {{
background-color: #10b981;
color: white;
border-color: #10b981;
}}
2026-01-15 09:07:51 +00:00
@media (max-width: 768px) {{
body {{
padding: 1rem;
}}
}}
@media print {{
body {{
padding: 0.5in;
max-width: none;
}}
2026-04-06 10:58:38 +00:00
.print-toolbar {{
display: none !important;
}}
}}
/* Print toolbar (only shown in preview mode) */
.print-toolbar {{
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1000;
display: flex;
gap: 0.5rem;
background: var(--bg-secondary, #f8f9fa);
padding: 0.5rem;
border-radius: 0.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border: 1px solid var(--border-primary, #dee2e6);
}}
.print-toolbar button {{
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-primary, #dee2e6);
border-radius: 0.375rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #333333);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
transition: background-color 0.15s, border-color 0.15s;
}}
.print-toolbar button:hover {{
background: var(--bg-tertiary, #e9ecef);
border-color: var(--accent-primary, #0366d6);
}}
.print-toolbar button svg {{
width: 1rem;
height: 1rem;
2026-01-15 09:07:51 +00:00
}}
</style>
</head>
<body>
2026-04-06 10:58:38 +00:00
{print_toolbar_html}
2026-01-15 09:07:51 +00:00
<div class="markdown-preview" id="content"></div>
2026-04-06 10:58:38 +00:00
2026-01-15 09:07:51 +00:00
<script>
// Protect LaTeX delimiters \\(...\\) and \\[...\\] from marked.js escaping
marked.use({{
extensions: [{{
name: 'protectLatexMath',
level: 'inline',
start(src) {{ return src.match(/\\\\[\\(\\[]/)?.index; }},
tokenizer(src) {{
const match = src.match(/^(\\\\[\\(\\[])([\\s\\S]*?)(\\\\[\\)\\]])/);
if (match) {{
return {{ type: 'html', raw: match[0], text: match[0] }};
}}
}}
}}]
}});
2026-01-15 09:07:51 +00:00
// Configure marked
marked.setOptions({{
gfm: true,
breaks: true,
headerIds: true,
mangle: false
}});
2026-01-15 09:07:51 +00:00
// Raw markdown content
const markdown = `{escaped_content}`;
2026-06-29 10:19:06 +00:00
// GFM/GLFM callouts. Runs BEFORE code-block extraction so fences
// nested in a callout blockquote lose their `> ` prefix on the closer
// otherwise the restored block sits inside <div class="callout-body">
// without a valid CommonMark fence closer and runs unclosed.
2026-07-10 10:34:46 +00:00
// Fence-aware so literal `> [!TIP]` inside a top-level code block
// is not misread. Mirrors the in-app preview preprocessor.
2026-06-29 10:19:06 +00:00
let processed;
{{
const CALLOUT_RE = /^>\\s*\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\]\\s*(.*)$/i;
const CALLOUT_ICONS = {{ note: '', tip: '💡', important: '', warning: '⚠️', caution: '🛑' }};
const CALLOUT_TITLES = {{ note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', caution: 'Caution' }};
const FENCE_OPEN_RE = /^\\s{{0,3}}(`{{3,}}|~{{3,}})/;
2026-06-29 10:19:06 +00:00
const escapeAttr = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const srcLines = markdown.split('\\n');
2026-06-29 10:19:06 +00:00
const outLines = [];
let li = 0;
let fenceChar = null;
let fenceLen = 0;
2026-06-29 10:19:06 +00:00
while (li < srcLines.length) {{
const line = srcLines[li];
if (fenceChar) {{
outLines.push(line);
const closeRe = new RegExp('^\\\\s{{0,3}}' + (fenceChar === '`' ? '`' : '~') + '{{' + fenceLen + ',}}\\\\s*$');
if (closeRe.test(line)) {{ fenceChar = null; fenceLen = 0; }}
li++;
continue;
}}
const fenceOpen = line.match(FENCE_OPEN_RE);
if (fenceOpen) {{
fenceChar = fenceOpen[1][0];
fenceLen = fenceOpen[1].length;
outLines.push(line);
li++;
continue;
}}
const m = line.match(CALLOUT_RE);
if (!m) {{ outLines.push(line); li++; continue; }}
2026-06-29 10:19:06 +00:00
const type = m[1].toLowerCase();
const title = escapeAttr((m[2] || '').trim() || CALLOUT_TITLES[type]);
const icon = CALLOUT_ICONS[type];
const bodyLines = [];
li++;
while (li < srcLines.length && srcLines[li].startsWith('>')) {{
bodyLines.push(srcLines[li].replace(/^>\\s?/, ''));
li++;
}}
outLines.push(
'',
'<div class="callout callout-' + type + '">',
'<div class="callout-title"><span class="callout-icon" aria-hidden="true">' + icon + '</span><span class="callout-title-text">' + title + '</span></div>',
'<div class="callout-body">',
'',
bodyLines.join('\\n'),
'',
'</div>',
'</div>',
''
);
}}
processed = outLines.join('\\n');
2026-07-06 07:55:05 +00:00
// Escape same-line block-starters after a task marker so they
// render as literal text. Matches Obsidian. Issue #247.
processed = processed.replace(
/^(\\s*[-*+]\\s+\\[[xX ]\\]\\s+)(\\d+)\\.(\\s)/gm,
'$1$2\\\\.$3'
);
processed = processed.replace(
/^(\\s*[-*+]\\s+\\[[xX ]\\]\\s+)([#>*+\\-])(\\s)/gm,
'$1\\\\$2$3'
);
2026-06-29 10:19:06 +00:00
}}
2026-03-03 08:27:01 +00:00
// Render markdown with XSS sanitization
// DOMPurify strips scripts, iframes, and event handlers while allowing safe HTML/SVG
2026-06-29 10:19:06 +00:00
const rawHtml = marked.parse(processed);
2026-03-03 08:27:01 +00:00
const safeHtml = DOMPurify.sanitize(rawHtml);
document.getElementById('content').innerHTML = safeHtml;
2026-01-15 10:23:43 +00:00
// Typeset math after content is inserted
if (typeof MathJax !== 'undefined' && MathJax.typeset) {{
MathJax.typeset();
}}
2026-01-15 10:23:43 +00:00
// Add copy buttons to code blocks
document.querySelectorAll('.markdown-preview pre').forEach(pre => {{
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = 'Copy';
btn.addEventListener('click', async () => {{
const code = pre.querySelector('code');
if (code) {{
try {{
await navigator.clipboard.writeText(code.textContent);
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => {{
btn.textContent = 'Copy';
btn.classList.remove('copied');
}}, 2000);
}} catch (err) {{
btn.textContent = 'Failed';
setTimeout(() => btn.textContent = 'Copy', 2000);
}}
}}
}});
pre.appendChild(btn);
}});
2026-01-15 09:07:51 +00:00
</script>
</body>
</html>'''
return html