Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions site-template/SETUP-PROMPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,16 @@ 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:

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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
73 changes: 67 additions & 6 deletions site-template/build_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
49 changes: 41 additions & 8 deletions site-template/template.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
{% set og_image = site.ogImage | default('https://tmhsdigital.github.io/Developer-Tools-Directory/assets/logo.png', true) %}
<title>{{ plugin.displayName }}</title>
<meta name="description" content="{{ plugin.description }}" />
{% if site.canonical %}<link rel="canonical" href="{{ site.canonical }}" />{% endif %}
<meta property="og:title" content="{{ plugin.displayName }}" />
<meta property="og:description" content="{{ plugin.description }}" />
<meta property="og:type" content="website" />
{% if site.ogImage %}<meta property="og:image" content="{{ site.ogImage }}" />{% endif %}
{% if site.canonical %}<meta property="og:url" content="{{ site.canonical }}" />{% endif %}
<meta property="og:image" content="{{ og_image }}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ plugin.displayName }}" />
<meta name="twitter:description" content="{{ plugin.description }}" />
<meta name="twitter:image" content="{{ og_image }}" />
{% if site.favicon %}<link rel="icon" href="{{ site.favicon }}" />{% endif %}
<link rel="preload" as="font" type="font/woff2" crossorigin href="fonts/inter-regular.woff2" />
<link rel="preload" as="font" type="font/woff2" crossorigin href="fonts/inter-bold.woff2" />
Expand Down Expand Up @@ -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; }
Expand All @@ -259,6 +270,8 @@
</head>
<body>

<a href="#main" class="skip-link">Skip to content</a>

<!-- Nav -->
<nav class="nav">
<div class="nav-inner">
Expand All @@ -283,6 +296,8 @@
</div>
</nav>

<main id="main">

<!-- Hero -->
<section class="hero">
<div class="hero-inner">
Expand Down Expand Up @@ -550,6 +565,8 @@

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

</main>

<!-- Footer -->
<footer>
<div class="footer-inner">
Expand Down Expand Up @@ -604,13 +621,29 @@
};
})();

/* Parse an SVG string and append its children as real nodes. No HTML sink
used (parity with the catalog's DOMParser approach). */
function svgChildrenInto(target, svgString) {
while (target.firstChild) target.removeChild(target.firstChild);
var doc = new DOMParser().parseFromString(svgString, 'image/svg+xml');
var src = doc.documentElement;
if (src && src.nodeName === 'svg') {
Array.prototype.slice.call(src.childNodes).forEach(function (k) {
target.appendChild(document.importNode(k, true));
});
}
}

/* Theme toggle (dark / light / auto) */
(function () {
var SVG_NS = 'http://www.w3.org/2000/svg';
var btn = document.getElementById('themeToggle');
var icon = document.getElementById('themeIcon');
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"/>';
var moonSvg = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>';
var autoSvg = '<circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 0 20V2z"/>';
var iconSvgs = {
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>',
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>',
auto: '<svg xmlns="' + SVG_NS + '"><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 0 20V2z"/></svg>'
};
var states = ['dark', 'light', 'auto'];
function getState() { return localStorage.getItem('theme') || 'auto'; }
function apply(state) {
Expand All @@ -622,10 +655,9 @@
updateIcon(state);
}
function updateIcon(state) {
if (state === 'light') icon.innerHTML = sunSvg;
else if (state === 'dark') icon.innerHTML = moonSvg;
else icon.innerHTML = autoSvg;
svgChildrenInto(icon, iconSvgs[state] || iconSvgs.auto);
btn.title = 'Theme: ' + state;
btn.setAttribute('aria-label', 'Theme: ' + state + ' (click to change)');
}
btn.addEventListener('click', function () {
var cur = getState();
Expand Down Expand Up @@ -673,11 +705,12 @@

/* Auto-add copy buttons next to <code> in install steps */
(function () {
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>';
document.querySelectorAll('.install-steps code').forEach(function (code) {
var btn = document.createElement('span');
btn.className = 'copy-icon';
btn.title = 'Copy';
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>';
svgChildrenInto(btn, COPY_SVG);
btn.addEventListener('click', function () { copyText(code.textContent); });
code.parentNode.insertBefore(btn, code.nextSibling);
});
Expand Down
Loading