mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-09-09 22:31:29 +00:00
Completes the markdown-html/ domain at 5 skills. The Tier-3 use case from
Shihipar's essay ("Slide Decks"): a markdown deck (slides separated by
--- HR boundaries or # H1 headings, with optional <!-- notes: ... -->
presenter notes blocks) becomes a single-file HTML presentation with
keyboard nav, presenter mode, and print-to-PDF.
Three stdlib tools pipeline together:
1. slide_splitter.py — splits markdown on --- HR or # H1 boundaries
(or --boundary auto: HR wins ≥ 3, else H1 ≥ 5). Extracts the first
heading per slide as the title. Hard rule: refuses 1-slide decks
(exit 5 — it's a poster) and no-boundary input (exit 6 — route to
md-document). Soft-warns slides > 40 source lines (signal-to-noise;
renders anyway).
2. presenter_notes_parser.py — extracts <!-- notes: ... --> blocks
(also speaker-notes: and presenter: aliases) per slide, attaches
as a separate `notes` field, strips from body. Tracks
notes_coverage_pct for the optional --strict-notes gate (refuses
< 50% coverage when presenter mode is essential).
3. deck_html_renderer.py — single-file HTML deck. All slides as
<section class="slide"> elements, one visible at a time (CSS-
controlled). Vanilla JS keyboard handlers: → / Space / PgDn advance;
← / PgUp previous; Home / End first/last; P toggles presenter mode;
Esc exits presenter. URL-hash deep linking (#3 jumps to slide 3,
back/forward walks slides). Progress bar at top (3px); slide counter
bottom-right. Presenter mode = split view: current slide (60% width)
+ panel (40% width with clock + speaker notes + next-slide preview).
@media print { section { display: block; page-break-after: always; } }
→ Cmd+P produces PDF with one slide per page. prefers-reduced-motion
honored throughout. Reuses md-document/scripts/markdown_parser.py
for slide-body content (consistent paragraphs / lists / code / tables
/ callouts). Prism.js is OPT-IN via --syntax (off by default — most
decks don't need it; keeps the file tiny).
Plus 3 references each citing 5-7 sources:
- presentation_ux.md — Atkinson Beyond Bullet Points + Reynolds
Presentation Zen + Tufte Cognitive Style of PowerPoint + NN/g +
Weinschenk + Marp/reveal.js/Big convergence + Tom MacWright
- keyboard_nav_patterns.md — reveal.js/Big/Spectacle keymap + WCAG
2.1.1 + 2.4.3 + MDN KeyboardEvent + NN/g keyboard accessibility
- single_file_deck_conventions.md — Big + Marp + Pandoc + reveal.js
standalone + WCAG 2.3.3 + @media print
1 template asset documenting the canonical single-file deck shape.
/cs:md-slides slash command with 6 pre-flight gates + pipeline +
output digest.
Repo-level updates:
- markdown-html/.claude-plugin/plugin.json: skills array adds
./skills/md-slides; version 2.10.2 → 2.10.3; description marks
domain COMPLETE at 5 skills.
- .claude-plugin/marketplace.json: markdown-html-skills entry version
and description (domain complete); top-level counters 342 → 343
skills, 545 → 548 Python tools, 688 → 691 references, 89 → 90 slash
commands; metadata.version 2.10.2 → 2.10.3.
- Root CLAUDE.md: v2.10.3 release-notes block above v2.10.2.
Validation:
- check_plugin_json.py → OK
- sync-codex-skills.py --dry-run → 1 new symlink, documentation:
5 skills, total 345
- skill_description_validator.py → PASS (all 5 checks: present,
826/1024 chars, third-person, trigger "use after", action verb
"Convert")
- skill_review_checklist_runner.py → 5/6 PASS (under-100-lines warns
at 102; same advisory as md-document SKILL.md)
- All 3 tools pass --help and --sample
- Hard rules verified end-to-end:
no-boundary input → exit 6 with md-document routing hint
1-slide deck → exit 5 with poster recommendation
--strict-notes with < 50% coverage → exit 7
- Full pipeline on 5-slide sample deck (3 with presenter notes)
produces 12.2 KB single-file HTML with all 16 expected components
(slide-1 + slide-5 anchors, notes attribute populated, P-key
handler, arrow nav, @media print, page-break-after, presenter
panel + clock + next-preview, palette tokens, progress bar, title
in header, "1 / 5" counter, history.replaceState URL hash sync,
prefers-reduced-motion).
Domain status: COMPLETE. All 5 planned skills shipped across 4 PRs
(#780 foundation, #793 md-document, #795 md-review, this PR md-slides).
The markdown-html/ domain operationalizes Shihipar's central claim —
markdown collapses past 100 lines; HTML restores density, clarity,
shareability, and lightweight interaction — across all three layout
families (long-form documents, code reviews, slide decks).
https://claude.ai/code/session_01BK2KoQot1U7J5oSosrCQdc
116 lines
3.9 KiB
Python
116 lines
3.9 KiB
Python
#!/usr/bin/env python3
|
|
"""presenter_notes_parser.py - Extract <!-- notes: ... --> blocks from slide bodies.
|
|
|
|
Stdlib-only. Operates on the slide JSON produced by slide_splitter.py. For each
|
|
slide, finds any HTML-comment notes block (the convention used by reveal.js,
|
|
Marp, Big, Pandoc-Beamer) and:
|
|
|
|
- Records the notes text separately under `notes`
|
|
- Removes the notes block from `body_markdown` so the slide renders cleanly
|
|
|
|
Accepted notes syntax (case-insensitive on the keyword):
|
|
|
|
<!-- notes: This is a single-line presenter note. -->
|
|
|
|
<!-- notes:
|
|
Multi-line notes block. Can contain markdown.
|
|
- Bullet points
|
|
- Multiple paragraphs
|
|
-->
|
|
|
|
<!-- speaker-notes: alias -->
|
|
<!-- presenter: alias -->
|
|
|
|
If a slide has multiple notes blocks, they're concatenated with blank lines
|
|
between them.
|
|
|
|
NO LLM CALLS. Pure regex + slide-by-slide transformation.
|
|
|
|
Usage:
|
|
python presenter_notes_parser.py --slides slides.json --output slides-notes.json
|
|
python presenter_notes_parser.py --sample
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Matches the entire <!-- notes: ... --> block, including multi-line
|
|
NOTES_BLOCK_RE = re.compile(
|
|
r"<!--\s*(?:notes|speaker-notes|presenter)\s*:\s*(.*?)\s*-->",
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
|
|
|
|
def extract_notes_from_slide(slide: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return a new slide dict with `notes` populated and `body_markdown`
|
|
stripped of any notes blocks."""
|
|
body = slide.get("body_markdown", "")
|
|
found: list[str] = []
|
|
def _capture(m: re.Match) -> str:
|
|
found.append(m.group(1).strip())
|
|
return "" # remove the block from body
|
|
cleaned = NOTES_BLOCK_RE.sub(_capture, body)
|
|
# Tidy up: collapse runs of >2 blank lines, trim trailing whitespace
|
|
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip("\n")
|
|
|
|
out = dict(slide)
|
|
out["body_markdown"] = cleaned
|
|
out["notes"] = "\n\n".join(found) if found else ""
|
|
out["has_notes"] = bool(found)
|
|
return out
|
|
|
|
|
|
def attach_notes(slides_payload: dict[str, Any]) -> dict[str, Any]:
|
|
slides = slides_payload.get("slides", [])
|
|
new_slides = [extract_notes_from_slide(s) for s in slides]
|
|
notes_count = sum(1 for s in new_slides if s["has_notes"])
|
|
out = dict(slides_payload)
|
|
out["slides"] = new_slides
|
|
out["summary"] = dict(out.get("summary", {}))
|
|
out["summary"]["slides_with_notes"] = notes_count
|
|
out["summary"]["notes_coverage_pct"] = (
|
|
round(100 * notes_count / len(new_slides), 1) if new_slides else 0.0
|
|
)
|
|
return out
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
p.add_argument("--slides", help="Path to slide_splitter JSON output, or '-' for stdin")
|
|
p.add_argument("--output", help="Path to write JSON output (else stdout)")
|
|
p.add_argument("--sample", action="store_true",
|
|
help="Run on the slide_splitter built-in sample")
|
|
args = p.parse_args(argv)
|
|
|
|
if args.sample:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import slide_splitter
|
|
slides_payload = slide_splitter.split_slides(slide_splitter.SAMPLE_MARKDOWN)
|
|
elif args.slides:
|
|
raw = sys.stdin.read() if args.slides == "-" else Path(args.slides).read_text(encoding="utf-8")
|
|
slides_payload = json.loads(raw)
|
|
else:
|
|
p.print_help()
|
|
return 0
|
|
|
|
result = attach_notes(slides_payload)
|
|
payload = json.dumps(result, indent=2)
|
|
|
|
if args.output:
|
|
Path(args.output).write_text(payload, encoding="utf-8")
|
|
print(f"wrote {args.output}: {result['summary']['slides_with_notes']}/"
|
|
f"{result['summary']['total_slides']} slides have presenter notes "
|
|
f"({result['summary']['notes_coverage_pct']}% coverage)")
|
|
else:
|
|
print(payload)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|