diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 0fb0cea..d37c3e6 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -1,11 +1,11 @@
name: Deploy GitHub Pages
-# Builds the fleet landing page (docs/index.html, via the shared Developer-Tools-Directory
-# template) AND the local examples gallery (docs/gallery/index.html, generated from
-# examples/gallery.json by scripts/build_gallery.py). Both ship in the single docs/ artifact;
-# the fleet build only writes docs/index.html + docs/fonts/ + docs/assets/, so it never
-# clobbers docs/gallery/. When the fleet template gains examples support, the gallery step is
-# retired and the data (examples/gallery.json) migrates onto the shared template.
+# Builds the landing page (docs/index.html, via the locally vendored template at
+# scripts/site/ — originally scaffolded from Developer-Tools-Directory's site-template,
+# now owned and evolved by this repo) AND the local examples gallery
+# (docs/gallery/index.html, generated from examples/gallery.json by scripts/build_gallery.py).
+# Both ship in the single docs/ artifact; the landing build only writes docs/index.html +
+# docs/fonts/ + docs/assets/, so it never clobbers docs/gallery/.
on:
push:
@@ -20,6 +20,8 @@ on:
- "examples/gallery.json"
- "docs/gallery/**"
- "scripts/build_gallery.py"
+ - "scripts/site/**"
+ - "CHANGELOG.md"
workflow_dispatch:
permissions:
@@ -39,21 +41,14 @@ jobs:
steps:
- uses: actions/checkout@v7
- - name: Checkout site template
- uses: actions/checkout@v7
- with:
- repository: TMHSDigital/Developer-Tools-Directory
- sparse-checkout: site-template
- path: _template
-
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install Jinja2
- - name: Build fleet landing page
- run: python _template/site-template/build_site.py --repo-root . --out docs
+ - name: Build landing page (vendored template)
+ run: python scripts/site/build_site.py --repo-root . --out docs
- name: Build local examples gallery (from examples/gallery.json)
# Stdlib-only; regenerates docs/gallery/index.html so the committed page can never
diff --git a/.gitignore b/.gitignore
index 5762f77..904784c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,12 @@ ehthumbs.db
# Local planning docs (not published)
docs/site-upgrade-plan.md
+# Landing-page build outputs (generated at deploy time by scripts/site/build_site.py;
+# docs/gallery/ stays committed)
+docs/index.html
+docs/fonts/
+docs/assets/
+
# Build artifacts
dist/
build/
diff --git a/AGENTS.md b/AGENTS.md
index ec5e953..bf6281c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -125,6 +125,12 @@ way, and a one-paragraph rationale. 30 to 80 lines is the right size.
each release. Triggered on push to `main` for content-changing paths only.
- `label-sync.yml` self-heals labels via `gh label create --force` per
label, then applies them to the PR.
+- `pages.yml` builds the landing page from the **locally vendored** template
+ at `scripts/site/` (originally scaffolded from Developer-Tools-Directory's
+ site-template, now owned by this repo — the fleet template only scaffolds
+ new tools) plus the examples gallery via `scripts/build_gallery.py`, then
+ deploys `docs/`. `docs/index.html`, `docs/fonts/`, and `docs/assets/` are
+ build outputs and gitignored; `docs/gallery/` is committed.
## Where to look for canonical references
diff --git a/scripts/site/build_site.py b/scripts/site/build_site.py
new file mode 100644
index 0000000..389c524
--- /dev/null
+++ b/scripts/site/build_site.py
@@ -0,0 +1,414 @@
+#!/usr/bin/env python3
+"""Build a GitHub Pages site from plugin metadata and a Jinja2 template.
+
+Reads .cursor-plugin/plugin.json, site.json, skills/, rules/, and
+mcp-tools.json from a tool repository and renders a single-page site.
+"""
+
+import argparse
+import datetime
+import json
+import re
+import shutil
+import sys
+from pathlib import Path
+
+from jinja2 import Environment, FileSystemLoader
+
+
+def load_json(path: Path) -> dict | list:
+ with open(path, encoding="utf-8") as f:
+ return json.load(f)
+
+
+def parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
+ """Extract YAML frontmatter and return (metadata_dict, body_after_frontmatter)."""
+ lines = text.splitlines(keepends=True)
+ if not lines or lines[0].strip() != "---":
+ return {}, text
+
+ end_idx = None
+ for i, line in enumerate(lines[1:], start=1):
+ if line.strip() == "---":
+ end_idx = i
+ break
+
+ if end_idx is None:
+ return {}, text
+
+ meta: dict[str, str] = {}
+ for line in lines[1:end_idx]:
+ if ":" in line and not line.strip().startswith("-"):
+ key, _, val = line.partition(":")
+ meta[key.strip().lower()] = val.strip()
+
+ body = "".join(lines[end_idx + 1:])
+ return meta, body
+
+
+def _extract_tools_from_frontmatter(lines: list[str]) -> list[str]:
+ """Parse a YAML list of tools from frontmatter lines (handles ``tools:`` key)."""
+ tools: list[str] = []
+ in_tools = False
+ for line in lines:
+ stripped = line.strip()
+ if stripped.startswith("tools:"):
+ in_tools = True
+ continue
+ if in_tools:
+ if stripped.startswith("- "):
+ tools.append(stripped[2:].strip())
+ else:
+ break
+ return tools
+
+
+def _extract_trigger_section(body: str) -> list[str]:
+ """Pull bullet items from the ``## Trigger`` section of a SKILL.md body."""
+ triggers: list[str] = []
+ in_trigger = False
+ for line in body.splitlines():
+ stripped = line.strip()
+ if re.match(r"^##\s+Trigger", stripped, re.IGNORECASE):
+ in_trigger = True
+ continue
+ if in_trigger:
+ if stripped.startswith("##"):
+ break
+ if stripped.startswith("- "):
+ triggers.append(stripped[2:].strip())
+ return triggers
+
+
+def parse_skills(repo_root: Path) -> list[dict]:
+ skills_dir = repo_root / "skills"
+ if not skills_dir.is_dir():
+ return []
+
+ results = []
+ for skill_dir in sorted(skills_dir.iterdir()):
+ skill_file = skill_dir / "SKILL.md"
+ if not skill_file.is_file():
+ continue
+
+ text = skill_file.read_text(encoding="utf-8", errors="replace")
+ meta, body = parse_frontmatter(text)
+
+ name = meta.get("name", "").replace("-", " ").replace("_", " ").title()
+ if not name:
+ name = skill_dir.name.replace("-", " ").replace("_", " ").title()
+ description = meta.get("description", "")[:200]
+
+ if not description:
+ for line in body.splitlines():
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ continue
+ description = stripped[:200]
+ break
+
+ if not name or name == skill_dir.name.replace("-", " ").replace("_", " ").title():
+ for line in body.splitlines():
+ stripped = line.strip()
+ if re.match(r"^#\s+\S", stripped):
+ name = re.sub(r"^#+\s*", "", stripped)
+ break
+
+ fm_lines = text.splitlines()
+ tools = _extract_tools_from_frontmatter(fm_lines)
+ triggers = _extract_trigger_section(body)
+
+ results.append({
+ "name": name,
+ "description": description,
+ "category": skill_dir.name,
+ "tools": tools,
+ "triggers": triggers,
+ })
+
+ return results
+
+
+def parse_rules(repo_root: Path) -> list[dict]:
+ rules_dir = repo_root / "rules"
+ if not rules_dir.is_dir():
+ return []
+
+ results = []
+ for rule_file in sorted(rules_dir.iterdir()):
+ if rule_file.suffix not in (".mdc", ".md"):
+ continue
+
+ text = rule_file.read_text(encoding="utf-8", errors="replace")
+ lines = text.strip().splitlines()
+
+ name = rule_file.stem.replace("-", " ").replace("_", " ").title()
+ description = ""
+ scope = ""
+
+ for line in lines:
+ stripped = line.strip()
+ if stripped.startswith("---"):
+ continue
+ if ":" in stripped and not description:
+ key, _, val = stripped.partition(":")
+ key_lower = key.strip().lower()
+ if key_lower == "description":
+ description = val.strip()[:200]
+ elif key_lower in ("globs", "scope"):
+ scope = val.strip()
+ continue
+ if stripped and not description:
+ description = stripped[:200]
+
+ results.append({
+ "name": name,
+ "description": description,
+ "scope": scope,
+ })
+
+ return results
+
+
+def parse_changelog(repo_root: Path, max_entries: int = 2) -> list[dict]:
+ """Parse a Keep-a-Changelog formatted CHANGELOG.md.
+
+ Returns up to *max_entries* release entries (skipping ``[Unreleased]``),
+ each as ``{ version, date, sections: [{ heading, items }] }``.
+ """
+ changelog_path = repo_root / "CHANGELOG.md"
+ if not changelog_path.is_file():
+ return []
+
+ text = changelog_path.read_text(encoding="utf-8", errors="replace")
+ entries: list[dict] = []
+ current_entry: dict | None = None
+ current_section: dict | None = None
+
+ for line in text.splitlines():
+ heading_match = re.match(r"^##\s+\[(.+?)\](?:\s*-\s*(.+))?", line)
+ if heading_match:
+ version = heading_match.group(1)
+ date = (heading_match.group(2) or "").strip()
+ if version.lower() == "unreleased":
+ continue
+ if current_entry is not None:
+ if current_section:
+ current_entry["sections"].append(current_section)
+ entries.append(current_entry)
+ if len(entries) >= max_entries:
+ break
+ current_entry = {"version": version, "date": date, "sections": []}
+ current_section = None
+ continue
+
+ if current_entry is None:
+ continue
+
+ sub_match = re.match(r"^###\s+(.+)", line)
+ if sub_match:
+ if current_section:
+ current_entry["sections"].append(current_section)
+ current_section = {"heading": sub_match.group(1).strip(), "entries": []}
+ continue
+
+ if current_section is not None and line.strip().startswith("- "):
+ current_section["entries"].append(line.strip()[2:].strip())
+
+ if current_entry is not None:
+ if current_section:
+ current_entry["sections"].append(current_section)
+ if len(entries) < max_entries:
+ entries.append(current_entry)
+
+ return entries
+
+
+def load_examples(repo_root: Path) -> list[dict]:
+ """Read examples/gallery.json (the gallery source of truth) when present.
+
+ Hero paths in gallery.json are repo-root-relative (``docs/gallery/...``);
+ the deployed site serves ``docs/`` as its root, so expose a site-relative
+ ``heroSite`` alongside each entry."""
+ gallery_path = repo_root / "examples" / "gallery.json"
+ if not gallery_path.is_file():
+ return []
+ data = load_json(gallery_path)
+ examples = data.get("examples", []) if isinstance(data, dict) else []
+ for ex in examples:
+ hero = ex.get("hero", "")
+ ex["heroSite"] = hero[len("docs/"):] if hero.startswith("docs/") else hero
+ return examples
+
+
+def load_mcp_tools(repo_root: Path) -> list[dict]:
+ mcp_file = repo_root / "mcp-tools.json"
+ if not mcp_file.is_file():
+ return []
+ data = load_json(mcp_file)
+ if isinstance(data, list):
+ return data
+ return []
+
+
+# Tokens that should render upper-case when humanizing a package name into a
+# display name (e.g. "screencast-mcp" -> "Screencast MCP").
+_ACRONYMS = {"mcp", "api", "ai", "ui", "cfx", "cli", "sdk", "id", "os", "npm"}
+
+
+def _humanize_package_name(name: str) -> str:
+ """Turn an npm package name into a display name. ``@tmhs/screencast-mcp``
+ becomes ``Screencast MCP``."""
+ base = name.split("/")[-1] if name else ""
+ words = [w for w in base.replace("_", "-").split("-") if w]
+ return " ".join(w.upper() if w.lower() in _ACRONYMS else w.capitalize() for w in words)
+
+
+def _clean_repo_url(url: str) -> str:
+ url = re.sub(r"^git\+", "", url or "")
+ url = re.sub(r"\.git$", "", url)
+ return url
+
+
+def load_plugin_meta(repo_root: Path, site: dict) -> dict:
+ """Return the plugin metadata the template needs.
+
+ Prefer ``.cursor-plugin/plugin.json`` when present (cursor plugins). When it
+ is absent (MCP server repos do not ship one) fall back to ``site.json`` plus
+ ``package.json`` for the display name, description, repository, version, and
+ license, so the shared template can build an MCP-server site without a
+ synthesized manifest."""
+ plugin_path = repo_root / ".cursor-plugin" / "plugin.json"
+ if plugin_path.is_file():
+ return load_json(plugin_path)
+
+ pkg_path = repo_root / "package.json"
+ pkg = load_json(pkg_path) if pkg_path.is_file() else {}
+ if not pkg and not site:
+ print(
+ f"ERROR: {plugin_path} not found and no package.json/site.json to "
+ "fall back to",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+ links = site.get("links") or {}
+ repo = links.get("github", "")
+ if not repo:
+ repository = pkg.get("repository")
+ if isinstance(repository, dict):
+ repo = _clean_repo_url(repository.get("url", ""))
+ elif isinstance(repository, str):
+ repo = _clean_repo_url(repository)
+
+ display = (
+ site.get("title")
+ or site.get("displayName")
+ or _humanize_package_name(pkg.get("name", ""))
+ or "Tool"
+ )
+ return {
+ "displayName": display,
+ "description": site.get("description") or pkg.get("description", ""),
+ "repository": repo,
+ "version": pkg.get("version", "0.0.0"),
+ "license": pkg.get("license", "CC-BY-NC-ND-4.0"),
+ "logo": site.get("logo"),
+ }
+
+
+def group_by_category(items: list[dict]) -> dict[str, list[dict]]:
+ groups: dict[str, list[dict]] = {}
+ for item in items:
+ cat = item.get("category", "General") or "General"
+ groups.setdefault(cat, []).append(item)
+ return dict(sorted(groups.items()))
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--repo-root",
+ type=Path,
+ required=True,
+ help="Path to the checked-out tool repository",
+ )
+ parser.add_argument(
+ "--out",
+ type=Path,
+ default=Path("docs"),
+ help="Output directory (default: docs)",
+ )
+ args = parser.parse_args()
+
+ repo_root = args.repo_root.resolve()
+ out_dir = args.out.resolve()
+ template_dir = Path(__file__).parent.resolve()
+
+ site_path = repo_root / "site.json"
+ if not site_path.is_file():
+ print(f"ERROR: {site_path} not found", file=sys.stderr)
+ sys.exit(1)
+
+ site = load_json(site_path)
+ plugin = load_plugin_meta(repo_root, site)
+
+ skills = parse_skills(repo_root)
+ rules = parse_rules(repo_root)
+ examples = load_examples(repo_root)
+ mcp_tools = load_mcp_tools(repo_root)
+ mcp_grouped = group_by_category(mcp_tools)
+ changelog = parse_changelog(repo_root)
+
+ context = {
+ "plugin": plugin,
+ "site": site,
+ "skills": skills,
+ "skill_count": len(skills),
+ "rules": rules,
+ "rule_count": len(rules),
+ "examples": examples,
+ "example_count": len(examples),
+ "snippet_count": len(plugin.get("snippets", [])),
+ "template_count": len(plugin.get("templates", [])),
+ "mcp_tools": mcp_tools,
+ "mcp_tool_count": len(mcp_tools),
+ "mcp_grouped": mcp_grouped,
+ "changelog": changelog,
+ "has_changelog": len(changelog) > 0,
+ "build_date": datetime.date.today().isoformat(),
+ }
+
+ env = Environment(
+ loader=FileSystemLoader(str(template_dir)),
+ autoescape=True,
+ keep_trailing_newline=True,
+ )
+ template = env.get_template("template.html.j2")
+ html = template.render(**context)
+
+ out_dir.mkdir(parents=True, exist_ok=True)
+ (out_dir / "index.html").write_text(html, encoding="utf-8")
+ print(f"Wrote {out_dir / 'index.html'}")
+
+ fonts_src = template_dir / "fonts"
+ fonts_dst = out_dir / "fonts"
+ if fonts_src.is_dir():
+ if fonts_dst.exists():
+ shutil.rmtree(fonts_dst)
+ shutil.copytree(fonts_src, fonts_dst)
+ print(f"Copied fonts to {fonts_dst}")
+
+ assets_src = repo_root / "assets"
+ assets_dst = out_dir / "assets"
+ if assets_src.is_dir():
+ if assets_dst.exists():
+ shutil.rmtree(assets_dst)
+ shutil.copytree(assets_src, assets_dst)
+ print(f"Copied assets to {assets_dst}")
+
+ print("Done.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/site/fonts/inter-bold.woff2 b/scripts/site/fonts/inter-bold.woff2
new file mode 100644
index 0000000..b9e3cb3
Binary files /dev/null and b/scripts/site/fonts/inter-bold.woff2 differ
diff --git a/scripts/site/fonts/inter-medium.woff2 b/scripts/site/fonts/inter-medium.woff2
new file mode 100644
index 0000000..fdfdcc6
Binary files /dev/null and b/scripts/site/fonts/inter-medium.woff2 differ
diff --git a/scripts/site/fonts/inter-regular.woff2 b/scripts/site/fonts/inter-regular.woff2
new file mode 100644
index 0000000..2bcd222
Binary files /dev/null and b/scripts/site/fonts/inter-regular.woff2 differ
diff --git a/scripts/site/fonts/jetbrains-mono-regular.woff2 b/scripts/site/fonts/jetbrains-mono-regular.woff2
new file mode 100644
index 0000000..66c5467
Binary files /dev/null and b/scripts/site/fonts/jetbrains-mono-regular.woff2 differ
diff --git a/scripts/site/requirements.txt b/scripts/site/requirements.txt
new file mode 100644
index 0000000..9894c74
--- /dev/null
+++ b/scripts/site/requirements.txt
@@ -0,0 +1 @@
+Jinja2>=3.1,<4.0
diff --git a/scripts/site/template.html.j2 b/scripts/site/template.html.j2
new file mode 100644
index 0000000..a94313a
--- /dev/null
+++ b/scripts/site/template.html.j2
@@ -0,0 +1,887 @@
+
+
+
+
+
+ {% set og_image = site.ogImage | default('https://tmhsdigital.github.io/Developer-Tools-Directory/assets/logo.png', true) %}
+ {{ plugin.displayName }}
+
+ {% if site.canonical %} {% endif %}
+
+
+
+ {% if site.canonical %} {% endif %}
+
+
+
+
+
+ {% if site.favicon %} {% endif %}
+
+
+
+
+
+
+
+ Skip to content
+
+
+
+
+
{{ plugin.displayName }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if plugin.logo %}
{% endif %}
+
{{ plugin.displayName }}
+
{{ plugin.description }}
+
+ {% if skill_count %}
{% endif %}
+ {% if rule_count %}
{% endif %}
+ {% if mcp_tool_count %}
{% endif %}
+ {% if snippet_count %}
{% endif %}
+ {% if template_count %}
{% endif %}
+ {% if example_count %}
{% endif %}
+
+ {% if site.compatibility %}
+
+ {% if site.compatibility.cursor %}
+
+
+ Cursor {{ site.compatibility.cursor }}
+
+ {% endif %}
+ {% if site.compatibility.os %}
+
+
+ {{ site.compatibility.os | join(' / ') }}
+
+ {% endif %}
+ {% if site.compatibility.node %}
+
+
+ Node {{ site.compatibility.node }}
+
+ {% endif %}
+ {% if site.compatibility.claude %}
+
+
+ {{ site.compatibility.claude }}
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+ {% if site.quickStart %}
+
+
{{ site.quickStart.title | default('Get started') }}
+
{{ site.quickStart.command }}
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+ {% if skills %}
+
+
+
+ Skills list
+
+ {% for skill in skills %}
+
+
+ {{ skill.name }}
+ {{ skill.description }}
+
+
+ {% if skill.triggers %}
+
+
Triggers
+
{% for t in skill.triggers %}{{ t }} {% endfor %}
+
+ {% endif %}
+ {% if skill.tools %}
+
+
MCP Tools
+
{% for tool in skill.tools %}{{ tool }}{% endfor %}
+
+ {% endif %}
+
{{ skill.category }}
+
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+
+ {% if rules %}
+
+
+
+ Rules list
+
+
+ Name Scope Description
+
+ {% for rule in rules %}
+
+ {{ rule.name }}
+ {{ rule.scope }}
+ {{ rule.description }}
+
+ {% endfor %}
+
+
+
+
+
+ {% endif %}
+
+
+ {% if examples %}
+
+ {% endif %}
+
+
+ {% if mcp_tools %}
+
+ {% endif %}
+
+
+ {% if site.installSteps %}
+
+
+
+ Installation steps
+
+
+ {% for step in site.installSteps %}
+ {{ step | safe }}
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ {% if has_changelog %}
+
+
+
+ Recent changelog
+
+ {% for entry in changelog %}
+
+
v{{ entry.version }}
+ {% if entry.date %}
{{ entry.date }} {% endif %}
+ {% for sec in entry.sections %}
+
{{ sec.heading }}
+
+ {% for item in sec.entries %}{{ item }} {% endfor %}
+
+ {% endfor %}
+
+ {% endfor %}
+
View full changelog on GitHub →
+
+
+
+ {% endif %}
+
+
+ {% if site.relatedTools %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/site/tokens.css b/scripts/site/tokens.css
new file mode 100644
index 0000000..7dd8f6d
--- /dev/null
+++ b/scripts/site/tokens.css
@@ -0,0 +1,35 @@
+/* tokens.css - canonical shared design tokens for the TMHSDigital Developer
+ Tools Directory presentation surfaces.
+
+ Consumed by:
+ - the catalog site (docs/index.html), which mirrors these declarations in its
+ inline :root; tests/test_design_tokens.py enforces the mirror;
+ - the tool-site template (site-template/template.html.j2), which embeds this
+ file directly with a Jinja include of tokens.css.
+
+ Only the tokens that are shared and fixed across both surfaces live here. The
+ per-tool themeable values - accent, accent-light, the hero gradient, and the
+ page background - are NOT here; each surface sets those itself (the template
+ reads them from the tool's site.json). Edit a shared token here and mirror it
+ into docs/index.html; the parity test fails otherwise.
+
+ Container widths are intentionally NOT shared: the catalog is a card grid
+ (1200px) and a tool page is a reading column (1040px). */
+:root {
+ --bg2: #161b22;
+ --bg3: #1c2128;
+ --bg-hover: #22272e;
+ --border: #30363d;
+ --text: #e6edf3;
+ --text-dim: #8b949e;
+ --text-muted: #6e7681;
+ --green: #3fb950;
+ --blue: #58a6ff;
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
+ --radius: 8px;
+ --radius-lg: 12px;
+ --hero-h1: 2.5rem;
+ --stat-size: 1.75rem;
+ --link-hover: #c4b5fd;
+}