#!/usr/bin/env python3
"""deck_html_renderer.py - Render parsed slides into a single-file HTML deck.
Stdlib-only. Reads slide JSON (from slide_splitter + presenter_notes_parser)
plus the design-system config, emits one .html file with:
- All slides as elements
- One slide visible at a time (driven by URL hash + JS)
- Keyboard nav: ← / → / Space / PgDn / PgUp / Home / End
- Presenter mode toggle (P key): split view with current + notes + clock + next-slide preview
- @media print { section { display: block; page-break-after: always; } } → PDF export
- 12 design-system tokens applied; design_style affects layout density
- Reuses md-document's markdown_parser to render slide-body content (paragraphs,
lists, code, tables, callouts) consistently with md-document
Vanilla JS only — no frameworks. Total payload ~3-4 KB inline.
Single-file output: all CSS + JS inline. Only external is Google Fonts CSS.
No Prism (slide code blocks are short; we color them with the design-system
code background but don't fetch the Prism CDN by default — pass --syntax to
enable Prism for code-heavy decks).
NO LLM CALLS. Pure templating.
Usage:
python deck_html_renderer.py --slides slides.json --output deck.html
python deck_html_renderer.py --sample --output /tmp/sample.html
python deck_html_renderer.py --slides slides.json --syntax --output deck.html
"""
from __future__ import annotations
import argparse
import base64
import html
import json
import os
import sys
from pathlib import Path
from typing import Any
# Bridge to design-system config
_DESIGN_SYSTEM_SCRIPTS = (
Path(__file__).resolve().parent.parent.parent / "design-system" / "scripts"
)
sys.path.insert(0, str(_DESIGN_SYSTEM_SCRIPTS))
try:
import config_loader as _cfg
except ImportError:
_cfg = None
# Reuse md-document's markdown parser for slide-body rendering
_MD_DOCUMENT_SCRIPTS = (
Path(__file__).resolve().parent.parent.parent / "md-document" / "scripts"
)
sys.path.insert(0, str(_MD_DOCUMENT_SCRIPTS))
try:
import markdown_parser as _mp
except ImportError:
_mp = None
CALLOUT_ICONS: dict[str, str] = {
"NOTE": "i", "TIP": "*", "IMPORTANT": "!", "WARNING": "!", "CAUTION": "!",
}
def _palette_to_css(palette: dict[str, str]) -> str:
if not palette:
palette = {
"--md-bg": "#0E1E38", "--md-surface": "#142B50", "--md-border": "#1A3868",
"--md-text": "#F7F7F2", "--md-text-muted": "rgba(247, 247, 242, 0.68)",
"--md-accent": "#00D4AA", "--md-accent-soft": "rgba(0, 212, 170, 0.14)",
"--md-code-bg": "#122648",
"--md-link": "#00D4AA", "--md-link-hover": "#08FECE",
"--md-success": "#10A85C", "--md-warn": "#C87C10",
}
return "\n".join(f" {k}: {v};" for k, v in palette.items())
def _font_url(heading: str, body: str) -> str:
families = sorted({heading, body})
parts = "&".join(f"family={f.replace(' ', '+')}:wght@400;600;700" for f in families)
return f"https://fonts.googleapis.com/css2?{parts}&display=swap"
def _font_stack(name: str) -> str:
fallback = ("Georgia, serif" if name in
("Playfair Display", "Merriweather", "Lora", "Source Serif 4")
else "system-ui, -apple-system, sans-serif")
return f"'{name}', {fallback}"
def _render_block(block: dict[str, Any]) -> str:
"""Render one parsed-markdown block to slide HTML."""
t = block["type"]
if t == "heading":
# Inside a slide, demote: H1 already handled as slide title; H2 stays H2; etc.
level = max(2, block["level"])
text = _mp.render_inline_html(block["text"]) if _mp else html.escape(block["text"])
return f'{text}'
if t == "paragraph":
text = _mp.render_inline_html(block["text"]) if _mp else html.escape(block["text"])
return f'
{text}
'
if t == "hr":
return ''
if t == "code":
lang = block.get("language") or "text"
body = html.escape(block["body"])
return f'
{body}
'
if t == "list":
tag = "ol" if block.get("ordered") else "ul"
items = "".join(
f"
{_mp.render_inline_html(item) if _mp else html.escape(item)}
"
for item in block["items"]
)
return f"<{tag}>{items}{tag}>"
if t == "table":
headers = block["headers"]
aligns = block.get("aligns") or ["left"] * len(headers)
rows = block["rows"]
thead = "
" + "".join(
f'
{_mp.render_inline_html(h) if _mp else html.escape(h)}
'
for h, a in zip(headers, aligns)
) + "
"
tbody = "" + "".join(
"
" + "".join(
f'
'
f'{_mp.render_inline_html(cell) if _mp else html.escape(cell)}
'
for i, cell in enumerate(row)
) + "
"
for row in rows
) + ""
return f"
{thead}{tbody}
"
if t == "callout":
kind = (block.get("kind") or "NOTE").upper()
icon = CALLOUT_ICONS.get(kind, "i")
body = " ".join(
_mp.render_inline_html(ln) if _mp else html.escape(ln)
for ln in (block.get("body_lines") or []) if ln
)
klass = kind.lower()
return (
f''
)
if t == "blockquote":
body = " ".join(
_mp.render_inline_html(ln) if _mp else html.escape(ln)
for ln in block.get("body_lines") or [] if ln
)
return f"
{body}
"
return ""
def _render_slide_body(body_markdown: str) -> str:
"""Parse a slide's markdown body and render it as HTML blocks."""
if not body_markdown.strip():
return ""
if _mp is None:
return f'
"""
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
p.add_argument("--slides", help="Path to presenter_notes_parser JSON, or '-' for stdin")
p.add_argument("--output", help="Path to write HTML (else stdout)")
p.add_argument("--title", default="Deck", help="Browser tab title")
p.add_argument("--syntax", action="store_true",
help="Enable Prism.js CDN for code syntax highlighting (off by default)")
p.add_argument("--no-config", action="store_true",
help="Bypass design-system config (use DEFAULTS)")
p.add_argument("--sample", action="store_true",
help="Render the built-in 5-slide sample deck")
p.add_argument("--strict-notes", action="store_true",
help="Refuse to render if < 50%% of slides have presenter notes "
"AND user wants presenter mode")
args = p.parse_args(argv)
if args.sample:
sys.path.insert(0, str(Path(__file__).resolve().parent))
import presenter_notes_parser
import slide_splitter
slides_payload = slide_splitter.split_slides(slide_splitter.SAMPLE_MARKDOWN)
deck_payload = presenter_notes_parser.attach_notes(slides_payload)
title = "Sample Deck — The Case for Single-File HTML"
else:
if not args.slides:
p.print_help()
return 0
raw = sys.stdin.read() if args.slides == "-" else Path(args.slides).read_text(encoding="utf-8")
deck_payload = json.loads(raw)
title = args.title
# Hard rule: if --strict-notes, refuse < 50% coverage
if args.strict_notes:
coverage = deck_payload.get("summary", {}).get("notes_coverage_pct", 0)
if coverage < 50:
print(f"refusing (--strict-notes): only {coverage}% of slides have presenter "
f"notes (need ≥ 50% for a presenter deck). Add more "
f" blocks or drop --strict-notes.",
file=sys.stderr)
return 7
if args.no_config or os.environ.get("MARKDOWN_HTML_NO_CONFIG") == "1":
config = _cfg.DEFAULTS if _cfg else {}
else:
config = _cfg.load_config() if _cfg else {}
output = render(deck_payload, config, title=title, enable_syntax=args.syntax)
if args.output and args.output != "-":
Path(args.output).write_text(output, encoding="utf-8")
notes_count = sum(1 for s in deck_payload.get("slides", []) if s.get("has_notes"))
print(f"wrote {args.output}: {len(output):,} bytes, "
f"{deck_payload['summary']['total_slides']} slides "
f"({notes_count} with notes, syntax={args.syntax})")
else:
print(output)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))