diff --git a/skills/publish-to-pages/SKILL.md b/skills/publish-to-pages/SKILL.md new file mode 100644 index 000000000..a13a12864 --- /dev/null +++ b/skills/publish-to-pages/SKILL.md @@ -0,0 +1,90 @@ +--- +name: publish-to-pages +description: 'Publish presentations and web content to GitHub Pages. Converts PPTX, PDF, HTML, or Google Slides to a live GitHub Pages URL. Handles repo creation, file conversion, Pages enablement, and returns the live URL. Use when the user wants to publish, deploy, or share a presentation or HTML file via GitHub Pages.' +--- + +# publish-to-pages + +Publish any presentation or web content to GitHub Pages in one shot. + +## 1. Prerequisites Check + +Run these silently. Only surface errors: + +```bash +command -v gh >/dev/null || echo "MISSING: gh CLI — install from https://cli.github.com" +gh auth status &>/dev/null || echo "MISSING: gh not authenticated — run 'gh auth login'" +command -v python3 >/dev/null || echo "MISSING: python3 (needed for PPTX conversion)" +``` + +`poppler-utils` is optional (PDF conversion via `pdftoppm`). Don't block on it. + +## 2. Input Detection + +Determine input type from what the user provides: + +| Input | Detection | +|-------|-----------| +| HTML file | Extension `.html` or `.htm` | +| PPTX file | Extension `.pptx` | +| PDF file | Extension `.pdf` | +| Google Slides URL | URL contains `docs.google.com/presentation` | + +Ask the user for a **repo name** if not provided. Default: filename without extension. + +## 3. Conversion + +### HTML +No conversion needed. Use the file directly as `index.html`. + +### PPTX +Run the conversion script: +```bash +python3 SKILL_DIR/scripts/convert-pptx.py INPUT_FILE /tmp/output.html +``` +If `python-pptx` is missing, tell the user: `pip install python-pptx` + +### PDF +Convert with the included script (requires `poppler-utils` for `pdftoppm`): +```bash +python3 SKILL_DIR/scripts/convert-pdf.py INPUT_FILE /tmp/output.html +``` +Each page is rendered as a PNG and base64-embedded into a self-contained HTML with slide navigation. +If `pdftoppm` is missing, tell the user: `apt install poppler-utils` (or `brew install poppler` on macOS). + +### Google Slides +1. Extract the presentation ID from the URL (the long string between `/d/` and `/`) +2. Download as PPTX: +```bash +curl -L "https://docs.google.com/presentation/d/PRESENTATION_ID/export/pptx" -o /tmp/slides.pptx +``` +3. Then convert the PPTX using the convert script above. + +## 4. Publishing + +### Visibility +Repos are created **public** by default. If the user specifies `private` (or wants a private repo), use `--private` — but note that GitHub Pages on private repos requires a Pro, Team, or Enterprise plan. + +### Publish +```bash +bash SKILL_DIR/scripts/publish.sh /path/to/index.html REPO_NAME public "Description" +``` + +Pass `private` instead of `public` if the user requests it. + +The script creates the repo, pushes `index.html`, and enables GitHub Pages. + +## 5. Output + +Tell the user: +- **Repository:** `https://github.com/USERNAME/REPO_NAME` +- **Live URL:** `https://USERNAME.github.io/REPO_NAME/` +- **Note:** Pages takes 1-2 minutes to go live. + +## Error Handling + +- **Repo already exists:** Suggest appending a number (`my-slides-2`) or a date (`my-slides-2026`). +- **Pages enablement fails:** Still return the repo URL. User can enable Pages manually in repo Settings. +- **PPTX conversion fails:** Tell user to run `pip install python-pptx`. +- **PDF conversion fails:** Suggest installing `poppler-utils` (`apt install poppler-utils` or `brew install poppler`). +- **Google Slides download fails:** The presentation may not be publicly accessible. Ask user to make it viewable or download the PPTX manually. diff --git a/skills/publish-to-pages/scripts/convert-pdf.py b/skills/publish-to-pages/scripts/convert-pdf.py new file mode 100755 index 000000000..01c99e739 --- /dev/null +++ b/skills/publish-to-pages/scripts/convert-pdf.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Convert a PDF to a self-contained HTML presentation. + +Each page is rendered as a PNG image (via pdftoppm) and base64-embedded +into a single HTML file with slide navigation (arrows, swipe, click). + +Requirements: poppler-utils (pdftoppm) +Usage: python3 convert-pdf.py input.pdf [output.html] +""" + +import base64 +import glob +import os +import subprocess +import sys +import tempfile +from pathlib import Path + + +def convert(pdf_path: str, output_path: str | None = None, dpi: int = 150): + pdf_path = str(Path(pdf_path).resolve()) + if not Path(pdf_path).exists(): + print(f"Error: {pdf_path} not found") + sys.exit(1) + + # Check for pdftoppm + if subprocess.run(["which", "pdftoppm"], capture_output=True).returncode != 0: + print("Error: pdftoppm not found. Install poppler-utils:") + print(" apt install poppler-utils # Debian/Ubuntu") + print(" brew install poppler # macOS") + sys.exit(1) + + with tempfile.TemporaryDirectory() as tmpdir: + prefix = os.path.join(tmpdir, "page") + result = subprocess.run( + ["pdftoppm", "-png", "-r", str(dpi), pdf_path, prefix], + capture_output=True, text=True + ) + if result.returncode != 0: + print(f"Error converting PDF: {result.stderr}") + sys.exit(1) + + pages = sorted(glob.glob(f"{prefix}-*.png")) + if not pages: + print("Error: No pages rendered from PDF") + sys.exit(1) + + slides_html = [] + for i, page_path in enumerate(pages, 1): + with open(page_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode() + slides_html.append( + f'
' + f'
' + f'Page {i}' + f'
' + ) + + # Try to extract title from filename + title = Path(pdf_path).stem.replace("-", " ").replace("_", " ") + + html = f''' + + + + +{title} + + + +{chr(10).join(slides_html)} +
+
+ +''' + + output = output_path or str(Path(pdf_path).with_suffix('.html')) + Path(output).write_text(html, encoding='utf-8') + print(f"Converted to: {output}") + print(f"Pages: {len(slides_html)}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: convert-pdf.py [output.html]") + sys.exit(1) + convert(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None) diff --git a/skills/publish-to-pages/scripts/convert-pptx.py b/skills/publish-to-pages/scripts/convert-pptx.py new file mode 100755 index 000000000..6423b14af --- /dev/null +++ b/skills/publish-to-pages/scripts/convert-pptx.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Convert a PPTX file to a self-contained HTML presentation with formatting preserved.""" +import sys +import base64 +import io +import re +from pathlib import Path + +try: + from pptx import Presentation + from pptx.util import Inches, Pt, Emu + from pptx.enum.text import PP_ALIGN + from pptx.dml.color import RGBColor +except ImportError: + print("ERROR: python-pptx not installed. Install with: pip install python-pptx") + sys.exit(1) + + +def rgb_to_hex(rgb_color): + """Convert RGBColor to hex string.""" + if rgb_color is None: + return None + try: + return f"#{rgb_color}" + except: + return None + + +def get_text_style(run): + """Extract inline text styling from a run.""" + styles = [] + try: + if run.font.bold: + styles.append("font-weight:bold") + if run.font.italic: + styles.append("font-style:italic") + if run.font.underline: + styles.append("text-decoration:underline") + if run.font.size: + styles.append(f"font-size:{run.font.size.pt}pt") + if run.font.color and run.font.color.rgb: + styles.append(f"color:{rgb_to_hex(run.font.color.rgb)}") + if run.font.name: + styles.append(f"font-family:'{run.font.name}',sans-serif") + except: + pass + return ";".join(styles) + + +def get_alignment(paragraph): + """Get CSS text-align from paragraph alignment.""" + try: + align = paragraph.alignment + if align == PP_ALIGN.CENTER: + return "center" + elif align == PP_ALIGN.RIGHT: + return "right" + elif align == PP_ALIGN.JUSTIFY: + return "justify" + except: + pass + return "left" + + +def extract_image(shape): + """Extract image from shape as base64 data URI.""" + try: + image = shape.image + content_type = image.content_type + image_bytes = image.blob + b64 = base64.b64encode(image_bytes).decode('utf-8') + return f"data:{content_type};base64,{b64}" + except: + return None + + +def get_shape_position(shape, slide_width, slide_height): + """Get shape position as percentages.""" + try: + left = (shape.left / slide_width) * 100 if shape.left else 0 + top = (shape.top / slide_height) * 100 if shape.top else 0 + width = (shape.width / slide_width) * 100 if shape.width else 50 + height = (shape.height / slide_height) * 100 if shape.height else 30 + return left, top, width, height + except: + return 5, 5, 90, 40 + + +def get_slide_background(slide, prs): + """Extract slide background color from XML.""" + from pptx.oxml.ns import qn + for source in [slide, slide.slide_layout]: + try: + bg_el = source.background._element + # Look for solidFill > srgbClr inside bgPr + for sf in bg_el.iter(qn('a:solidFill')): + clr = sf.find(qn('a:srgbClr')) + if clr is not None and clr.get('val'): + return f"background-color:#{clr.get('val')}" + except: + pass + return "background-color:#ffffff" + + +def get_shape_fill(shape): + """Extract shape fill color from XML.""" + from pptx.oxml.ns import qn + try: + sp_pr = shape._element.find(qn('p:spPr')) + if sp_pr is None: + sp_pr = shape._element.find(qn('a:spPr')) + if sp_pr is None: + # Try direct child + for tag in ['{http://schemas.openxmlformats.org/drawingml/2006/main}spPr', + '{http://schemas.openxmlformats.org/presentationml/2006/main}spPr']: + sp_pr = shape._element.find(tag) + if sp_pr is not None: + break + if sp_pr is not None: + sf = sp_pr.find(qn('a:solidFill')) + if sf is not None: + clr = sf.find(qn('a:srgbClr')) + if clr is not None and clr.get('val'): + return f"#{clr.get('val')}" + except: + pass + return None + + +def render_paragraph(paragraph): + """Render a paragraph with inline formatting.""" + align = get_alignment(paragraph) + parts = [] + for run in paragraph.runs: + text = run.text + if not text: + continue + text = text.replace("&", "&").replace("<", "<").replace(">", ">") + style = get_text_style(run) + if style: + parts.append(f'{text}') + else: + parts.append(text) + if not parts: + return "" + content = "".join(parts) + return f'

{content}

' + + +def convert(pptx_path, output_path=None): + prs = Presentation(pptx_path) + slide_width = prs.slide_width + slide_height = prs.slide_height + aspect_ratio = slide_width / slide_height if slide_height else 16/9 + + slides_html = [] + + for i, slide in enumerate(prs.slides, 1): + bg_style = get_slide_background(slide, prs) + elements = [] + + for shape in sorted(slide.shapes, key=lambda s: (s.top or 0, s.left or 0)): + left, top, width, height = get_shape_position(shape, slide_width, slide_height) + pos_style = f"position:absolute;left:{left:.1f}%;top:{top:.1f}%;width:{width:.1f}%;height:{height:.1f}%" + + # Image + if shape.shape_type == 13 or hasattr(shape, "image"): + data_uri = extract_image(shape) + if data_uri: + elements.append( + f'
' + f'' + f'
' + ) + continue + + # Table + if shape.has_table: + table = shape.table + table_html = '' + for row in table.rows: + table_html += "" + for cell in row.cells: + cell_text = cell.text.replace("&", "&").replace("<", "<") + table_html += f'' + table_html += "" + table_html += "
{cell_text}
" + elements.append(f'
{table_html}
') + continue + + # Text + if shape.has_text_frame: + text_parts = [] + for para in shape.text_frame.paragraphs: + rendered = render_paragraph(para) + if rendered: + text_parts.append(rendered) + if text_parts: + content = "".join(text_parts) + fill = get_shape_fill(shape) + fill_style = f"background-color:{fill};padding:1em;border-radius:8px;" if fill else "" + elements.append( + f'
' + f'{content}
' + ) + continue + + # Decorative shape with fill (colored rectangles, bars, etc.) + fill = get_shape_fill(shape) + if fill: + elements.append( + f'
' + ) + + slide_content = "\n".join(elements) + slides_html.append( + f'
\n
\n{slide_content}\n
\n
' + ) + + title = "Presentation" + # Try to get title from first slide + if prs.slides: + for shape in prs.slides[0].shapes: + if hasattr(shape, "text") and shape.text.strip() and len(shape.text.strip()) < 150: + title = shape.text.strip() + break + + html = f''' + + + + +{title} + + + +{chr(10).join(slides_html)} +
+
+ +''' + + output = output_path or str(Path(pptx_path).with_suffix('.html')) + Path(output).write_text(html, encoding='utf-8') + print(f"Converted to: {output}") + print(f"Slides: {len(slides_html)}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: convert-pptx.py [output.html]") + sys.exit(1) + convert(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None) diff --git a/skills/publish-to-pages/scripts/publish.sh b/skills/publish-to-pages/scripts/publish.sh new file mode 100755 index 000000000..ddebfc6fc --- /dev/null +++ b/skills/publish-to-pages/scripts/publish.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Main publish script +# Args: $1 = path to index.html, $2 = repo name, $3 = visibility (private|public), $4 = description +set -euo pipefail + +HTML_FILE="$1" +REPO_NAME="$2" +VISIBILITY="${3:-public}" +DESCRIPTION="${4:-Published via publish-to-pages}" + +USERNAME=$(gh api user --jq '.login') + +# Check if repo exists +if gh repo view "$USERNAME/$REPO_NAME" &>/dev/null; then + echo "ERROR: Repository $USERNAME/$REPO_NAME already exists" + exit 1 +fi + +# Create repo +gh repo create "$REPO_NAME" --"$VISIBILITY" --description "$DESCRIPTION" + +# Clone, push, enable pages +TMPDIR=$(mktemp -d) +git clone "https://github.com/$USERNAME/$REPO_NAME.git" "$TMPDIR" +cp "$HTML_FILE" "$TMPDIR/index.html" +cd "$TMPDIR" +git add index.html +git commit -m "Publish content" +git push origin main + +# Enable GitHub Pages +gh api "repos/$USERNAME/$REPO_NAME/pages" -X POST -f source[branch]=main -f source[path]=/ 2>/dev/null || true + +echo "REPO_URL=https://github.com/$USERNAME/$REPO_NAME" +echo "PAGES_URL=https://$USERNAME.github.io/$REPO_NAME/" +echo "" +echo "GitHub Pages may take 1-2 minutes to deploy." + +# Cleanup +rm -rf "$TMPDIR"