Files
wehub-resource-sync e011bb26cd
Lint skills / Validate catalog structure (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:55 +08:00

533 lines
20 KiB
Python

#!/usr/bin/env python3
"""Generate README.md catalog content from skill folder metadata.
Walks ``skills/`` and parses each ``SKILL.md`` frontmatter for the fields
``name``, ``description``, ``category``, ``catalog_summary``, and
``display_order``. Produces the README catalog section, the skill count
badge, the subtitle blockquote, the "What you get" counts, the catalog
header, the table-of-contents anchor, the catalog intro line, and a
handful of inline skill counts (install block, install-all paragraph,
hub paragraph, family-table claude-skills row, contributing paragraph)
that are substituted via anchored regex rather than marker pairs.
Two operating modes:
--check Exit 0 if README is in sync with skill metadata. Exit 2 with
a unified diff if README is stale.
--write Update README.md in place between marker comments. Print a
one-line summary on success.
Run from the repo root: ``python scripts/generate_readme_catalog.py --check``.
Markers in README.md:
<!-- COUNT_BADGE:START --> ... <!-- COUNT_BADGE:END -->
<!-- COUNT_INTRO:START --> ... <!-- COUNT_INTRO:END -->
<!-- COUNT_WHATYOUGET:START --> ... <!-- COUNT_WHATYOUGET:END -->
<!-- COUNT_TOC:START --> ... <!-- COUNT_TOC:END -->
<!-- FEATURED_SKILLS:START --> ... <!-- FEATURED_SKILLS:END -->
<!-- SURFACES:START --> ... <!-- SURFACES:END -->
<!-- COUNT_CATALOG_HEADER:START --> ... <!-- COUNT_CATALOG_HEADER:END -->
<!-- COUNT_CATALOG_INTRO:START --> ... <!-- COUNT_CATALOG_INTRO:END -->
<!-- CATALOG:START --> ... <!-- CATALOG:END -->
"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass
from difflib import unified_diff
from pathlib import Path
try:
import yaml
except ImportError:
print("ERROR: PyYAML is required. Install with: pip install pyyaml", file=sys.stderr)
sys.exit(1)
REPO_ROOT = Path(__file__).resolve().parents[1]
SKILLS_DIR = REPO_ROOT / "skills"
README = REPO_ROOT / "README.md"
# Canonical category order. Drives the README catalog section order and the
# allowed values for the ``category`` frontmatter field. Each tuple is
# (category_id, display_title, optional_intro_paragraph).
CATEGORIES: list[tuple[str, str, str | None]] = [
("strategy-and-discovery", "Strategy and discovery", None),
("brand", "Brand", None),
("design", "Design", None),
("content", "Content", None),
(
"seo-foundation",
"SEO foundation",
"Tool-agnostic SEO skills. These define the conceptual frameworks. "
"The SEO audit suite below adds the Ahrefs MCP-powered execution layer.",
),
(
"seo-audit-suite",
"SEO audit suite (Ahrefs MCP-powered)",
"End-to-end SEO audit workflows that pull data from the Ahrefs MCP "
"and produce concrete deliverables. These skills assume the Ahrefs "
"MCP is connected.",
),
("product", "Product", None),
("development", "Development", None),
("qa", "Quality assurance", None),
("operations", "Operations", None),
("growth", "Growth", None),
(
"growth-tooling",
"Growth tooling",
"Interactive web tools that turn visitors into leads. Lead "
"magnets, calculators, quizzes, multi-step forms, chatbots, "
"and the cross-tool funnel architecture that orchestrates them.",
),
(
"marketing",
"Marketing",
"Paid media discipline: strategy, creative, and performance "
"analytics. Pairs with the paid media platforms in the "
"/integrations catalog at rampstack.co.",
),
("research", "Research", None),
("cross-cutting", "Cross-cutting workflows", None),
("process-and-team", "Process and team", None),
]
CATEGORY_IDS = {cid for cid, _, _ in CATEGORIES}
MARKERS = [
"COUNT_INTRO",
"COUNT_WHATYOUGET",
"COUNT_TOC",
"FEATURED_SKILLS",
"SURFACES",
"COUNT_CATALOG_HEADER",
"COUNT_CATALOG_INTRO",
"CATALOG",
]
# Featured skills section: one entry-point skill per audience track.
# These six map to the audience tracks that the rampstack.co landing
# surfaces are organized around. Order matches the homepage suite tiles.
# Tuple shape: (track_label, slug, one-line blurb).
FEATURED_SKILLS_DATA: list[tuple[str, str, str]] = [
(
"Brand and creative",
"creative-direction",
"Four-axis brief (tone, aesthetic, audience, sensory ambition) that gives every downstream skill a coherent direction",
),
(
"PM, experimentation",
"experiment-design",
"From hypothesis to decision: sample size, duration, segment analysis, and the failure modes that produce wrong shipping calls",
),
(
"PM, gap-closing",
"feature-launch-playbook",
"The discipline of launching a feature well: positioning, internal alignment, customer comms, enablement, rollout, monitoring",
),
(
"Content",
"pillar-content-architecture",
"Hub-and-cluster topical authority: pillar selection, cluster planning, internal linking, refresh discipline",
),
(
"Marketing",
"landing-page-copy",
"Landing pages, sales pages, hero-to-CTA flow with copy that converts",
),
(
"Growth tooling",
"funnel-flow-architecture",
"Cross-tool conversion flows architected to match the audience and the funnel stage",
),
]
# Surfaces section: explains the layered offering (open-source methodology +
# commercial flagship surfaces at rampstack.co). Static content; the URLs
# do not change with each catalog update.
SURFACES_CONTENT = """\
This catalog is the open-source methodology layer. Commercial surfaces at [rampstack.co](https://rampstack.co) extend it:
- **[Skills directory](https://rampstack.co/skills)**. Every skill on a curated landing surface with audience tracks, search, and category navigation.
- **[Walkthroughs](https://rampstack.co/walkthroughs)**. Multi-skill recipes that orchestrate skill clusters end-to-end. Use these when one skill is not enough and a packaged sequence is.
- **[Integrations directory](https://rampstack.co/integrations)**. Curated MCPs, APIs, and tooling that the skills hook into.
- **[Showcase](https://rampstack.co/showcase)**. Real brand sites built from these skills, with the brief that produced each one.
The skills in this repository remain free, open-source, and stack-agnostic. The surfaces above are how the same methodology is delivered as a product."""
@dataclass(frozen=True)
class Skill:
slug: str
name: str
description: str
category: str
catalog_summary: str
display_order: int | None
def parse_frontmatter(skill_md: Path) -> dict:
"""Read SKILL.md and return its YAML frontmatter as a dict."""
text = skill_md.read_text(encoding="utf-8")
match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)$", text, re.DOTALL)
if not match:
raise ValueError(f"{skill_md}: no YAML frontmatter delimited by --- markers")
data = yaml.safe_load(match.group(1))
if not isinstance(data, dict):
raise ValueError(f"{skill_md}: frontmatter is not a YAML dict")
return data
def load_skills() -> list[Skill]:
"""Walk SKILLS_DIR and return validated Skill records.
Raises SystemExit with a list of validation errors if any skill is
missing required fields or has an unknown category.
"""
skills: list[Skill] = []
errors: list[str] = []
for entry in sorted(SKILLS_DIR.iterdir()):
if not entry.is_dir():
continue
skill_md = entry / "SKILL.md"
if not skill_md.exists():
continue
try:
fm = parse_frontmatter(skill_md)
except ValueError as exc:
errors.append(str(exc))
continue
name = fm.get("name")
description = fm.get("description")
category = fm.get("category")
catalog_summary = fm.get("catalog_summary")
display_order = fm.get("display_order")
missing = [
label
for label, value in (
("name", name),
("description", description),
("category", category),
("catalog_summary", catalog_summary),
)
if not value
]
if missing:
errors.append(
f"{entry.name}: missing frontmatter fields: {', '.join(missing)}"
)
continue
if category not in CATEGORY_IDS:
allowed = ", ".join(sorted(CATEGORY_IDS))
errors.append(
f"{entry.name}: unknown category '{category}'. "
f"Allowed values: {allowed}"
)
continue
desc_len = len(str(description))
if desc_len > 1024:
errors.append(
f"{entry.name}: description is {desc_len} characters, "
f"exceeds the 1024-character portability cap (Codex CLI, Pi, "
f"and Antigravity reject longer descriptions)"
)
continue
skills.append(
Skill(
slug=entry.name,
name=str(name),
description=str(description),
category=str(category),
catalog_summary=str(catalog_summary),
display_order=int(display_order) if display_order is not None else None,
)
)
if errors:
raise SystemExit("Skill frontmatter validation failed:\n " + "\n ".join(errors))
return skills
def group_by_category(skills: list[Skill]) -> dict[str, list[Skill]]:
"""Bucket skills by category id and sort within each bucket.
Sort key: (display_order ascending, slug ascending). Skills without a
display_order sort to the end alphabetically.
"""
grouped: dict[str, list[Skill]] = {cid: [] for cid, _, _ in CATEGORIES}
for skill in skills:
grouped[skill.category].append(skill)
sentinel = 10_000
for items in grouped.values():
items.sort(
key=lambda s: (s.display_order if s.display_order is not None else sentinel, s.slug)
)
return grouped
def count_reference_files() -> int:
"""Count files under any skills/*/references/ directory, at any depth.
Recurses into nested reference subdirectories (for example
references/by-vertical/) so every shipped reference file is counted,
including the ones below the top level of references/.
"""
total = 0
for entry in sorted(SKILLS_DIR.iterdir()):
if not entry.is_dir():
continue
ref_dir = entry / "references"
if not ref_dir.exists():
continue
for ref in ref_dir.rglob("*"):
if ref.is_file():
total += 1
return total
def generate_catalog(grouped: dict[str, list[Skill]]) -> str:
"""Build the full catalog markdown block: section headers and tables."""
lines: list[str] = []
for index, (cid, title, intro) in enumerate(CATEGORIES):
items = grouped.get(cid, [])
lines.append(f"### {title} ({len(items)})")
lines.append("")
if intro:
lines.append(intro)
lines.append("")
lines.append("| Skill | What it does |")
lines.append("|---|---|")
for skill in items:
lines.append(
f"| [`{skill.slug}`](skills/{skill.slug}/SKILL.md) | "
f"{skill.catalog_summary} |"
)
if index < len(CATEGORIES) - 1:
lines.append("")
return "\n".join(lines)
def generate_badge(total: int) -> str:
return f"[![Skills](https://img.shields.io/badge/Skills-{total}-blue.svg)](#the-{total}-skill-catalog)"
def generate_intro_blockquote(total: int) -> str:
return (
f"> {total} stack-agnostic skills covering brand, design, content, "
"SEO, dev, ops, growth, and research. Includes an Ahrefs MCP-powered "
"SEO audit suite. Use them on Next.js, WordPress, Shopify, Webflow, "
"plain HTML, or anything else."
)
def generate_whatyouget(total: int, ref_total: int, cat_total: int) -> str:
return (
f"- **{total} skills** across {cat_total} categories, every one "
"with a complete `SKILL.md` and at least one reference file\n"
f"- **{ref_total} reference files** (templates, checklists, decision "
"matrices, worked examples)"
)
def generate_toc_link(total: int) -> str:
return f"- [The {total}-skill catalog](#the-{total}-skill-catalog)"
def generate_catalog_header(total: int) -> str:
return f"## The {total}-skill catalog"
def generate_catalog_intro(total: int) -> str:
return (
f"All {total} skills are shipped. Each has a complete SKILL.md "
"plus at least one reference file (template, checklist, or playbook)."
)
def generate_featured_skills(skills_by_slug: dict[str, Skill]) -> str:
"""Build the Featured skills section as an intro line + markdown table."""
missing = [slug for _, slug, _ in FEATURED_SKILLS_DATA if slug not in skills_by_slug]
if missing:
raise ValueError(
"Featured skills not found in catalog: " + ", ".join(missing)
)
lines = [
"Six entry-point skills, one per audience track. Run any of these standalone, or compose them with the rest of the catalog.",
"",
"| Skill | What it does |",
"|---|---|",
]
for track, slug, blurb in FEATURED_SKILLS_DATA:
lines.append(f"| [`{slug}`](skills/{slug}/SKILL.md) ({track}) | {blurb} |")
return "\n".join(lines)
def generate_surfaces() -> str:
"""Return the static Surfaces section body."""
return SURFACES_CONTENT
def replace_block(text: str, marker: str, new_content: str) -> str:
"""Replace the body between a START/END marker pair.
The replacement body is sandwiched by single newlines, producing::
<!-- MARKER:START -->
{new_content}
<!-- MARKER:END -->
"""
pattern = re.compile(
rf"(<!-- {re.escape(marker)}:START -->)(.*?)(<!-- {re.escape(marker)}:END -->)",
re.DOTALL,
)
matches = list(pattern.finditer(text))
if not matches:
raise ValueError(f"Marker pair '{marker}' not found in README.md")
if len(matches) > 1:
raise ValueError(f"Marker pair '{marker}' appears more than once in README.md")
replacement = f"\\1\n{new_content}\n\\3"
return pattern.sub(replacement, text, count=1)
def replace_badge_count(text: str, total: int) -> str:
"""Substitute the skill count in the Skills badge markdown.
The badge sits inline inside a centered div alongside other badges.
A line that begins with an HTML comment triggers CommonMark's HTML
block (type 2) parsing, which prevents inline markdown from being
parsed on that line. Wrapping the badge with marker comments breaks
the badge rendering. Anchor on the unique full-badge markdown
pattern instead, so the substitution touches only the badge in the
centered div and leaves the TOC link and catalog header (which
have their own marker-based substitution) alone.
"""
badge_pattern = re.compile(
r"\[!\[Skills\]\(https://img\.shields\.io/badge/Skills-(\d+)-blue\.svg\)\]"
r"\(#the-(\d+)-skill-catalog\)"
)
matches = list(badge_pattern.finditer(text))
if not matches:
raise ValueError("Skills badge markdown pattern not found in README.md")
if len(matches) > 1:
raise ValueError("Skills badge markdown pattern appears more than once in README.md")
replacement = (
f"[![Skills](https://img.shields.io/badge/Skills-{total}-blue.svg)]"
f"(#the-{total}-skill-catalog)"
)
return badge_pattern.sub(replacement, text, count=1)
def replace_inline_count(text: str, pattern: str, total: int, label: str) -> str:
"""Substitute a skill count embedded in prose or a code fence.
The pattern must capture the text before the count as group 1 and the
text after as group 2, with the digits ungrouped between them. Asserts
the pattern matches exactly once so a drifted or duplicated anchor fails
loudly rather than silently mis-substituting.
"""
rx = re.compile(pattern)
matches = list(rx.finditer(text))
if not matches:
raise ValueError(f"{label}: count pattern not found in README.md")
if len(matches) > 1:
raise ValueError(f"{label}: count pattern appears more than once in README.md")
return rx.sub(rf"\g<1>{total}\g<2>", text, count=1)
def render_readme(text: str, skills: list[Skill]) -> str:
"""Apply all marker replacements and return the updated README content."""
grouped = group_by_category(skills)
skills_by_slug = {s.slug: s for s in skills}
total = len(skills)
ref_total = count_reference_files()
cat_total = len(CATEGORIES)
text = replace_badge_count(text, total)
text = replace_inline_count(text, r"(# full catalog \()\d+( skills\))", total, "install-block count")
text = replace_inline_count(text, r"(You do not have to install all )\d+(\.)", total, "install-all count")
text = replace_inline_count(text, r"(already uses\. )\d+( skills at the center)", total, "hub count")
text = replace_inline_count(text, r"(Full catalog \(you are here\) \| )\d+( \|)", total, "family-table count")
text = replace_inline_count(text, r"(authoring discipline used across all )\d+( skills)", total, "contributing count")
text = replace_block(text, "COUNT_INTRO", generate_intro_blockquote(total))
text = replace_block(
text, "COUNT_WHATYOUGET", generate_whatyouget(total, ref_total, cat_total)
)
text = replace_block(text, "COUNT_TOC", generate_toc_link(total))
text = replace_block(text, "FEATURED_SKILLS", generate_featured_skills(skills_by_slug))
text = replace_block(text, "SURFACES", generate_surfaces())
text = replace_block(text, "COUNT_CATALOG_HEADER", generate_catalog_header(total))
text = replace_block(text, "COUNT_CATALOG_INTRO", generate_catalog_intro(total))
text = replace_block(text, "CATALOG", generate_catalog(grouped))
return text
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate README catalog content from skill folder metadata."
)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument(
"--check",
action="store_true",
help="Exit non-zero with a diff if README is stale.",
)
mode.add_argument(
"--write",
action="store_true",
help="Update README.md in place between marker comments.",
)
args = parser.parse_args()
if not SKILLS_DIR.exists():
print(f"ERROR: skills/ directory not found at {SKILLS_DIR}", file=sys.stderr)
return 1
try:
skills = load_skills()
except SystemExit as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
current = README.read_text(encoding="utf-8")
try:
updated = render_readme(current, skills)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if args.check:
if current == updated:
return 0
diff = unified_diff(
current.splitlines(keepends=True),
updated.splitlines(keepends=True),
fromfile="README.md (current)",
tofile="README.md (generated)",
)
sys.stdout.writelines(diff)
print(
"\nREADME catalog is stale. "
"Run: python scripts/generate_readme_catalog.py --write"
)
return 2
# Write LF newlines explicitly so --write does not flip the file to
# CRLF on Windows, which would churn every line in the diff.
README.write_text(updated, encoding="utf-8", newline="\n")
ref_total = count_reference_files()
print(
f"Updated {len(MARKERS)} sections in README.md, "
f"{len(skills)} skills, {ref_total} reference files"
)
return 0
if __name__ == "__main__":
sys.exit(main())