Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ When modifying existing presentations, make **minimal changes** — only touch w

**If any rule fails after editing, fix it before saving.**

Then run the HTML tag-balance check: `python scripts/validate-html.py <file>`. It exits non-zero and names the offending tag on any unclosed or mismatched tag, which catches a broken edit the spec rules do not. Fix any reported tag before saving.

---

## Phase 1: Choose Mode
Expand Down Expand Up @@ -446,7 +448,7 @@ Always embed inline speaker notes in each slide. If the source had speaker notes

### Step 5.5: Validate & Save

Before saving, verify all 8 spec rules pass. Fix any that fail. Save the HTML file.
Before saving, verify all 8 spec rules pass. Fix any that fail. Then run `python scripts/validate-html.py <file>` to confirm every tag is balanced, and fix any unclosed or mismatched tag it names. Save the HTML file.

---

Expand Down Expand Up @@ -479,12 +481,13 @@ Deploys the presentation to a permanent shareable URL. Works on any device.
2. **Check login** — Run `npx vercel whoami`. If not logged in, guide through:
- Sign up at https://vercel.com/signup (free)
- Run `vercel login` to authorize
3. **Deploy** — Run:
3. **Check confidentiality** - A public deploy ships the whole file, including every inline `<script class="slide-notes">` block, so speaker notes are readable by anyone with the URL. Grep the file for client or project identifiers across both visible text and the `slide-notes` blocks. If the notes must not ship, deploy a stripped copy instead: remove every `<script class="slide-notes">` block from the copy, deploy that copy, and keep the original so the presenter app still has the notes.
4. **Deploy** — Run:
```bash
bash scripts/deploy.sh <path-to-presentation>
```
Accepts a single HTML file or a folder with index.html.
4. **Share the URL** — Tell the user the live URL, that it works on any device, and that they can delete it from https://vercel.com/dashboard later.
5. **Share the URL** — Tell the user the live URL, that it works on any device, and that they can delete it from https://vercel.com/dashboard later.

**Requires:** Node.js + Vercel account (free tier)

Expand Down Expand Up @@ -523,6 +526,7 @@ Captures each slide as a screenshot and combines into a single PDF. Animations a
| [libraries.md](references/libraries.md) | CDN libraries: Mermaid.js (diagrams), anime.js (animations), Chart.js (charts) | Phase 3 (when content needs them) |
| [presentation-layer.md](references/presentation-layer.md) | Shared spec: slide structure, speaker notes, 8 validation rules, navigation | Phase 3 (reference) |
| [scripts/extract-pptx.py](scripts/extract-pptx.py) | Python script for PPT content extraction | Phase 4 (PPT conversion) |
| [scripts/validate-html.py](scripts/validate-html.py) | Stdlib HTML tag-balance check, exits non-zero on any unclosed or mismatched tag | Step 5.5 and after any modification |
| [conversion-patterns.md](references/conversion-patterns.md) | Framework detection patterns and extraction rules | Phase 5 (HTML conversion) |
| [scripts/deploy.sh](scripts/deploy.sh) | Deploy slides to Vercel for instant sharing | Phase 7 (sharing) |
| [scripts/export-pdf.sh](scripts/export-pdf.sh) | Export slides to PDF | Phase 7 (sharing) |
7 changes: 5 additions & 2 deletions references/presentation-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ Every slide **must** include an inline `<script type="application/json" class="s

**Never include:** delivery instructions, transition cues, presentation advice, or meta commentary.

## Spec Rules (8 total)
**Warning:** these notes ship inside the public artifact. A deployed file carries every `slide-notes` block in plain text, readable by anyone with the URL, unless you strip them before deploy (see SKILL.md Phase 7A).

After generating or modifying ANY presentation, verify all 8 rules:
## Spec Rules (9 total)

After generating or modifying ANY presentation, verify all 9 rules:

1. `<div class="deck" id="deck">` exists
2. All slides are `<div class="slide">` with sequential `data-slide="0"` through `data-slide="N"`
Expand All @@ -68,6 +70,7 @@ After generating or modifying ANY presentation, verify all 8 rules:
6. All JS inline (except CDN libraries: Chart.js, Mermaid, anime.js)
7. No broken numbering gaps after insertions or deletions
8. `<meta name="generator" content="html-slides vX.Y.Z">` exists in `<head>`
9. Every tag is balanced. Run `python scripts/validate-html.py <file>`, which exits non-zero and names the offending tag on any unclosed or mismatched tag.

## Content Density Limits

Expand Down
102 changes: 102 additions & 0 deletions scripts/validate-html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
Validate that an HTML slide file has balanced tags.
Exits non-zero and names the offending tag on any unclosed or mismatched tag.

Usage:
python scripts/validate-html.py <input.html>
"""

import sys
from html.parser import HTMLParser

# Void elements never carry a closing tag, so they must not push onto the stack.
VOID_ELEMENTS = frozenset(
[
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]
)


class TagBalanceParser(HTMLParser):
"""Track a stack of opened non-void tags and record the first imbalance."""

def __init__(self):
super().__init__(convert_charrefs=True)
self.stack = []
self.error = None

def handle_starttag(self, tag, attrs):
if self.error is not None:
return
if tag not in VOID_ELEMENTS:
self.stack.append((tag, self.getpos()))

def handle_startendtag(self, tag, attrs):
# Self-closing syntax (<tag />) opens and closes in one token, so skip it.
return

def handle_endtag(self, tag):
if self.error is not None:
return
if tag in VOID_ELEMENTS:
return
if not self.stack:
line, _ = self.getpos()
self.error = f"unexpected closing tag </{tag}> at line {line} with no open tag"
return
open_tag, (open_line, _) = self.stack[-1]
if open_tag != tag:
line, _ = self.getpos()
self.error = (
f"mismatched closing tag </{tag}> at line {line}, "
f"expected </{open_tag}> opened at line {open_line}"
)
return
self.stack.pop()


def validate(file_path):
"""Return an error message string, or None when every tag balances."""
with open(file_path, "r", encoding="utf-8") as f:
html = f.read()

parser = TagBalanceParser()
parser.feed(html)
parser.close()

if parser.error is not None:
return parser.error
if parser.stack:
tag, (line, _) = parser.stack[0]
return f"unclosed tag <{tag}> opened at line {line}"
return None


if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python scripts/validate-html.py <input.html>")
sys.exit(1)

input_file = sys.argv[1]
error = validate(input_file)

if error is not None:
print(f"FAIL: {input_file}: {error}")
sys.exit(1)

print(f"OK: {input_file}: all tags balanced")
sys.exit(0)
113 changes: 113 additions & 0 deletions scripts/validate-html.test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""
Tests for validate-html.py.
Run with: python scripts/validate-html.test.py

Stdlib unittest only, no install step, mirroring the script it covers.
"""

import importlib.util
import os
import tempfile
import unittest

_HERE = os.path.dirname(os.path.abspath(__file__))
_SPEC = importlib.util.spec_from_file_location(
"validate_html", os.path.join(_HERE, "validate-html.py")
)
validate_html = importlib.util.module_from_spec(_SPEC)
_SPEC.loader.exec_module(validate_html)


def write_temp(html):
"""Write HTML to a temp file and return its path for validation."""
fd, path = tempfile.mkstemp(suffix=".html")
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(html)
return path


WELL_FORMED_DECK = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Example Deck</title>
</head>
<body>
<div class="deck" id="deck">
<div class="slide active" data-slide="0"><h1>Slide One</h1></div>
<div class="slide" data-slide="1"><h1>Slide Two</h1></div>
</div>
</body>
</html>
"""

UNCLOSED_DIV = """<!DOCTYPE html>
<html>
<body>
<div class="deck">
<div class="slide">Example bullet
</div>
</body>
</html>
"""

MISMATCHED_CLOSE = """<!DOCTYPE html>
<html>
<body>
<div class="deck"><span>Section Title</div></span>
</body>
</html>
"""

VOID_ELEMENTS_DECK = """<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><link rel="icon" href="x"></head>
<body>
<div class="slide">
<img src="a.png">
<br>
<hr>
<input type="text">
</div>
</body>
</html>
"""


class ValidateHtmlTest(unittest.TestCase):
def test_well_formed_deck_passes(self):
path = write_temp(WELL_FORMED_DECK)
try:
self.assertIsNone(validate_html.validate(path))
finally:
os.remove(path)

def test_unclosed_div_fails_and_names_tag(self):
path = write_temp(UNCLOSED_DIV)
try:
error = validate_html.validate(path)
self.assertIsNotNone(error)
self.assertIn("div", error)
finally:
os.remove(path)

def test_mismatched_close_is_reported(self):
path = write_temp(MISMATCHED_CLOSE)
try:
error = validate_html.validate(path)
self.assertIsNotNone(error)
self.assertIn("mismatched", error)
finally:
os.remove(path)

def test_void_elements_do_not_count_as_unclosed(self):
path = write_temp(VOID_ELEMENTS_DECK)
try:
self.assertIsNone(validate_html.validate(path))
finally:
os.remove(path)


if __name__ == "__main__":
unittest.main()