mirror of
https://github.com/alirezarezvani/claude-skills.git
synced 2026-09-05 08:05:57 +00:00
Adds the third skill to the markdown-html/ domain. The 90%-case converter that Shihipar's essay calls for: any markdown spec / plan / RFC / report / explainer becomes a single-file, lightly-interactive HTML document with the user's onboarded brand applied. Three stdlib tools pipeline together: 1. markdown_parser.py — CommonMark subset → section AST. Stdlib regex + state machine, no `markdown` dependency. Handles headings 1-6 (with slug anchors), paragraphs with inline bold/italic/code/links/images, fenced code with language tag, GFM tables with per-column alignment, GFM callouts (NOTE/TIP/IMPORTANT/WARNING/CAUTION), blockquotes, ordered + unordered lists (single-level), horizontal rules. 2. html_renderer.py — section AST + design-system config → single-file HTML. Inlines the 12 derived CSS custom properties from ~/.config/markdown-html/design-system.json. Applies the user's design_style (editorial/technical/minimal/playful) via body-class CSS overrides — editorial uses 720px max-width and 1.75 line-height, playful rounds callouts with shadows, technical is dense with 0.875rem code. Emits sticky-sidebar/collapsible-top/inline/none TOC per toc.behavior. Google Fonts CDN link + Prism.js theme link per code_theme (light/dark/auto = prefers-color-scheme). 3. interactivity_injector.py — vanilla-JS payload injected before </body>. Search filter on H2 sections (Esc clears, sticky bar), code-copy buttons (navigator.clipboard + execCommand fallback), smooth-scroll on TOC links, scrollspy via IntersectionObserver (sets aria-current="location" on the matching TOC entry; rootMargin tuned to NN/g F-shape reading pattern). Idempotent (marker check). Feature subset selectable via --features search,copycode,smoothscroll,scrollspy. Plus 3 reference docs (each citing 5-7 sources): information_density _patterns.md (Shihipar + Tufte + Wattenberger + Appleton + Ciechanowski + Bret Victor + Nielsen), toc_and_nav_ux.md (NN/g + WCAG 2.2 + ARIA APG + Vitepress/Docusaurus/mdBook convergence + GOV.UK + MDN), single_file _html_discipline.md (Shihipar + Tom MacWright's Big + Google Fonts API + Prism.js + Anil Dash). 1 template asset documenting the canonical output shape. /cs:md-document slash command with the pre-flight gates + pipeline + output digest. Repo-level updates: - markdown-html/.claude-plugin/plugin.json: skills array adds ./skills/md-document; version 2.10.0 → 2.10.1; description updated. - .claude-plugin/marketplace.json: markdown-html-skills entry version 2.10.0 → 2.10.1, description updated; ALSO cleans up stale top-level counters left over from foundation PR #780 — 338 → 341 skills, 16 → 17 domains, 63 → 64 plugins, 2.9.0 → 2.10.1 metadata.version. - Root CLAUDE.md: v2.10.1 release-notes block above v2.10.0. Validation: - check_plugin_json.py → OK - sync-codex-skills.py --dry-run → 1 new symlink, documentation: 3 skills - skill_description_validator.py → PASS (action verb, third person, trigger phrase, length within limit) - skill_review_checklist_runner.py → 5/6 PASS (under-100-lines warns at 105 — same as research-ops orchestrator; advisory not blocking) - All 3 tools pass --help and --sample - Full pipeline on markdown-html/CLAUDE.md (470 lines) produces 22.8 KB single-file HTML with all 7 expected components present (doctype, injected JS, scrollspy, IntersectionObserver, clipboard API, palette tokens, TOC) - Design-style switch round-trip: --set design_style=editorial → output has 720px max-width and 1.75 line-height; --set design_style=playful → output has rounded callouts with shadow. Customization changes behavior, not decoration (design-system rule §3 honored). - Empirical footprint: ~150-line markdown → 11 KB HTML / 15 KB with JS; ~470-line markdown → 17 KB / 23 KB with JS. Coming in v2.10.2: md-review (2-col diff + severity-tagged margin annotations + jump-nav) and md-slides (arrow-key nav + presenter mode + print-to-PDF). Both will reuse md-document's renderer scaffolding and design-system/scripts/config_loader.py. https://claude.ai/code/session_01BK2KoQot1U7J5oSosrCQdc
382 lines
12 KiB
Python
382 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""markdown_parser.py - CommonMark-subset parser for the md-document converter.
|
|
|
|
Stdlib-only. Reads a markdown file (or stdin), produces a structured section
|
|
tree as JSON that the html_renderer can consume. NO LLM CALLS — pure regex
|
|
+ state-machine line tokenization.
|
|
|
|
Scope (CommonMark subset sufficient for agent-generated specs/reports/RFCs):
|
|
- Headings: # / ## / ### / #### / ##### / ###### (1-6 levels)
|
|
- Paragraphs (lines separated by blank lines)
|
|
- Fenced code blocks (``` with optional language tag)
|
|
- Tables (GFM: header row + delimiter row + body rows)
|
|
- GFM-style callouts: > [!NOTE], > [!TIP], > [!IMPORTANT], > [!WARNING], > [!CAUTION]
|
|
- Plain blockquotes: > text
|
|
- Ordered lists: 1. / 2. / 3. (single-level only)
|
|
- Unordered lists: - / * / + (single-level only)
|
|
- Horizontal rules: --- / *** / ___
|
|
- Inline: **bold** / *italic* / `code` / [text](url) / 
|
|
|
|
Out of scope: nested lists, HTML inlines, footnotes, definition lists, task
|
|
list checkboxes (rendered as plain text), reference-style links, hard line
|
|
breaks (two-space). These can be added later if a real document needs them.
|
|
|
|
The output is a JSON object with two keys:
|
|
- meta: {title, line_count, heading_count, section_count}
|
|
- blocks: ordered list of block nodes; each section H2+ is also stored as
|
|
a structural anchor for the TOC + scrollspy.
|
|
|
|
Usage:
|
|
python markdown_parser.py --input report.md
|
|
python markdown_parser.py --input - --output sections.json
|
|
python markdown_parser.py --sample
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
CALLOUT_RE = re.compile(r"^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$", re.IGNORECASE)
|
|
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")
|
|
FENCE_RE = re.compile(r"^```(\S*)\s*$")
|
|
HR_RE = re.compile(r"^(-{3,}|\*{3,}|_{3,})\s*$")
|
|
ORDERED_LI_RE = re.compile(r"^(\d+)\.\s+(.+)$")
|
|
UNORDERED_LI_RE = re.compile(r"^[-*+]\s+(.+)$")
|
|
TABLE_DELIM_RE = re.compile(r"^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$")
|
|
TABLE_ROW_RE = re.compile(r"^\|.*\|\s*$")
|
|
BLOCKQUOTE_RE = re.compile(r"^>\s?(.*)$")
|
|
|
|
INLINE_CODE_RE = re.compile(r"`([^`]+)`")
|
|
BOLD_RE = re.compile(r"\*\*([^*]+)\*\*")
|
|
ITALIC_RE = re.compile(r"(?<!\*)\*([^*]+)\*(?!\*)")
|
|
LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
|
IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
|
|
|
|
|
|
def slugify(text: str) -> str:
|
|
"""Convert a heading text to a URL-safe anchor slug."""
|
|
text = re.sub(r"<[^>]+>", "", text) # strip any HTML tags
|
|
text = re.sub(r"[^a-zA-Z0-9\s-]", "", text)
|
|
text = re.sub(r"\s+", "-", text.strip())
|
|
return text.lower() or "section"
|
|
|
|
|
|
def render_inline_html(text: str) -> str:
|
|
"""Convert inline markdown markup to HTML, with HTML-escaping for safety."""
|
|
# HTML-escape first, then re-introduce markup via tokens that won't collide
|
|
# with user content. We use placeholder tokens to avoid double-substitution.
|
|
out = text
|
|
out = out.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
# Images first (so the ! prefix isn't eaten by link)
|
|
out = IMAGE_RE.sub(lambda m: f'<img src="{m.group(2)}" alt="{m.group(1)}">', out)
|
|
# Links
|
|
out = LINK_RE.sub(lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>', out)
|
|
# Inline code (before bold/italic so backticks short-circuit emphasis)
|
|
out = INLINE_CODE_RE.sub(lambda m: f"<code>{m.group(1)}</code>", out)
|
|
# Bold
|
|
out = BOLD_RE.sub(lambda m: f"<strong>{m.group(1)}</strong>", out)
|
|
# Italic (single * not adjacent to another *)
|
|
out = ITALIC_RE.sub(lambda m: f"<em>{m.group(1)}</em>", out)
|
|
return out
|
|
|
|
|
|
def parse_table(lines: list[str], start: int) -> tuple[dict[str, Any], int]:
|
|
"""Parse a GFM table starting at lines[start]. Returns (node, next_index)."""
|
|
header_line = lines[start]
|
|
delim_line = lines[start + 1]
|
|
body_lines: list[str] = []
|
|
i = start + 2
|
|
while i < len(lines) and TABLE_ROW_RE.match(lines[i]):
|
|
body_lines.append(lines[i])
|
|
i += 1
|
|
|
|
def split_row(row: str) -> list[str]:
|
|
cells = row.strip().strip("|").split("|")
|
|
return [c.strip() for c in cells]
|
|
|
|
headers = split_row(header_line)
|
|
aligns = []
|
|
for cell in split_row(delim_line):
|
|
s = cell.strip()
|
|
if s.startswith(":") and s.endswith(":"):
|
|
aligns.append("center")
|
|
elif s.endswith(":"):
|
|
aligns.append("right")
|
|
else:
|
|
aligns.append("left")
|
|
rows = [split_row(r) for r in body_lines]
|
|
|
|
return ({"type": "table", "headers": headers, "aligns": aligns, "rows": rows}, i)
|
|
|
|
|
|
def parse_list(lines: list[str], start: int) -> tuple[dict[str, Any], int]:
|
|
"""Parse a single-level ordered or unordered list starting at start."""
|
|
first = lines[start]
|
|
ordered = bool(ORDERED_LI_RE.match(first))
|
|
items: list[str] = []
|
|
i = start
|
|
while i < len(lines):
|
|
if ordered:
|
|
m = ORDERED_LI_RE.match(lines[i])
|
|
if not m:
|
|
break
|
|
items.append(m.group(2))
|
|
else:
|
|
m = UNORDERED_LI_RE.match(lines[i])
|
|
if not m:
|
|
break
|
|
items.append(m.group(1))
|
|
i += 1
|
|
return ({"type": "list", "ordered": ordered, "items": items}, i)
|
|
|
|
|
|
def parse_callout(lines: list[str], start: int) -> tuple[dict[str, Any], int]:
|
|
"""Parse a GFM-style callout starting at start.
|
|
|
|
Pattern:
|
|
> [!NOTE]
|
|
> Body line 1
|
|
> Body line 2
|
|
"""
|
|
m = CALLOUT_RE.match(lines[start])
|
|
kind = m.group(1).upper() if m else "NOTE"
|
|
body: list[str] = []
|
|
i = start + 1
|
|
while i < len(lines):
|
|
bq = BLOCKQUOTE_RE.match(lines[i])
|
|
if not bq:
|
|
break
|
|
body.append(bq.group(1))
|
|
i += 1
|
|
return ({"type": "callout", "kind": kind, "body_lines": body}, i)
|
|
|
|
|
|
def parse_blockquote(lines: list[str], start: int) -> tuple[dict[str, Any], int]:
|
|
"""Parse a plain blockquote (no callout marker)."""
|
|
body: list[str] = []
|
|
i = start
|
|
while i < len(lines):
|
|
bq = BLOCKQUOTE_RE.match(lines[i])
|
|
if not bq:
|
|
break
|
|
body.append(bq.group(1))
|
|
i += 1
|
|
return ({"type": "blockquote", "body_lines": body}, i)
|
|
|
|
|
|
def parse_code_block(lines: list[str], start: int) -> tuple[dict[str, Any], int]:
|
|
"""Parse a fenced code block starting at start (which is the opening fence)."""
|
|
m = FENCE_RE.match(lines[start])
|
|
language = m.group(1).strip() if m else ""
|
|
body: list[str] = []
|
|
i = start + 1
|
|
while i < len(lines):
|
|
if FENCE_RE.match(lines[i]):
|
|
i += 1
|
|
break
|
|
body.append(lines[i])
|
|
i += 1
|
|
return ({"type": "code", "language": language, "body": "\n".join(body)}, i)
|
|
|
|
|
|
def parse_paragraph(lines: list[str], start: int) -> tuple[dict[str, Any], int]:
|
|
"""Collect consecutive non-empty, non-block lines into a paragraph."""
|
|
body: list[str] = []
|
|
i = start
|
|
while i < len(lines):
|
|
ln = lines[i]
|
|
if not ln.strip():
|
|
break
|
|
# Stop if we hit a block-level construct
|
|
if (HEADING_RE.match(ln) or FENCE_RE.match(ln) or HR_RE.match(ln) or
|
|
CALLOUT_RE.match(ln) or BLOCKQUOTE_RE.match(ln) or
|
|
ORDERED_LI_RE.match(ln) or UNORDERED_LI_RE.match(ln) or
|
|
TABLE_ROW_RE.match(ln)):
|
|
break
|
|
body.append(ln)
|
|
i += 1
|
|
text = " ".join(s.strip() for s in body)
|
|
return ({"type": "paragraph", "text": text}, i)
|
|
|
|
|
|
def parse_markdown(text: str) -> dict[str, Any]:
|
|
"""Top-level parse — returns {meta, blocks}."""
|
|
lines = text.splitlines()
|
|
blocks: list[dict[str, Any]] = []
|
|
i = 0
|
|
title = ""
|
|
heading_count = 0
|
|
section_count = 0
|
|
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
|
|
if not line.strip():
|
|
i += 1
|
|
continue
|
|
|
|
# Heading
|
|
h = HEADING_RE.match(line)
|
|
if h:
|
|
level = len(h.group(1))
|
|
text_inline = h.group(2).strip()
|
|
anchor = slugify(text_inline)
|
|
heading_count += 1
|
|
if level == 1 and not title:
|
|
title = text_inline
|
|
if level >= 2:
|
|
section_count += 1
|
|
blocks.append({
|
|
"type": "heading",
|
|
"level": level,
|
|
"text": text_inline,
|
|
"anchor": anchor,
|
|
})
|
|
i += 1
|
|
continue
|
|
|
|
# HR
|
|
if HR_RE.match(line):
|
|
blocks.append({"type": "hr"})
|
|
i += 1
|
|
continue
|
|
|
|
# Fenced code
|
|
if FENCE_RE.match(line):
|
|
node, next_i = parse_code_block(lines, i)
|
|
blocks.append(node)
|
|
i = next_i
|
|
continue
|
|
|
|
# Callout (more specific than blockquote — must match first)
|
|
if CALLOUT_RE.match(line):
|
|
node, next_i = parse_callout(lines, i)
|
|
blocks.append(node)
|
|
i = next_i
|
|
continue
|
|
|
|
# Plain blockquote
|
|
if BLOCKQUOTE_RE.match(line):
|
|
node, next_i = parse_blockquote(lines, i)
|
|
blocks.append(node)
|
|
i = next_i
|
|
continue
|
|
|
|
# Table (header row + delim row check ahead)
|
|
if TABLE_ROW_RE.match(line) and i + 1 < len(lines) and TABLE_DELIM_RE.match(lines[i + 1]):
|
|
node, next_i = parse_table(lines, i)
|
|
blocks.append(node)
|
|
i = next_i
|
|
continue
|
|
|
|
# Lists
|
|
if ORDERED_LI_RE.match(line) or UNORDERED_LI_RE.match(line):
|
|
node, next_i = parse_list(lines, i)
|
|
blocks.append(node)
|
|
i = next_i
|
|
continue
|
|
|
|
# Paragraph (fallback)
|
|
node, next_i = parse_paragraph(lines, i)
|
|
blocks.append(node)
|
|
i = next_i
|
|
|
|
return {
|
|
"meta": {
|
|
"title": title,
|
|
"line_count": len(lines),
|
|
"heading_count": heading_count,
|
|
"section_count": section_count,
|
|
},
|
|
"blocks": blocks,
|
|
}
|
|
|
|
|
|
SAMPLE_MARKDOWN = """# Sample Specification
|
|
|
|
## Table of Contents
|
|
|
|
- Goals
|
|
- Architecture
|
|
- Risks
|
|
|
|
## Goals
|
|
|
|
We will integrate **Stripe Connect** with the existing checkout flow.
|
|
|
|
| Phase | Timeline | Owner |
|
|
|-------|----------|-------|
|
|
| Design | Week 1 | jane |
|
|
| Build | Week 2-3 | dev team |
|
|
| Ship | Week 4 | jane |
|
|
|
|
## Architecture
|
|
|
|
The integration uses webhooks for async events.
|
|
|
|
```python
|
|
def handle_webhook(event):
|
|
if event.type == "payment.succeeded":
|
|
mark_paid(event.data.object.id)
|
|
```
|
|
|
|
> [!NOTE]
|
|
> All webhook handlers must be idempotent.
|
|
|
|
> [!WARNING]
|
|
> Tax calculation has edge cases for digital goods in the EU.
|
|
|
|
## Risks
|
|
|
|
1. Webhook delivery delays
|
|
2. Tax calculation edge cases for VAT
|
|
3. Refund cascading across multi-party transfers
|
|
|
|
See [Stripe Connect docs](https://stripe.com/docs/connect) for details.
|
|
"""
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
parser.add_argument("--input", help="Path to markdown file, or '-' for stdin")
|
|
parser.add_argument("--output", help="Path to write JSON output (else stdout)")
|
|
parser.add_argument("--sample", action="store_true",
|
|
help="Parse a built-in sample markdown document")
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.sample:
|
|
text = SAMPLE_MARKDOWN
|
|
elif args.input:
|
|
if args.input == "-":
|
|
text = sys.stdin.read()
|
|
else:
|
|
path = Path(args.input)
|
|
if not path.exists():
|
|
print(f"error: input not found: {path}", file=sys.stderr)
|
|
return 2
|
|
text = path.read_text(encoding="utf-8")
|
|
else:
|
|
parser.print_help()
|
|
return 0
|
|
|
|
result = parse_markdown(text)
|
|
payload = json.dumps(result, indent=2)
|
|
|
|
if args.output:
|
|
Path(args.output).write_text(payload, encoding="utf-8")
|
|
print(f"wrote {args.output}: {result['meta']['heading_count']} headings, "
|
|
f"{len(result['blocks'])} blocks")
|
|
else:
|
|
print(payload)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|