Skip to content

Commit 9e3e39c

Browse files
TMHSDigitalclaude
andcommitted
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 <main id="main"> 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 <noreply@anthropic.com> Signed-off-by: TMHSDigital <TMHospitalityStrategies@gmail.com>
1 parent 901d776 commit 9e3e39c

3 files changed

Lines changed: 115 additions & 20 deletions

File tree

site-template/SETUP-PROMPT.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,16 @@ You are setting up this repo's GitHub Pages site to use the unified auto-sync te
1717
1818
The template system works like this:
1919
- A Python build script (site-template/build_site.py) in Developer-Tools-Directory reads data from THIS repo and generates docs/index.html
20-
- It reads: .cursor-plugin/plugin.json, site.json, skills/*/SKILL.md, rules/*.mdc, and mcp-tools.json
20+
- It reads: site.json (required), .cursor-plugin/plugin.json OR package.json, skills/*/SKILL.md, rules/*.mdc, and mcp-tools.json
21+
- 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).
2122
- The pages.yml workflow clones Developer-Tools-Directory at deploy time, runs the build, and deploys docs/
2223
2324
Your tasks:
2425
2526
1. Create `site.json` in the repo root (see schema below)
2627
2. Create `mcp-tools.json` in the repo root (see format below)
2728
3. Update `.github/workflows/pages.yml` to clone the template and run build_site.py
28-
4. Verify .cursor-plugin/plugin.json has all required fields
29+
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.
2930
5. Commit and push with message: feat: switch to unified auto-sync GitHub Pages template
3031
3132
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
134135
The build script reads files from the tool repo and passes them as context to the Jinja2 template.
135136

136137
```
137-
.cursor-plugin/plugin.json --> plugin (dict)
138+
.cursor-plugin/plugin.json --> plugin (dict) [if absent, falls back to package.json + site.json]
138139
site.json --> site (dict)
139140
skills/*/SKILL.md --> parse_skills() --> skills (list), skill_count (int)
140141
rules/*.mdc|*.md --> parse_rules() --> rules (list), rule_count (int)
@@ -258,13 +259,13 @@ jobs:
258259
259260
## Troubleshooting
260261
261-
### "ERROR: .cursor-plugin/plugin.json not found"
262+
### Missing or wrong display metadata
262263
263-
The build script requires this file. Ensure your repo has `.cursor-plugin/plugin.json` with at least `displayName`, `description`, `version`, `author`, `repository`, and `license`.
264+
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.
264265

265266
### "ERROR: site.json not found"
266267

267-
Create a `site.json` in the repo root. At minimum it can be `{}` and the template will use default colors.
268+
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).
268269

269270
### Empty skills/rules sections
270271

site-template/build_site.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,72 @@ def load_mcp_tools(repo_root: Path) -> list[dict]:
234234
return []
235235

236236

237+
# Tokens that should render upper-case when humanizing a package name into a
238+
# display name (e.g. "screencast-mcp" -> "Screencast MCP").
239+
_ACRONYMS = {"mcp", "api", "ai", "ui", "cfx", "cli", "sdk", "id", "os", "npm"}
240+
241+
242+
def _humanize_package_name(name: str) -> str:
243+
"""Turn an npm package name into a display name. ``@tmhs/screencast-mcp``
244+
becomes ``Screencast MCP``."""
245+
base = name.split("/")[-1] if name else ""
246+
words = [w for w in base.replace("_", "-").split("-") if w]
247+
return " ".join(w.upper() if w.lower() in _ACRONYMS else w.capitalize() for w in words)
248+
249+
250+
def _clean_repo_url(url: str) -> str:
251+
url = re.sub(r"^git\+", "", url or "")
252+
url = re.sub(r"\.git$", "", url)
253+
return url
254+
255+
256+
def load_plugin_meta(repo_root: Path, site: dict) -> dict:
257+
"""Return the plugin metadata the template needs.
258+
259+
Prefer ``.cursor-plugin/plugin.json`` when present (cursor plugins). When it
260+
is absent (MCP server repos do not ship one) fall back to ``site.json`` plus
261+
``package.json`` for the display name, description, repository, version, and
262+
license, so the shared template can build an MCP-server site without a
263+
synthesized manifest."""
264+
plugin_path = repo_root / ".cursor-plugin" / "plugin.json"
265+
if plugin_path.is_file():
266+
return load_json(plugin_path)
267+
268+
pkg_path = repo_root / "package.json"
269+
pkg = load_json(pkg_path) if pkg_path.is_file() else {}
270+
if not pkg and not site:
271+
print(
272+
f"ERROR: {plugin_path} not found and no package.json/site.json to "
273+
"fall back to",
274+
file=sys.stderr,
275+
)
276+
sys.exit(1)
277+
278+
links = site.get("links") or {}
279+
repo = links.get("github", "")
280+
if not repo:
281+
repository = pkg.get("repository")
282+
if isinstance(repository, dict):
283+
repo = _clean_repo_url(repository.get("url", ""))
284+
elif isinstance(repository, str):
285+
repo = _clean_repo_url(repository)
286+
287+
display = (
288+
site.get("title")
289+
or site.get("displayName")
290+
or _humanize_package_name(pkg.get("name", ""))
291+
or "Tool"
292+
)
293+
return {
294+
"displayName": display,
295+
"description": site.get("description") or pkg.get("description", ""),
296+
"repository": repo,
297+
"version": pkg.get("version", "0.0.0"),
298+
"license": pkg.get("license", "CC-BY-NC-ND-4.0"),
299+
"logo": site.get("logo"),
300+
}
301+
302+
237303
def group_by_category(items: list[dict]) -> dict[str, list[dict]]:
238304
groups: dict[str, list[dict]] = {}
239305
for item in items:
@@ -262,18 +328,13 @@ def main():
262328
out_dir = args.out.resolve()
263329
template_dir = Path(__file__).parent.resolve()
264330

265-
plugin_path = repo_root / ".cursor-plugin" / "plugin.json"
266-
if not plugin_path.is_file():
267-
print(f"ERROR: {plugin_path} not found", file=sys.stderr)
268-
sys.exit(1)
269-
270331
site_path = repo_root / "site.json"
271332
if not site_path.is_file():
272333
print(f"ERROR: {site_path} not found", file=sys.stderr)
273334
sys.exit(1)
274335

275-
plugin = load_json(plugin_path)
276336
site = load_json(site_path)
337+
plugin = load_plugin_meta(repo_root, site)
277338

278339
skills = parse_skills(repo_root)
279340
rules = parse_rules(repo_root)

site-template/template.html.j2

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
{% set og_image = site.ogImage | default('https://tmhsdigital.github.io/Developer-Tools-Directory/assets/logo.png', true) %}
67
<title>{{ plugin.displayName }}</title>
78
<meta name="description" content="{{ plugin.description }}" />
9+
{% if site.canonical %}<link rel="canonical" href="{{ site.canonical }}" />{% endif %}
810
<meta property="og:title" content="{{ plugin.displayName }}" />
911
<meta property="og:description" content="{{ plugin.description }}" />
1012
<meta property="og:type" content="website" />
11-
{% if site.ogImage %}<meta property="og:image" content="{{ site.ogImage }}" />{% endif %}
13+
{% if site.canonical %}<meta property="og:url" content="{{ site.canonical }}" />{% endif %}
14+
<meta property="og:image" content="{{ og_image }}" />
15+
<meta name="twitter:card" content="summary_large_image" />
16+
<meta name="twitter:title" content="{{ plugin.displayName }}" />
17+
<meta name="twitter:description" content="{{ plugin.description }}" />
18+
<meta name="twitter:image" content="{{ og_image }}" />
1219
{% if site.favicon %}<link rel="icon" href="{{ site.favicon }}" />{% endif %}
1320
<link rel="preload" as="font" type="font/woff2" crossorigin href="fonts/inter-regular.woff2" />
1421
<link rel="preload" as="font" type="font/woff2" crossorigin href="fonts/inter-bold.woff2" />
@@ -250,6 +257,10 @@
250257
/* SR ONLY */
251258
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
252259
260+
/* SKIP LINK */
261+
.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; }
262+
.skip-link:focus { top: 0.5rem; color: #fff; }
263+
253264
/* REDUCED MOTION */
254265
@media (prefers-reduced-motion: reduce) {
255266
html { scroll-behavior: auto; }
@@ -259,6 +270,8 @@
259270
</head>
260271
<body>
261272

273+
<a href="#main" class="skip-link">Skip to content</a>
274+
262275
<!-- Nav -->
263276
<nav class="nav">
264277
<div class="nav-inner">
@@ -283,6 +296,8 @@
283296
</div>
284297
</nav>
285298

299+
<main id="main">
300+
286301
<!-- Hero -->
287302
<section class="hero">
288303
<div class="hero-inner">
@@ -550,6 +565,8 @@
550565

551566
</div><!-- /.content-area -->
552567

568+
</main>
569+
553570
<!-- Footer -->
554571
<footer>
555572
<div class="footer-inner">
@@ -604,13 +621,29 @@
604621
};
605622
})();
606623
624+
/* Parse an SVG string and append its children as real nodes. No HTML sink
625+
used (parity with the catalog's DOMParser approach). */
626+
function svgChildrenInto(target, svgString) {
627+
while (target.firstChild) target.removeChild(target.firstChild);
628+
var doc = new DOMParser().parseFromString(svgString, 'image/svg+xml');
629+
var src = doc.documentElement;
630+
if (src && src.nodeName === 'svg') {
631+
Array.prototype.slice.call(src.childNodes).forEach(function (k) {
632+
target.appendChild(document.importNode(k, true));
633+
});
634+
}
635+
}
636+
607637
/* Theme toggle (dark / light / auto) */
608638
(function () {
639+
var SVG_NS = 'http://www.w3.org/2000/svg';
609640
var btn = document.getElementById('themeToggle');
610641
var icon = document.getElementById('themeIcon');
611-
var sunSvg = '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>';
612-
var moonSvg = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>';
613-
var autoSvg = '<circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 0 20V2z"/>';
642+
var iconSvgs = {
643+
light: '<svg xmlns="' + SVG_NS + '"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>',
644+
dark: '<svg xmlns="' + SVG_NS + '"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
645+
auto: '<svg xmlns="' + SVG_NS + '"><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 0 20V2z"/></svg>'
646+
};
614647
var states = ['dark', 'light', 'auto'];
615648
function getState() { return localStorage.getItem('theme') || 'auto'; }
616649
function apply(state) {
@@ -622,10 +655,9 @@
622655
updateIcon(state);
623656
}
624657
function updateIcon(state) {
625-
if (state === 'light') icon.innerHTML = sunSvg;
626-
else if (state === 'dark') icon.innerHTML = moonSvg;
627-
else icon.innerHTML = autoSvg;
658+
svgChildrenInto(icon, iconSvgs[state] || iconSvgs.auto);
628659
btn.title = 'Theme: ' + state;
660+
btn.setAttribute('aria-label', 'Theme: ' + state + ' (click to change)');
629661
}
630662
btn.addEventListener('click', function () {
631663
var cur = getState();
@@ -673,11 +705,12 @@
673705
674706
/* Auto-add copy buttons next to <code> in install steps */
675707
(function () {
708+
var COPY_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/></svg>';
676709
document.querySelectorAll('.install-steps code').forEach(function (code) {
677710
var btn = document.createElement('span');
678711
btn.className = 'copy-icon';
679712
btn.title = 'Copy';
680-
btn.innerHTML = '<svg viewBox="0 0 16 16" fill="currentColor"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"/></svg>';
713+
svgChildrenInto(btn, COPY_SVG);
681714
btn.addEventListener('click', function () { copyText(code.textContent); });
682715
code.parentNode.insertBefore(btn, code.nextSibling);
683716
});

0 commit comments

Comments
 (0)