Add publish-to-pages agent skill (#1035)

* Add publish-to-pages agent skill

Agent skill that publishes presentations and web content to GitHub Pages.
Works with any AI coding agent (Copilot CLI, Claude Code, Gemini CLI, etc.)

Features:
- Converts PPTX and PDF with full formatting preservation
- Creates repo, enables Pages, returns live URL
- Zero config — just needs gh CLI

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs: update README.skills.md

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Andrea Liliana Griffiths
2026-03-16 21:49:40 -04:00
committed by GitHub
parent e2c763df88
commit 4ad31b665f
5 changed files with 558 additions and 0 deletions

View File

@@ -196,6 +196,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to
| [prd](../skills/prd/SKILL.md) | Generate high-quality Product Requirements Documents (PRDs) for software systems and AI-powered features. Includes executive summaries, user stories, technical specifications, and risk analysis. | None |
| [project-workflow-analysis-blueprint-generator](../skills/project-workflow-analysis-blueprint-generator/SKILL.md) | Comprehensive technology-agnostic prompt generator for documenting end-to-end application workflows. Automatically detects project architecture patterns, technology stacks, and data flow patterns to generate detailed implementation blueprints covering entry points, service layers, data access, error handling, and testing approaches across multiple technologies including .NET, Java/Spring, React, and microservices architectures. | None |
| [prompt-builder](../skills/prompt-builder/SKILL.md) | Guide users through creating high-quality GitHub Copilot prompts with proper structure, tools, and best practices. | None |
| [publish-to-pages](../skills/publish-to-pages/SKILL.md) | 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. | `scripts/convert-pdf.py`<br />`scripts/convert-pptx.py`<br />`scripts/publish.sh` |
| [pytest-coverage](../skills/pytest-coverage/SKILL.md) | Run pytest tests with coverage, discover lines missing coverage, and increase coverage to 100%. | None |
| [python-mcp-server-generator](../skills/python-mcp-server-generator/SKILL.md) | Generate a complete MCP server project in Python with tools, resources, and proper configuration | None |
| [quasi-coder](../skills/quasi-coder/SKILL.md) | Expert 10x engineer skill for interpreting and implementing code from shorthand, quasi-code, and natural language descriptions. Use when collaborators provide incomplete code snippets, pseudo-code, or descriptions with potential typos or incorrect terminology. Excels at translating non-technical or semi-technical descriptions into production-quality code. | None |

View File

@@ -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.

View File

@@ -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'<section class="slide">'
f'<div class="slide-inner">'
f'<img src="data:image/png;base64,{b64}" alt="Page {i}">'
f'</div></section>'
)
# Try to extract title from filename
title = Path(pdf_path).stem.replace("-", " ").replace("_", " ")
html = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
html, body {{ height: 100%; overflow: hidden; background: #000; }}
.slide {{ width: 100vw; height: 100vh; display: none; align-items: center; justify-content: center; }}
.slide.active {{ display: flex; }}
.slide-inner {{ display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }}
.slide-inner img {{ max-width: 100%; max-height: 100%; object-fit: contain; }}
.progress {{ position: fixed; bottom: 0; left: 0; height: 4px; background: #0366d6; transition: width 0.3s; z-index: 100; }}
.counter {{ position: fixed; bottom: 12px; right: 20px; font-size: 14px; color: rgba(255,255,255,0.4); z-index: 100; }}
</style>
</head>
<body>
{chr(10).join(slides_html)}
<div class="progress" id="progress"></div>
<div class="counter" id="counter"></div>
<script>
const slides = document.querySelectorAll('.slide');
let current = 0;
function show(n) {{
slides.forEach(s => s.classList.remove('active'));
current = Math.max(0, Math.min(n, slides.length - 1));
slides[current].classList.add('active');
document.getElementById('progress').style.width = ((current + 1) / slides.length * 100) + '%';
document.getElementById('counter').textContent = (current + 1) + ' / ' + slides.length;
}}
document.addEventListener('keydown', e => {{
if (e.key === 'ArrowRight' || e.key === ' ') {{ e.preventDefault(); show(current + 1); }}
if (e.key === 'ArrowLeft') {{ e.preventDefault(); show(current - 1); }}
}});
let touchStartX = 0;
document.addEventListener('touchstart', e => {{ touchStartX = e.changedTouches[0].screenX; }});
document.addEventListener('touchend', e => {{
const diff = e.changedTouches[0].screenX - touchStartX;
if (Math.abs(diff) > 50) {{ diff > 0 ? show(current - 1) : show(current + 1); }}
}});
document.addEventListener('click', e => {{
if (e.clientX > window.innerWidth / 2) show(current + 1);
else show(current - 1);
}});
show(0);
</script>
</body></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 <file.pdf> [output.html]")
sys.exit(1)
convert(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)

View File

@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
style = get_text_style(run)
if style:
parts.append(f'<span style="{style}">{text}</span>')
else:
parts.append(text)
if not parts:
return ""
content = "".join(parts)
return f'<p style="text-align:{align};margin:0.3em 0;line-height:1.4">{content}</p>'
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'<div style="{pos_style};display:flex;align-items:center;justify-content:center">'
f'<img src="{data_uri}" style="max-width:100%;max-height:100%;object-fit:contain" alt="">'
f'</div>'
)
continue
# Table
if shape.has_table:
table = shape.table
table_html = '<table style="width:100%;border-collapse:collapse;font-size:0.9em">'
for row in table.rows:
table_html += "<tr>"
for cell in row.cells:
cell_text = cell.text.replace("&", "&amp;").replace("<", "&lt;")
table_html += f'<td style="border:1px solid #ccc;padding:6px 10px">{cell_text}</td>'
table_html += "</tr>"
table_html += "</table>"
elements.append(f'<div style="{pos_style};overflow:auto">{table_html}</div>')
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'<div style="{pos_style};{fill_style}overflow:hidden;display:flex;flex-direction:column;justify-content:center">'
f'{content}</div>'
)
continue
# Decorative shape with fill (colored rectangles, bars, etc.)
fill = get_shape_fill(shape)
if fill:
elements.append(
f'<div style="{pos_style};background-color:{fill};border-radius:4px"></div>'
)
slide_content = "\n".join(elements)
slides_html.append(
f'<section class="slide" style="{bg_style}">\n<div class="slide-inner">\n{slide_content}\n</div>\n</section>'
)
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'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
html, body {{ height: 100%; overflow: hidden; background: #000; }}
.slide {{
width: 100vw; height: 100vh;
display: none;
align-items: center; justify-content: center;
overflow: hidden;
}}
.slide.active {{ display: flex; }}
.slide-inner {{
position: relative;
width: 1280px; height: {int(1280 / aspect_ratio)}px;
transform-origin: center center;
flex-shrink: 0;
}}
.progress {{ position: fixed; bottom: 0; left: 0; height: 4px; background: #0366d6; transition: width 0.3s; z-index: 100; }}
.counter {{ position: fixed; bottom: 12px; right: 20px; font-size: 14px; color: rgba(255,255,255,0.4); z-index: 100; }}
</style>
</head>
<body>
{chr(10).join(slides_html)}
<div class="progress" id="progress"></div>
<div class="counter" id="counter"></div>
<script>
const slides = document.querySelectorAll('.slide');
let current = 0;
function show(n) {{
slides.forEach(s => s.classList.remove('active'));
current = Math.max(0, Math.min(n, slides.length - 1));
slides[current].classList.add('active');
document.getElementById('progress').style.width = ((current + 1) / slides.length * 100) + '%';
document.getElementById('counter').textContent = (current + 1) + ' / ' + slides.length;
}}
document.addEventListener('keydown', e => {{
if (e.key === 'ArrowRight' || e.key === ' ') {{ e.preventDefault(); show(current + 1); }}
if (e.key === 'ArrowLeft') {{ e.preventDefault(); show(current - 1); }}
}});
let touchStartX = 0;
document.addEventListener('touchstart', e => {{ touchStartX = e.changedTouches[0].screenX; }});
document.addEventListener('touchend', e => {{
const diff = e.changedTouches[0].screenX - touchStartX;
if (Math.abs(diff) > 50) {{ diff > 0 ? show(current - 1) : show(current + 1); }}
}});
document.addEventListener('click', e => {{
if (e.clientX > window.innerWidth / 2) show(current + 1);
else show(current - 1);
}});
show(0);
function scaleSlides() {{
document.querySelectorAll('.slide-inner').forEach(inner => {{
const scaleX = window.innerWidth / inner.offsetWidth;
const scaleY = window.innerHeight / inner.offsetHeight;
const scale = Math.min(scaleX, scaleY);
inner.style.transform = 'scale(' + scale + ')';
}});
}}
window.addEventListener('resize', scaleSlides);
scaleSlides();
</script>
</body></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 <file.pptx> [output.html]")
sys.exit(1)
convert(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)

View File

@@ -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"