From 9e3e39cdfc4cc8ddfc28f8f11906269f6e4ba705 Mon Sep 17 00:00:00 2001 From: TMHSDigital Date: Wed, 17 Jun 2026 17:23:13 -0400 Subject: [PATCH] feat(site-template): build mcp-server sites, drop innerHTML, add main landmark and meta [skip version] The shared template could not build an MCP-server repo: build_site.py exited 1 when .cursor-plugin/plugin.json was absent, which MCP servers do not ship, so they hand-rolled their pages instead (T1). It also used innerHTML for the theme and copy icons (C1), had no main landmark or skip link (A1), and emitted no og:image when site.ogImage was unset (S1). - build_site.py: plugin.json is now optional. When absent it falls back to site.json + package.json for display name (humanized from the package name, acronyms like MCP upper-cased), description, repository, version, and license. site.json is the one required input. - template.html.j2: theme-toggle and install copy-button icons are built with DOMParser + importNode instead of innerHTML, matching the catalog's hardening. Wrapped content in
with a visually-hidden skip link, and the theme toggle now sets an aria-label reflecting the current state. - template head: og:image defaults to the directory logo when site.ogImage is unset, plus Twitter card tags and an optional canonical/og:url from site.canonical. - SETUP-PROMPT.md: documents the plugin.json fallback and updates troubleshooting. Verified by building site-template/build_site.py against C:\Dev\screencast-mcp with no synthesized plugin.json: it builds and renders (title and h1 "Screencast MCP", 25-tool table, footer v0.8.12), the rendered output has zero innerHTML and a populated themeIcon, and the source repo is untouched. Co-Authored-By: Claude Opus 4.8 Signed-off-by: TMHSDigital --- site-template/SETUP-PROMPT.md | 13 +++--- site-template/build_site.py | 73 +++++++++++++++++++++++++++++++--- site-template/template.html.j2 | 49 +++++++++++++++++++---- 3 files changed, 115 insertions(+), 20 deletions(-) diff --git a/site-template/SETUP-PROMPT.md b/site-template/SETUP-PROMPT.md index 5b50ed9..6f7be06 100644 --- a/site-template/SETUP-PROMPT.md +++ b/site-template/SETUP-PROMPT.md @@ -17,7 +17,8 @@ You are setting up this repo's GitHub Pages site to use the unified auto-sync te The template system works like this: - A Python build script (site-template/build_site.py) in Developer-Tools-Directory reads data from THIS repo and generates docs/index.html -- It reads: .cursor-plugin/plugin.json, site.json, skills/*/SKILL.md, rules/*.mdc, and mcp-tools.json +- It reads: site.json (required), .cursor-plugin/plugin.json OR package.json, skills/*/SKILL.md, rules/*.mdc, and mcp-tools.json +- For a cursor plugin, plugin display metadata comes from .cursor-plugin/plugin.json. For an MCP server with no plugin manifest, it falls back to site.json + package.json (name, description, version, license, repository). - The pages.yml workflow clones Developer-Tools-Directory at deploy time, runs the build, and deploys docs/ Your tasks: @@ -25,7 +26,7 @@ Your tasks: 1. Create `site.json` in the repo root (see schema below) 2. Create `mcp-tools.json` in the repo root (see format below) 3. Update `.github/workflows/pages.yml` to clone the template and run build_site.py -4. Verify .cursor-plugin/plugin.json has all required fields +4. Cursor plugins: verify .cursor-plugin/plugin.json has all required fields. MCP servers without a plugin manifest: verify package.json has name, description, version, and license, and set site.json `title` if you want a specific display name. 5. Commit and push with message: feat: switch to unified auto-sync GitHub Pages template Do NOT modify existing skills/, rules/, or .cursor-plugin/plugin.json content. @@ -134,7 +135,7 @@ When categories are present and there are multiple categories, tools are grouped The build script reads files from the tool repo and passes them as context to the Jinja2 template. ``` -.cursor-plugin/plugin.json --> plugin (dict) +.cursor-plugin/plugin.json --> plugin (dict) [if absent, falls back to package.json + site.json] site.json --> site (dict) skills/*/SKILL.md --> parse_skills() --> skills (list), skill_count (int) rules/*.mdc|*.md --> parse_rules() --> rules (list), rule_count (int) @@ -258,13 +259,13 @@ jobs: ## Troubleshooting -### "ERROR: .cursor-plugin/plugin.json not found" +### Missing or wrong display metadata -The build script requires this file. Ensure your repo has `.cursor-plugin/plugin.json` with at least `displayName`, `description`, `version`, `author`, `repository`, and `license`. +Cursor plugins read display metadata from `.cursor-plugin/plugin.json` (at least `displayName`, `description`, `version`, `author`, `repository`, `license`). MCP servers without a plugin manifest fall back to `package.json` (`name`, `description`, `version`, `license`, `repository`); the display name is derived from the package name, so set `title` in `site.json` to override it. The build no longer fails when `.cursor-plugin/plugin.json` is absent. ### "ERROR: site.json not found" -Create a `site.json` in the repo root. At minimum it can be `{}` and the template will use default colors. +Create a `site.json` in the repo root. `site.json` is the one required input. At minimum it can be `{}` and the template will use default colors (set `title` for an MCP server so the display name is not derived from the package name). ### Empty skills/rules sections diff --git a/site-template/build_site.py b/site-template/build_site.py index af7c5ac..7cacd69 100644 --- a/site-template/build_site.py +++ b/site-template/build_site.py @@ -234,6 +234,72 @@ def load_mcp_tools(repo_root: Path) -> list[dict]: 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: @@ -262,18 +328,13 @@ def main(): out_dir = args.out.resolve() template_dir = Path(__file__).parent.resolve() - plugin_path = repo_root / ".cursor-plugin" / "plugin.json" - if not plugin_path.is_file(): - print(f"ERROR: {plugin_path} not found", file=sys.stderr) - sys.exit(1) - 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) - plugin = load_json(plugin_path) site = load_json(site_path) + plugin = load_plugin_meta(repo_root, site) skills = parse_skills(repo_root) rules = parse_rules(repo_root) diff --git a/site-template/template.html.j2 b/site-template/template.html.j2 index c1d526d..a28a7a1 100644 --- a/site-template/template.html.j2 +++ b/site-template/template.html.j2 @@ -3,12 +3,19 @@ + {% 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.ogImage %}{% endif %} + {% if site.canonical %}{% endif %} + + + + + {% if site.favicon %}{% endif %} @@ -250,6 +257,10 @@ /* SR ONLY */ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; } + /* SKIP LINK */ + .skip-link { position: absolute; left: 0.5rem; top: -3rem; z-index: 300; background: var(--accent); color: #fff; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; font-weight: 600; transition: top 0.15s; } + .skip-link:focus { top: 0.5rem; color: #fff; } + /* REDUCED MOTION */ @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } @@ -259,6 +270,8 @@ + + +
+
@@ -550,6 +565,8 @@
+
+