From ecf4445a6bf60af4cda6010b54293982eb9b9d36 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:04:35 -0700 Subject: [PATCH 1/7] fix(seo): resolve Search Console issues and add category hubs - Absolute canonical/OG/breadcrumb/JSON-LD URLs across routes; Dataset creator+license - Category-aware entry schema; strip relative-href links from rendered content (404 source) - robots Disallow /api,/data,/downloads,/_next + Content-Signal; non-prod host noindex - Per-entry + per-category sitemap lastmod; reclaim / as indexable hubs - browse invalid-category 301; /search->/browse and /jobs/post canonicals Co-Authored-By: Claude Opus 4.8 --- apps/web/src/lib/detail-assembly.ts | 27 ++- apps/web/src/lib/robots-policy.ts | 18 ++ apps/web/src/lib/security-headers.ts | 36 ++- apps/web/src/routes/$category.tsx | 224 ++++++++++++++++-- apps/web/src/routes/best.$slug.tsx | 5 +- apps/web/src/routes/best.tsx | 5 +- apps/web/src/routes/browse.tsx | 25 +- apps/web/src/routes/changelog.tsx | 5 +- apps/web/src/routes/compare.tsx | 3 +- apps/web/src/routes/ecosystem.tsx | 7 +- apps/web/src/routes/entry.$category.$slug.tsx | 43 +++- apps/web/src/routes/feeds.tsx | 3 +- apps/web/src/routes/index.tsx | 9 +- apps/web/src/routes/jobs.$slug.tsx | 3 +- apps/web/src/routes/jobs.index.tsx | 5 +- apps/web/src/routes/jobs.post.tsx | 4 + apps/web/src/routes/quality.tsx | 5 +- apps/web/src/routes/search.ts | 17 ++ apps/web/src/routes/sitemap[.]xml.ts | 26 +- apps/web/src/routes/subscriptions.tsx | 3 +- apps/web/src/routes/validators.tsx | 25 +- apps/web/src/server.ts | 7 +- 22 files changed, 441 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/routes/search.ts diff --git a/apps/web/src/lib/detail-assembly.ts b/apps/web/src/lib/detail-assembly.ts index 443b4fc382..cf2f7ae086 100644 --- a/apps/web/src/lib/detail-assembly.ts +++ b/apps/web/src/lib/detail-assembly.ts @@ -84,14 +84,25 @@ function sanitizeRenderedHtml(html: string) { img: ["https"], }, transformTags: { - a: (_tagName, attribs) => ({ - tagName: "a", - attribs: { - ...attribs, - rel: "nofollow noopener noreferrer", - target: "_blank", - }, - }), + a: (_tagName, attribs) => { + // Drop relative / scheme-less anchors. GFM autolinking turns bare paths in entry + // content (e.g. ".claude/hooks/foo.sh", "/utils/trpc") into site-relative links that + // Google then crawls as 404s. Real external URLs (http/https/mailto) stay linked. + const href = String(attribs.href ?? ""); + if (!/^(https?:|mailto:)/i.test(href)) { + // Unwrap to a non-anchor (span is not in allowedTags, so sanitize-html drops the tag + // but keeps the text) — avoids leaving an orphaned, destination-less in the DOM. + return { tagName: "span", attribs: {} }; + } + return { + tagName: "a", + attribs: { + ...attribs, + rel: "nofollow noopener noreferrer", + target: "_blank", + }, + }; + }, }, }); } diff --git a/apps/web/src/lib/robots-policy.ts b/apps/web/src/lib/robots-policy.ts index 8e646a1f6a..d253c6b475 100644 --- a/apps/web/src/lib/robots-policy.ts +++ b/apps/web/src/lib/robots-policy.ts @@ -1,11 +1,20 @@ import { siteConfig } from "@/lib/site"; +// Machine endpoints and generated artifacts should not be crawled: they waste crawl budget +// and surface as "crawled - not indexed" / 404 noise in Search Console. +const DISALLOW_PATHS = ["/api/", "/data/", "/downloads/", "/_next/"]; + +// AI content-usage preferences (contentsignals.org / draft-romm-aipref-contentsignals). +// Fully open: appear in search + AI answers and allow training. +const CONTENT_SIGNAL = "ai-train=yes, search=yes, ai-input=yes"; + export function getRobotsPolicy() { return { rules: [ { userAgent: "*", allow: "/", + disallow: DISALLOW_PATHS, }, { userAgent: [ @@ -17,8 +26,10 @@ export function getRobotsPolicy() { "Google-Extended", ], allow: "/", + disallow: DISALLOW_PATHS, }, ], + contentSignal: CONTENT_SIGNAL, sitemap: `${siteConfig.url}/sitemap.xml`, host: new URL(siteConfig.url).host, }; @@ -33,6 +44,13 @@ export function renderRobotsTxt() { lines.push(`User-agent: ${userAgent}`); } lines.push(`Allow: ${rule.allow}`); + for (const path of rule.disallow ?? []) { + lines.push(`Disallow: ${path}`); + } + // Content-Signal applies to all crawlers — emit it once, under the catch-all group. + if (policy.contentSignal && rule.userAgent === "*") { + lines.push(`Content-Signal: ${policy.contentSignal}`); + } lines.push(""); } lines.push(`Sitemap: ${policy.sitemap}`); diff --git a/apps/web/src/lib/security-headers.ts b/apps/web/src/lib/security-headers.ts index d130aab547..32c7314551 100644 --- a/apps/web/src/lib/security-headers.ts +++ b/apps/web/src/lib/security-headers.ts @@ -56,10 +56,44 @@ const SECURITY_HEADERS = { "x-frame-options": "DENY", } as const; -export function applySecurityHeaders(headers: Headers) { +// Non-production hosts (preview/staging) must not be indexed — otherwise Google treats +// e.g. dev.heyclau.de as duplicate content competing with the canonical production site. +function isNonProdHost(hostname: string) { + return ( + hostname.startsWith("dev.") || + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.includes("staging") || + hostname.endsWith(".workers.dev") + ); +} + +// RFC 8288 Link header advertising agent-discovery resources from every HTML page. +const AGENT_LINK_HEADER = [ + `<${siteConfig.url}/.well-known/api-catalog>; rel="api-catalog"`, + `<${siteConfig.url}/openapi.json>; rel="service-desc"; type="application/json"`, + `<${siteConfig.url}/api-docs>; rel="service-doc"; type="text/html"`, + `<${siteConfig.url}/.well-known/mcp/server-card.json>; rel="related"; title="MCP server card"`, + `<${siteConfig.url}/.well-known/agent-skills/index.json>; rel="related"; title="Agent skills index"`, +].join(", "); + +export function applySecurityHeaders(headers: Headers, request?: Request) { for (const [name, value] of Object.entries(SECURITY_HEADERS)) { if (!headers.has(name)) headers.set(name, value); } + if ((headers.get("content-type") ?? "").includes("text/html") && !headers.has("link")) { + headers.set("link", AGENT_LINK_HEADER); + } + if (request) { + try { + const { hostname } = new URL(request.url); + if (isNonProdHost(hostname)) { + headers.set("x-robots-tag", "noindex, follow"); + } + } catch { + // Malformed request URL — leave indexing headers untouched. + } + } return headers; } diff --git a/apps/web/src/routes/$category.tsx b/apps/web/src/routes/$category.tsx index 02b1f1e0cf..46a400cd8f 100644 --- a/apps/web/src/routes/$category.tsx +++ b/apps/web/src/routes/$category.tsx @@ -1,21 +1,213 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; -import { CATEGORIES } from "@/types/registry"; +import { createFileRoute, Link, notFound } from "@tanstack/react-router"; +import { ArrowRight } from "lucide-react"; +import { CATEGORIES, type Category } from "@/types/registry"; +import { search } from "@/data/search"; +import { + categoryLabels, + categoryDescriptions, + categorySeoDescriptions, + categoryUsageHints, + categoryQuickstarts, +} from "@/lib/site"; +import { ResourceCard } from "@/components/resource-card"; +import { Breadcrumbs } from "@/components/breadcrumbs"; +import { NewsletterInline } from "@/components/newsletter-inline"; +import { stringifyJsonLd } from "@/lib/json-ld"; +import { absoluteUrl } from "@/lib/seo"; -const categoryIds = new Set(CATEGORIES.map((category) => category.id)); +const categoryIds = new Set(CATEGORIES.map((c) => c.id)); + +// Reuse the canonical registry ranking (recommendedScore) so hub order matches /browse. +function topEntriesFor(id: string) { + return search({ categories: [id as Category] }); +} + +function faqFor(id: string, label: string) { + return [ + { + q: `What are Claude ${label}?`, + a: + categoryDescriptions[id] ?? + `Claude ${label} are community-submitted resources curated in the HeyClaude directory.`, + }, + { + q: `How do I use ${label} from HeyClaude?`, + a: + categoryUsageHints[id] ?? + `Open any entry to see install or usage details, copy the snippet or config, and review the linked source before adding it to your Claude setup.`, + }, + { + q: `Are HeyClaude ${label} reviewed before they are listed?`, + a: "Each entry is metadata-reviewed for source, trust, and safety/privacy signals before it appears. HeyClaude does not scan for malware, so always review the linked source before installing anything that touches your filesystem, network, or credentials.", + }, + ]; +} export const Route = createFileRoute("/$category")({ - loader: ({ params, location }) => { - const pathSegments = location.pathname.split("/").filter(Boolean); - if (pathSegments.length > 1) return null; - - if (!categoryIds.has(params.category as never)) { - throw redirect({ to: "/browse", replace: true, statusCode: 301 }); - } - throw redirect({ - to: "/browse", - search: { category: params.category }, - replace: true, - statusCode: 301, - }); + loader: ({ params }) => { + if (!categoryIds.has(params.category as never)) throw notFound(); + return {}; + }, + head: ({ params }) => { + const id = params.category; + if (!categoryIds.has(id as never)) return { meta: [] }; + const label = categoryLabels[id] ?? id; + const path = `/${id}`; + const url = absoluteUrl(path); + const entries = topEntriesFor(id); + const title = `Claude ${label} — HeyClaude directory`; + const description = + categorySeoDescriptions[id] ?? + categoryDescriptions[id] ?? + `Browse ${entries.length} source-backed Claude ${label} in the HeyClaude directory.`; + + const itemList = { + "@context": "https://schema.org", + "@type": "ItemList", + name: `Claude ${label}`, + description, + numberOfItems: entries.length, + itemListElement: entries.slice(0, 30).map((e, i) => ({ + "@type": "ListItem", + position: i + 1, + name: e.title, + url: absoluteUrl(`/entry/${e.category}/${e.slug}`), + })), + }; + const breadcrumbs = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Directory", item: absoluteUrl("/browse") }, + { "@type": "ListItem", position: 2, name: label, item: url }, + ], + }; + const faq = { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: faqFor(id, label).map((item) => ({ + "@type": "Question", + name: item.q, + acceptedAnswer: { "@type": "Answer", text: item.a }, + })), + }; + + return { + meta: [ + { title }, + { name: "description", content: description }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: url }, + { property: "og:type", content: "website" }, + { name: "twitter:card", content: "summary_large_image" }, + ], + links: [{ rel: "canonical", href: url }], + scripts: [ + { type: "application/ld+json", children: stringifyJsonLd(itemList) }, + { type: "application/ld+json", children: stringifyJsonLd(breadcrumbs) }, + { type: "application/ld+json", children: stringifyJsonLd(faq) }, + ], + }; }, + component: CategoryHub, + notFoundComponent: () => ( +
+

Category not found

+

+ That category doesn't exist. Browse the full HeyClaude directory instead. +

+ + Browse all + +
+ ), }); + +function CategoryHub() { + const { category: id } = Route.useParams(); + const label = categoryLabels[id] ?? id; + const entries = topEntriesFor(id); + const top = entries.slice(0, 24); + const quickstart = categoryQuickstarts[id] ?? []; + const faqs = faqFor(id, label); + + return ( +
+ + +
+
{entries.length} entries
+

Claude {label}

+

+ {categorySeoDescriptions[id] ?? categoryDescriptions[id]} +

+
+ + Browse & filter all {label} + +
+ {quickstart.length > 0 && ( +
+
Quick start
+
    + {quickstart.map((step) => ( +
  • + + {step} +
  • + ))} +
+
+ )} +
+ +
+

Top {label}

+
+ {top.map((e) => ( + + ))} +
+ {entries.length > top.length && ( +
+ + See all {entries.length} {label} → + +
+ )} +
+ +
+

Frequently asked

+
+ {faqs.map((item) => ( +
+
{item.q}
+
{item.a}
+
+ ))} +
+
+ + +
+ ); +} diff --git a/apps/web/src/routes/best.$slug.tsx b/apps/web/src/routes/best.$slug.tsx index 3cd890dc35..604667570d 100644 --- a/apps/web/src/routes/best.$slug.tsx +++ b/apps/web/src/routes/best.$slug.tsx @@ -5,6 +5,7 @@ import type { Entry } from "@/types/registry"; import { ResourceCard } from "@/components/resource-card"; import { NewsletterInline } from "@/components/newsletter-inline"; import { stringifyJsonLd } from "@/lib/json-ld"; +import { absoluteUrl } from "@/lib/seo"; export const Route = createFileRoute("/best/$slug")({ loader: ({ params }) => { @@ -15,7 +16,7 @@ export const Route = createFileRoute("/best/$slug")({ head: ({ params, loaderData }) => { if (!loaderData) return { meta: [] }; const l = loaderData.list; - const url = `/best/${params.slug}`; + const url = absoluteUrl(`/best/${params.slug}`); const ld = { "@context": "https://schema.org", "@type": "ItemList", @@ -25,7 +26,7 @@ export const Route = createFileRoute("/best/$slug")({ itemListElement: l.picks.map((p, i) => ({ "@type": "ListItem", position: i + 1, - url: `/entry/${p.ref}`, + url: absoluteUrl(`/entry/${p.ref}`), })), }; return { diff --git a/apps/web/src/routes/best.tsx b/apps/web/src/routes/best.tsx index c530ac6357..316563d997 100644 --- a/apps/web/src/routes/best.tsx +++ b/apps/web/src/routes/best.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { ArrowRight } from "lucide-react"; +import { absoluteUrl } from "@/lib/seo"; import { BEST_LISTS, ENTRIES } from "@/data/entries"; import { ResourceCard } from "@/components/resource-card"; @@ -17,9 +18,9 @@ export const Route = createFileRoute("/best")({ property: "og:description", content: "Curated picks for Claude Code, MCP, agents, skills, and more.", }, - { property: "og:url", content: "/best" }, + { property: "og:url", content: absoluteUrl("/best") }, ], - links: [{ rel: "canonical", href: "/best" }], + links: [{ rel: "canonical", href: absoluteUrl("/best") }], }), component: BestPage, }); diff --git a/apps/web/src/routes/browse.tsx b/apps/web/src/routes/browse.tsx index f419780a98..6fc50ddebb 100644 --- a/apps/web/src/routes/browse.tsx +++ b/apps/web/src/routes/browse.tsx @@ -1,5 +1,11 @@ import * as React from "react"; -import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router"; +import { + createFileRoute, + Link, + redirect, + stripSearchParams, + useNavigate, +} from "@tanstack/react-router"; import { z } from "zod"; import { useMemo } from "react"; import { toast } from "sonner"; @@ -31,6 +37,7 @@ import { SavedSearchManager } from "@/components/saved-search-manager"; import { FilterSummaryBar, type ActiveFilter } from "@/components/filter-summary-bar"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { cn } from "@/lib/utils"; +import { absoluteUrl } from "@/lib/seo"; function SavedSearchChipRow({ currentLabel, @@ -134,6 +141,18 @@ export const Route = createFileRoute("/browse")({ search: { middlewares: [stripSearchParams(defaultSearch)], }, + beforeLoad: ({ search }) => { + // Stale/external links with unknown category values (e.g. ?category=plugins) render an + // empty result set that Google flags as a soft 404. Redirect them to clean /browse. + if (search.category && !CATEGORIES.some((c) => c.id === search.category)) { + throw redirect({ + to: "/browse", + search: { ...search, category: "" }, + statusCode: 301, + replace: true, + }); + } + }, head: () => ({ meta: [ { title: "Browse — HeyClaude directory" }, @@ -146,10 +165,10 @@ export const Route = createFileRoute("/browse")({ property: "og:description", content: "Search and filter every resource in the HeyClaude registry.", }, - { property: "og:url", content: "/browse" }, + { property: "og:url", content: absoluteUrl("/browse") }, { name: "twitter:card", content: "summary_large_image" }, ], - links: [{ rel: "canonical", href: "/browse" }], + links: [{ rel: "canonical", href: absoluteUrl("/browse") }], }), component: Browse, }); diff --git a/apps/web/src/routes/changelog.tsx b/apps/web/src/routes/changelog.tsx index 4fdbcbf7e6..11eb1d7a41 100644 --- a/apps/web/src/routes/changelog.tsx +++ b/apps/web/src/routes/changelog.tsx @@ -5,6 +5,7 @@ import { CHANGELOG, RELEASE_NOTES, type ReleaseStream } from "@/data/changelog"; import { FilterChip, FilterChipGroup } from "@/components/filter-chip"; import { cn } from "@/lib/utils"; import { stringifyJsonLd } from "@/lib/json-ld"; +import { absoluteUrl } from "@/lib/seo"; export const Route = createFileRoute("/changelog")({ head: () => ({ @@ -19,12 +20,12 @@ export const Route = createFileRoute("/changelog")({ property: "og:description", content: "What changed in the registry, content policy, and integrity controls.", }, - { property: "og:url", content: "/changelog" }, + { property: "og:url", content: absoluteUrl("/changelog") }, { property: "og:type", content: "article" }, { name: "twitter:card", content: "summary_large_image" }, ], links: [ - { rel: "canonical", href: "/changelog" }, + { rel: "canonical", href: absoluteUrl("/changelog") }, { rel: "alternate", type: "application/rss+xml", diff --git a/apps/web/src/routes/compare.tsx b/apps/web/src/routes/compare.tsx index 74663e9446..90dbce9c42 100644 --- a/apps/web/src/routes/compare.tsx +++ b/apps/web/src/routes/compare.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { createFileRoute, Link, stripSearchParams } from "@tanstack/react-router"; +import { absoluteUrl } from "@/lib/seo"; import { z } from "zod"; import { X, ArrowRight, ExternalLink, Plus, Search as SearchIcon } from "lucide-react"; import { ENTRIES } from "@/data/entries"; @@ -39,7 +40,7 @@ export const Route = createFileRoute("/compare")({ content: "Side-by-side comparison of Claude workflow resources.", }, ], - links: [{ rel: "canonical", href: "/compare" }], + links: [{ rel: "canonical", href: absoluteUrl("/compare") }], }), component: ComparePage, }); diff --git a/apps/web/src/routes/ecosystem.tsx b/apps/web/src/routes/ecosystem.tsx index 11c06349cb..5aa08ad899 100644 --- a/apps/web/src/routes/ecosystem.tsx +++ b/apps/web/src/routes/ecosystem.tsx @@ -18,6 +18,7 @@ import { CopyButton } from "@/components/copy-button"; import { CountUp } from "@/components/count-up"; import { cn } from "@/lib/utils"; import { stringifyJsonLd } from "@/lib/json-ld"; +import { absoluteUrl } from "@/lib/seo"; const CLIENTS = [ { id: "claude-code", label: "Claude Code" }, @@ -195,7 +196,7 @@ export const Route = createFileRoute("/ecosystem")({ "@type": "ListItem", position: i + 1, name: it.name, - url: `/integrations/${it.slug}`, + url: absoluteUrl(`/integrations/${it.slug}`), })), }; return { @@ -211,9 +212,9 @@ export const Route = createFileRoute("/ecosystem")({ property: "og:description", content: "Where the HeyClaude registry runs, how to plug it in, and who's powering it.", }, - { property: "og:url", content: "/ecosystem" }, + { property: "og:url", content: absoluteUrl("/ecosystem") }, ], - links: [{ rel: "canonical", href: "/ecosystem" }], + links: [{ rel: "canonical", href: absoluteUrl("/ecosystem") }], scripts: [{ type: "application/ld+json", children: stringifyJsonLd(ld) }], }; }, diff --git a/apps/web/src/routes/entry.$category.$slug.tsx b/apps/web/src/routes/entry.$category.$slug.tsx index e1d9cdf1ef..b5cf03e295 100644 --- a/apps/web/src/routes/entry.$category.$slug.tsx +++ b/apps/web/src/routes/entry.$category.$slug.tsx @@ -33,6 +33,7 @@ import { WatchButton } from "@/components/watch-button"; import { CopyButton } from "@/components/copy-button"; import { ResourceCard } from "@/components/resource-card"; import { stringifyJsonLd } from "@/lib/json-ld"; +import { absoluteUrl } from "@/lib/seo"; // (HoverChevrons removed — related uses static grid) import { ShareMenu } from "@/components/share-menu"; import { DossierTOC, type TocItem } from "@/components/dossier-toc"; @@ -76,6 +77,38 @@ const loadFullEntry = createServerFn({ method: "GET" }) return buildEntry({ ...entry, bodyHtml, sections }); }); +// Category-aware schema, aligned with the registry's canonical buildEntryJsonLd type policy: +// guides -> TechArticle, code-like (commands/hooks/mcp/statuslines) -> SoftwareSourceCode, +// everything else -> CreativeWork. (The dedicated software-app schema is reserved for tool +// listings with complete offer/app fields, so generic entries never masquerade as apps and +// repo stars are never surfaced as a rating.) +const CODE_LIKE_CATEGORIES = new Set(["commands", "hooks", "mcp", "statuslines"]); +function entrySchema(e: Entry, url: string): Record { + const base = { + "@context": "https://schema.org", + name: e.title, + description: e.description, + url, + datePublished: e.dateAdded, + dateModified: e.reviewedAt ?? e.dateAdded, + author: { "@type": "Person", name: e.author }, + ...(e.sourceUrl ? { sameAs: e.sourceUrl, isBasedOn: e.sourceUrl } : {}), + }; + if (e.category === "guides") { + return { ...base, "@type": "TechArticle", headline: e.title }; + } + if (CODE_LIKE_CATEGORIES.has(e.category)) { + return { + ...base, + "@type": "SoftwareSourceCode", + ...(e.sourceUrl ? { codeRepository: e.sourceUrl } : {}), + programmingLanguage: e.scriptLanguage ?? "Markdown", + runtimePlatform: "Claude Code", + }; + } + return { ...base, "@type": "CreativeWork" }; +} + export const Route = createFileRoute("/entry/$category/$slug")({ loader: async ({ params }): Promise<{ entry: import("@/types/registry").Entry }> => { const fullEntry = await loadFullEntry({ @@ -88,7 +121,8 @@ export const Route = createFileRoute("/entry/$category/$slug")({ head: ({ params, loaderData }) => { if (!loaderData) return { meta: [] }; const e = loaderData.entry; - const url = `/entry/${params.category}/${params.slug}`; + const path = `/entry/${params.category}/${params.slug}`; + const url = absoluteUrl(path); const ld = { "@context": "https://schema.org", "@type": "WebPage", @@ -105,17 +139,17 @@ export const Route = createFileRoute("/entry/$category/$slug")({ "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [ - { "@type": "ListItem", position: 1, name: "Directory", item: "/browse" }, + { "@type": "ListItem", position: 1, name: "Directory", item: absoluteUrl("/browse") }, { "@type": "ListItem", position: 2, name: e.category, - item: `/browse?category=${e.category}`, + item: absoluteUrl(`/${e.category}`), }, { "@type": "ListItem", position: 3, name: e.title, item: url }, ], }; - const ogUrl = `/og/${params.category}/${params.slug}`; + const ogUrl = absoluteUrl(`/og/${params.category}/${params.slug}`); return { meta: [ { title: e.seoTitle ? `${e.seoTitle} — HeyClaude` : `${e.title} — HeyClaude` }, @@ -134,6 +168,7 @@ export const Route = createFileRoute("/entry/$category/$slug")({ scripts: [ { type: "application/ld+json", children: stringifyJsonLd(ld) }, { type: "application/ld+json", children: stringifyJsonLd(breadcrumbs) }, + { type: "application/ld+json", children: stringifyJsonLd(entrySchema(e, url)) }, ], }; }, diff --git a/apps/web/src/routes/feeds.tsx b/apps/web/src/routes/feeds.tsx index 8cb601a079..81ef2a1e0d 100644 --- a/apps/web/src/routes/feeds.tsx +++ b/apps/web/src/routes/feeds.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { Rss } from "lucide-react"; +import { absoluteUrl } from "@/lib/seo"; import { CATEGORIES } from "@/types/registry"; import { CopyButton } from "@/components/copy-button"; import { SubscribeForm } from "@/components/subscribe-form"; @@ -21,7 +22,7 @@ export const Route = createFileRoute("/feeds")({ }, ], links: [ - { rel: "canonical", href: "/feeds" }, + { rel: "canonical", href: absoluteUrl("/feeds") }, { rel: "alternate", type: "application/rss+xml", href: "/feed.xml", title: "HeyClaude" }, { rel: "alternate", type: "application/atom+xml", href: "/atom.xml", title: "HeyClaude" }, ], diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index e2bd1b6302..590a111bbe 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -31,6 +31,7 @@ import { CATEGORIES, type Category } from "@/types/registry"; import { ENTRIES, BRIEF_ISSUES, REGISTRY_GENERATED_AT } from "@/data/entries"; import { search } from "@/data/search"; import { stringifyJsonLd } from "@/lib/json-ld"; +import { absoluteUrl } from "@/lib/seo"; // Pre-computed counts at module scope so SSR + first paint show real numbers. const TRUSTED_COUNT = ENTRIES.filter((e) => e.trust === "trusted").length; @@ -83,9 +84,9 @@ export const Route = createFileRoute("/")({ content: "Source-backed registry of MCP servers, agents, skills, hooks, commands, and rules. Reviewed before installing.", }, - { property: "og:url", content: "/" }, + { property: "og:url", content: absoluteUrl("/") }, ], - links: [{ rel: "canonical", href: "/" }], + links: [{ rel: "canonical", href: absoluteUrl("/") }], scripts: [ { type: "application/ld+json", @@ -262,8 +263,8 @@ function Home() { return (
diff --git a/apps/web/src/routes/jobs.$slug.tsx b/apps/web/src/routes/jobs.$slug.tsx index 2307ca2e1c..af42b673ca 100644 --- a/apps/web/src/routes/jobs.$slug.tsx +++ b/apps/web/src/routes/jobs.$slug.tsx @@ -1,4 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; +import { absoluteUrl } from "@/lib/seo"; import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { NewsletterInline } from "@/components/newsletter-inline"; @@ -39,7 +40,7 @@ export const Route = createFileRoute("/jobs/$slug")({ }, head: ({ params, loaderData }) => { const job = loaderData?.job; - const url = `/jobs/${params.slug}`; + const url = absoluteUrl(`/jobs/${params.slug}`); return { meta: [ { diff --git a/apps/web/src/routes/jobs.index.tsx b/apps/web/src/routes/jobs.index.tsx index bcac279ed4..ea3a33cf24 100644 --- a/apps/web/src/routes/jobs.index.tsx +++ b/apps/web/src/routes/jobs.index.tsx @@ -1,4 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; +import { absoluteUrl } from "@/lib/seo"; import { createServerFn } from "@tanstack/react-start"; import { useEffect, useMemo, useState } from "react"; import { ArrowUpRight, Search, Sparkles, X } from "lucide-react"; @@ -32,9 +33,9 @@ export const Route = createFileRoute("/jobs/")({ property: "og:description", content: "Source-verified jobs for Claude Code, MCP, and agent workflows.", }, - { property: "og:url", content: "/jobs" }, + { property: "og:url", content: absoluteUrl("/jobs") }, ], - links: [{ rel: "canonical", href: "/jobs" }], + links: [{ rel: "canonical", href: absoluteUrl("/jobs") }], }), errorComponent: ({ error, reset }: ErrorComponentProps) => (
diff --git a/apps/web/src/routes/jobs.post.tsx b/apps/web/src/routes/jobs.post.tsx index 45d81bb536..c0d4462c4b 100644 --- a/apps/web/src/routes/jobs.post.tsx +++ b/apps/web/src/routes/jobs.post.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useState, type FormEvent } from "react"; +import { absoluteUrl } from "@/lib/seo"; import { Check } from "lucide-react"; import { cn } from "@/lib/utils"; import type { JobTier } from "@/types/registry"; @@ -12,7 +13,10 @@ export const Route = createFileRoute("/jobs/post")({ name: "description", content: "Reach developers shipping Claude Code, MCP, and agent workflows.", }, + { property: "og:url", content: absoluteUrl("/jobs/post") }, ], + // ?tier=* variants are duplicates of the same page — consolidate onto the clean URL. + links: [{ rel: "canonical", href: absoluteUrl("/jobs/post") }], }), component: PostJobPage, }); diff --git a/apps/web/src/routes/quality.tsx b/apps/web/src/routes/quality.tsx index 236f010ec8..a90a9e9ba1 100644 --- a/apps/web/src/routes/quality.tsx +++ b/apps/web/src/routes/quality.tsx @@ -1,4 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; +import { absoluteUrl } from "@/lib/seo"; import { ShieldCheck, GitBranch, @@ -31,9 +32,9 @@ export const Route = createFileRoute("/quality")({ property: "og:description", content: "Coverage, improvement queue, and signed artifact contracts.", }, - { property: "og:url", content: "/quality" }, + { property: "og:url", content: absoluteUrl("/quality") }, ], - links: [{ rel: "canonical", href: "/quality" }], + links: [{ rel: "canonical", href: absoluteUrl("/quality") }], }), component: QualityPage, }); diff --git a/apps/web/src/routes/search.ts b/apps/web/src/routes/search.ts new file mode 100644 index 0000000000..a63881b341 --- /dev/null +++ b/apps/web/src/routes/search.ts @@ -0,0 +1,17 @@ +import { createFileRoute } from "@tanstack/react-router"; + +// There is no /search page — search lives at /browse. Legacy links and the WebSite +// SearchAction template surfaced /search?q=... as a soft 404, so 301 it to the real UI. +export const Route = createFileRoute("/search")({ + server: { + handlers: { + GET: ({ request }) => { + const incoming = new URL(request.url); + const target = new URL("/browse", incoming.origin); + const q = incoming.searchParams.get("q"); + if (q) target.searchParams.set("q", q); + return Response.redirect(target.toString(), 301); + }, + }, + }, +}); diff --git a/apps/web/src/routes/sitemap[.]xml.ts b/apps/web/src/routes/sitemap[.]xml.ts index c2db703c9e..a536bea828 100644 --- a/apps/web/src/routes/sitemap[.]xml.ts +++ b/apps/web/src/routes/sitemap[.]xml.ts @@ -17,8 +17,8 @@ function escapeXml(value: string) { .replaceAll("'", "'"); } -function urlItem(pathname: string, priority: string, changefreq = "weekly") { - const lastmod = String(atlasRegistry.generatedAt || "").slice(0, 10); +function urlItem(pathname: string, priority: string, changefreq = "weekly", lastmodInput?: string) { + const lastmod = String(lastmodInput || atlasRegistry.generatedAt || "").slice(0, 10); return [ " ", ` ${escapeXml(`${siteConfig.url}${pathname}`)}`, @@ -63,7 +63,6 @@ async function renderSitemap() { "/feed.xml", "/atom.xml", "/feeds/trending.xml", - "/data/feeds/index.json", ]; const feedPaths = [ ...CATEGORIES.map((category) => `/feeds/${category.id}.xml`), @@ -72,7 +71,14 @@ async function renderSitemap() { "/feeds/changelog-security.xml", ]; const bestPaths = BEST_LISTS.map((list) => `/best/${list.slug}`); - const entryPaths = ENTRIES.map((entry) => `/entry/${entry.category}/${entry.slug}`); + // Latest content date per category, so hub lastmod reflects real updates, not every rebuild. + const categoryLastmod = new Map(); + for (const entry of ENTRIES) { + const date = String(entry.reviewedAt ?? entry.dateAdded ?? "").slice(0, 10); + if (!date) continue; + const current = categoryLastmod.get(entry.category); + if (!current || date > current) categoryLastmod.set(entry.category, date); + } const contributorPaths = CONTRIBUTORS.map((contributor) => `/contributors/${contributor.slug}`); const integrationPaths = INTEGRATIONS.map((integration) => `/integrations/${integration.slug}`); const jobPaths = (await getJobs()).map((job) => `/jobs/${job.slug}`); @@ -80,8 +86,18 @@ async function renderSitemap() { const rows = [ ...staticPaths.map((pathname) => urlItem(pathname, pathname === "" ? "1" : "0.7")), ...feedPaths.map((pathname) => urlItem(pathname, "0.4")), + ...CATEGORIES.map((category) => + urlItem(`/${category.id}`, "0.8", "weekly", categoryLastmod.get(category.id)), + ), ...bestPaths.map((pathname) => urlItem(pathname, "0.75")), - ...entryPaths.map((pathname) => urlItem(pathname, "0.8", "monthly")), + ...ENTRIES.map((entry) => + urlItem( + `/entry/${entry.category}/${entry.slug}`, + "0.8", + "monthly", + entry.reviewedAt ?? entry.dateAdded, + ), + ), ...contributorPaths.map((pathname) => urlItem(pathname, "0.5", "monthly")), ...integrationPaths.map((pathname) => urlItem(pathname, "0.6", "monthly")), ...jobPaths.map((pathname) => urlItem(pathname, "0.6", "daily")), diff --git a/apps/web/src/routes/subscriptions.tsx b/apps/web/src/routes/subscriptions.tsx index d1cd720b05..553e02a212 100644 --- a/apps/web/src/routes/subscriptions.tsx +++ b/apps/web/src/routes/subscriptions.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; +import { absoluteUrl } from "@/lib/seo"; import { Bell, Mail, Rss, Trash2, Pencil, Check, X, MailX, Compass } from "lucide-react"; import { useRecents } from "@/lib/recents"; import { SavedSearchManager } from "@/components/saved-search-manager"; @@ -32,7 +33,7 @@ export const Route = createFileRoute("/subscriptions")({ content: "Followed categories, email segments, and saved-search alerts.", }, ], - links: [{ rel: "canonical", href: "/subscriptions" }], + links: [{ rel: "canonical", href: absoluteUrl("/subscriptions") }], }), component: SubscriptionsPage, }); diff --git a/apps/web/src/routes/validators.tsx b/apps/web/src/routes/validators.tsx index 8c9efecd2c..6d0b5a75d3 100644 --- a/apps/web/src/routes/validators.tsx +++ b/apps/web/src/routes/validators.tsx @@ -11,6 +11,9 @@ import { import { CategoryPill, SourceBadge, TrustBadge } from "@/components/badges"; import { FilterChip, FilterChipGroup } from "@/components/filter-chip"; import { stringifyJsonLd } from "@/lib/json-ld"; +import { absoluteUrl } from "@/lib/seo"; +import { siteConfig } from "@/lib/site"; +import atlasRegistry from "@/generated/atlas-registry.json"; export const Route = createFileRoute("/validators")({ head: () => ({ @@ -27,10 +30,10 @@ export const Route = createFileRoute("/validators")({ content: "Coverage dashboards and local validation tools for source, safety, privacy, and install metadata.", }, - { property: "og:url", content: "/validators" }, + { property: "og:url", content: absoluteUrl("/validators") }, { name: "twitter:card", content: "summary_large_image" }, ], - links: [{ rel: "canonical", href: "/validators" }], + links: [{ rel: "canonical", href: absoluteUrl("/validators") }], scripts: [ { type: "application/ld+json", @@ -40,7 +43,23 @@ export const Route = createFileRoute("/validators")({ name: "HeyClaude maintainer review coverage", description: "Registry coverage metrics for source-backed entries, review status, safety notes, and privacy notes.", - url: "/validators", + url: absoluteUrl("/validators"), + isAccessibleForFree: true, + license: "https://opensource.org/licenses/MIT", + creator: { + "@type": "Organization", + name: siteConfig.name, + url: siteConfig.url, + }, + datePublished: String(atlasRegistry.generatedAt || "").slice(0, 10), + dateModified: String(atlasRegistry.generatedAt || "").slice(0, 10), + keywords: [ + "Claude", + "registry", + "review coverage", + "safety metadata", + "privacy metadata", + ], }), }, ], diff --git a/apps/web/src/server.ts b/apps/web/src/server.ts index b672f14dd3..dfa2916e34 100644 --- a/apps/web/src/server.ts +++ b/apps/web/src/server.ts @@ -22,8 +22,8 @@ async function getServerEntry(): Promise { // h3 swallows in-handler throws into a normal 500 Response with body // {"unhandled":true,"message":"HTTPError"} — try/catch alone never fires for those. -function withSecurityHeaders(response: Response): Response { - const headers = applySecurityHeaders(new Headers(response.headers)); +function withSecurityHeaders(response: Response, request: Request): Response { + const headers = applySecurityHeaders(new Headers(response.headers), request); return new Response(response.body, { status: response.status, statusText: response.statusText, @@ -54,7 +54,7 @@ export default { try { const handler = await getServerEntry(); const response = await handler.fetch(request, env, ctx); - return withSecurityHeaders(await normalizeCatastrophicSsrResponse(response)); + return withSecurityHeaders(await normalizeCatastrophicSsrResponse(response), request); } catch (error) { console.error(error); return withSecurityHeaders( @@ -62,6 +62,7 @@ export default { status: 500, headers: { "content-type": "text/html; charset=utf-8" }, }), + request, ); } }); From 788a2eecd67611e21cf49477bd63a52b5ebf3bd4 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:04:48 -0700 Subject: [PATCH 2/7] feat(agents): AI agent discovery surfaces - /.well-known/api-catalog (RFC 9727), /.well-known/mcp/server-card.json (SEP-1649) - /.well-known/agent-skills/index.json (checksummed skill packages) - RFC 8288 Link headers advertising the above on HTML responses - WebMCP provider exposing directory search to in-browser agents - docs/agent-discovery.md incl. DNS-AID ops steps Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/webmcp-provider.tsx | 65 +++++++++++++++++ ...[.]well-known.agent-skills.index[.]json.ts | 38 ++++++++++ .../src/routes/[.]well-known.api-catalog.ts | 42 +++++++++++ .../[.]well-known.mcp.server-card[.]json.ts | 61 ++++++++++++++++ apps/web/src/routes/__root.tsx | 2 + docs/agent-discovery.md | 73 +++++++++++++++++++ 6 files changed, 281 insertions(+) create mode 100644 apps/web/src/components/webmcp-provider.tsx create mode 100644 apps/web/src/routes/[.]well-known.agent-skills.index[.]json.ts create mode 100644 apps/web/src/routes/[.]well-known.api-catalog.ts create mode 100644 apps/web/src/routes/[.]well-known.mcp.server-card[.]json.ts create mode 100644 docs/agent-discovery.md diff --git a/apps/web/src/components/webmcp-provider.tsx b/apps/web/src/components/webmcp-provider.tsx new file mode 100644 index 0000000000..122917c1ef --- /dev/null +++ b/apps/web/src/components/webmcp-provider.tsx @@ -0,0 +1,65 @@ +import { useEffect } from "react"; +import { search } from "@/data/search"; +import { absoluteUrl } from "@/lib/seo"; + +// WebMCP (navigator.modelContext) — exposes directory search to in-browser AI agents. +// Experimental (Chrome EPP); no-ops where the API is unavailable. +type WebMcpTool = { + name: string; + description: string; + inputSchema: Record; + execute: ( + args: Record, + ) => Promise<{ content: { type: string; text: string }[] }>; +}; +type ModelContextNavigator = Navigator & { + modelContext?: { provideContext: (ctx: { tools: WebMcpTool[] }) => void }; +}; + +export function WebMcpProvider() { + useEffect(() => { + const nav = navigator as ModelContextNavigator; + if (!nav.modelContext?.provideContext) return; + try { + nav.modelContext.provideContext({ + tools: [ + { + name: "search_heyclaude", + description: + "Search the HeyClaude directory of Claude Code resources (MCP servers, agents, skills, hooks, commands, rules, collections, tools). Returns matching entries with titles, categories, descriptions, and URLs.", + inputSchema: { + type: "object", + properties: { + query: { type: "string", description: "Free-text search query." }, + category: { + type: "string", + description: + "Optional category filter, e.g. mcp, agents, skills, hooks, commands, rules, collections, tools.", + }, + }, + required: ["query"], + }, + async execute(args) { + const query = String(args.query ?? ""); + const category = typeof args.category === "string" ? args.category : ""; + const results = search({ q: query }) + .filter((e) => !category || e.category === category) + .slice(0, 10) + .map((e) => ({ + title: e.title, + category: e.category, + description: e.description, + url: absoluteUrl(`/entry/${e.category}/${e.slug}`), + })); + return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; + }, + }, + ], + }); + } catch { + // WebMCP is experimental — ignore registration failures. + } + }, []); + + return null; +} diff --git a/apps/web/src/routes/[.]well-known.agent-skills.index[.]json.ts b/apps/web/src/routes/[.]well-known.agent-skills.index[.]json.ts new file mode 100644 index 0000000000..95b0667eb5 --- /dev/null +++ b/apps/web/src/routes/[.]well-known.agent-skills.index[.]json.ts @@ -0,0 +1,38 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ENTRIES } from "@/data/entries"; +import { absoluteUrl } from "@/lib/seo"; +import { applySecurityHeaders } from "@/lib/security-headers"; + +// Agent Skills Discovery index (RFC v0.2.0): advertises checksummed skill packages. +function skillsIndex() { + const skills = ENTRIES.filter( + (e) => e.category === "skills" && e.downloadUrl && e.downloadSha256, + ).map((e) => ({ + name: e.title, + type: "skill", + description: e.description, + url: absoluteUrl(`/entry/${e.category}/${e.slug}`), + sha256: e.downloadSha256, + })); + return { + $schema: "https://agentskills.io/schemas/index.json", + skills, + }; +} + +export const Route = createFileRoute("/.well-known/agent-skills/index.json")({ + server: { + handlers: { + GET: async ({ request }) => + new Response(`${JSON.stringify(skillsIndex(), null, 2)}\n`, { + headers: applySecurityHeaders( + new Headers({ + "content-type": "application/json; charset=utf-8", + "cache-control": "public, max-age=3600, stale-while-revalidate=86400", + }), + request, + ), + }), + }, + }, +}); diff --git a/apps/web/src/routes/[.]well-known.api-catalog.ts b/apps/web/src/routes/[.]well-known.api-catalog.ts new file mode 100644 index 0000000000..69bfbd6705 --- /dev/null +++ b/apps/web/src/routes/[.]well-known.api-catalog.ts @@ -0,0 +1,42 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { siteConfig } from "@/lib/site"; +import { applySecurityHeaders } from "@/lib/security-headers"; + +// RFC 9727 API Catalog: lets agents discover the registry API and the MCP endpoint. +function catalog() { + const base = siteConfig.url; + return { + linkset: [ + { + anchor: `${base}/api`, + "service-desc": [{ href: `${base}/openapi.json`, type: "application/json" }], + "service-doc": [{ href: `${base}/api-docs`, type: "text/html" }], + status: [{ href: `${base}/api/public/feeds/health`, type: "application/json" }], + }, + { + anchor: `${base}/api/mcp`, + "service-desc": [ + { href: `${base}/.well-known/mcp/server-card.json`, type: "application/json" }, + ], + "service-doc": [{ href: `${base}/api-docs`, type: "text/html" }], + }, + ], + }; +} + +export const Route = createFileRoute("/.well-known/api-catalog")({ + server: { + handlers: { + GET: async ({ request }) => + new Response(`${JSON.stringify(catalog(), null, 2)}\n`, { + headers: applySecurityHeaders( + new Headers({ + "content-type": "application/linkset+json; charset=utf-8", + "cache-control": "public, max-age=3600, stale-while-revalidate=86400", + }), + request, + ), + }), + }, + }, +}); diff --git a/apps/web/src/routes/[.]well-known.mcp.server-card[.]json.ts b/apps/web/src/routes/[.]well-known.mcp.server-card[.]json.ts new file mode 100644 index 0000000000..5d7bde8fab --- /dev/null +++ b/apps/web/src/routes/[.]well-known.mcp.server-card[.]json.ts @@ -0,0 +1,61 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { siteConfig } from "@/lib/site"; +import { applySecurityHeaders } from "@/lib/security-headers"; + +// MCP Server Card (SEP-1649) for agent discovery of the hosted HeyClaude MCP server. +// Keep MCP_VERSION in sync with packages/mcp/package.json on each MCP release. +const MCP_VERSION = "0.3.0"; +const MCP_TOOLS = [ + "search_registry", + "search_duplicate_entries", + "list_category_entries", + "get_entry_detail", + "get_related_entries", + "get_recent_updates", + "compare_entries", + "get_copyable_asset", + "get_install_guidance", + "get_client_setup", + "get_compatibility", + "get_platform_adapter", + "get_registry_stats", + "list_distribution_feeds", + "plan_workflow_toolbox", + "server_info", + "get_submission_schema", + "get_submission_policy", + "get_submission_examples", + "get_category_submission_guidance", + "validate_submission_draft", +]; + +function serverCard() { + const base = siteConfig.url; + return { + serverInfo: { name: "@heyclaude/mcp", title: "HeyClaude", version: MCP_VERSION }, + description: + "Search and inspect the HeyClaude directory of Claude Code MCP servers, agents, skills, hooks, commands, rules, collections, and tools.", + transport: { type: "streamable-http", endpoint: `${base}/api/mcp` }, + capabilities: { tools: {} }, + tools: MCP_TOOLS.map((name) => ({ name })), + documentation: `${base}/api-docs`, + homepage: base, + }; +} + +export const Route = createFileRoute("/.well-known/mcp/server-card.json")({ + server: { + handlers: { + GET: async ({ request }) => + new Response(`${JSON.stringify(serverCard(), null, 2)}\n`, { + headers: applySecurityHeaders( + new Headers({ + "content-type": "application/json; charset=utf-8", + "cache-control": "public, max-age=3600, stale-while-revalidate=86400", + }), + request, + ), + }), + }, + }, +}); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 0a5f041c14..ebd93c9f0d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -21,6 +21,7 @@ import { Toaster } from "@/components/ui/sonner"; import { ShortcutsProvider } from "@/components/shortcuts-dialog"; import { SkipLink } from "@/components/skip-link"; import { RouteProgress } from "@/components/route-progress"; +import { WebMcpProvider } from "@/components/webmcp-provider"; import { siteConfig } from "@/lib/site"; function NotFoundComponent() { @@ -170,6 +171,7 @@ function RootComponent() { + api-catalog +_index._agents.heyclau.de. 3600 IN SVCB 1 heyclau.de. ( + alpn="h2" + endpoint="/.well-known/api-catalog" ) +``` + +Steps: +1. Add the SVCB records above on the Cloudflare DNS zone for `heyclau.de`. **Done** — + `_index._agents` and `_a2a._agents` SVCB records are published. +2. **DNSSEC: blocked for this domain.** DENIC (the `.de` registry) supports DNSSEC, but the current + registrar (Namecheap) does not expose DS-record submission for `.de`, so the chain of trust + cannot be anchored. Toggling Cloudflare's DNSSEC on is inert without a DS record at the + registrar (it signs the zone but resolvers see no DS and treat it as unsigned — no breakage, + no validation). Options: + - **(a) Accept it (recommended).** DNS-AID is a draft spec and the lowest-value readiness item; + the SVCB records still publish discovery hints. The substantive agent-readiness surfaces + (Link headers, api-catalog, MCP card, agent-skills index, Content-Signal, WebMCP) do not + depend on DNSSEC and are fully live. + - **(b) Move the registrar** to one that supports `.de` DS submission (e.g. INWX or another + `.de`-capable registrar — Cloudflare Registrar does NOT support `.de`), then anchor DNSSEC + end-to-end. Only worth it if the DNS-AID check specifically matters. +3. Verify the records with `dig _index._agents.heyclau.de SVCB +short`. + +## Verification + +```sh +curl -s https://heyclau.de/.well-known/api-catalog | jq . +curl -s https://heyclau.de/.well-known/mcp/server-card.json | jq . +curl -s https://heyclau.de/.well-known/agent-skills/index.json | jq '.skills | length' +curl -sI https://heyclau.de/ | grep -i '^link:' +curl -s https://heyclau.de/robots.txt | grep -i 'content-signal' +``` From 5a4d55dea2cdf3360f3ac79fe1d7e6408ca5f8b1 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:05:00 -0700 Subject: [PATCH 3/7] test(seo): update crawler/sitemap/atlas policy tests for new behavior - robots Disallow + Content-Signal, host-aware noindex, Link header - sitemap excludes disallowed /data, adds category hubs + per-category lastmod Co-Authored-By: Claude Opus 4.8 --- tests/atlas-production-data.test.ts | 3 ++- tests/crawler-policy.test.ts | 30 +++++++++++++++++++++++++---- tests/sitemap-policy.test.ts | 6 +++++- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/tests/atlas-production-data.test.ts b/tests/atlas-production-data.test.ts index 59a3ecb2d2..a6d9ec41bb 100644 --- a/tests/atlas-production-data.test.ts +++ b/tests/atlas-production-data.test.ts @@ -82,7 +82,8 @@ describe("Atlas production data wiring", () => { expect(appShell).not.toContain(retiredFeedPath); expect(feedsRoute).toContain('slug === "trending"'); expect(sitemapRoute).toContain('"/feeds/trending.xml"'); - expect(sitemapRoute).toContain('"/data/feeds/index.json"'); + // /data/** is robots-disallowed, so it is intentionally excluded from the sitemap. + expect(sitemapRoute).not.toContain('"/data/feeds/index.json"'); }); it("keeps first-party MCP integration metadata aligned with the package", () => { diff --git a/tests/crawler-policy.test.ts b/tests/crawler-policy.test.ts index 303929a2ec..beef2c7ac5 100644 --- a/tests/crawler-policy.test.ts +++ b/tests/crawler-policy.test.ts @@ -2,7 +2,8 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { getRobotsPolicy } from "@/lib/robots-policy"; +import { getRobotsPolicy, renderRobotsTxt } from "@/lib/robots-policy"; +import { applySecurityHeaders } from "@/lib/security-headers"; import { repoRoot } from "./helpers/registry-fixtures"; describe("crawler and AI citation policy", () => { @@ -16,13 +17,13 @@ describe("crawler and AI citation policy", () => { 'import { applySecurityHeaders } from "./lib/security-headers"', ); expect(serverSource).toContain( - "function withSecurityHeaders(response: Response): Response", + "function withSecurityHeaders(response: Response, request: Request): Response", ); expect(serverSource).toContain( - "applySecurityHeaders(new Headers(response.headers))", + "applySecurityHeaders(new Headers(response.headers), request)", ); expect(serverSource).toContain( - "return withSecurityHeaders(await normalizeCatastrophicSsrResponse(response));", + "return withSecurityHeaders(await normalizeCatastrophicSsrResponse(response), request);", ); }); it("keeps legitimate search and AI citation crawlers explicitly allowed", () => { @@ -48,6 +49,27 @@ describe("crawler and AI citation policy", () => { ]), ); expect(policy.sitemap).toBe("https://heyclau.de/sitemap.xml"); + + const robotsTxt = renderRobotsTxt(); + expect(robotsTxt).toContain("Disallow: /api/"); + expect(robotsTxt).toContain("Disallow: /data/"); + expect(robotsTxt).toContain("Disallow: /downloads/"); + expect(robotsTxt).toContain("Content-Signal:"); + }); + + it("noindexes non-production hosts and advertises agent discovery on HTML responses", () => { + const devHeaders = applySecurityHeaders( + new Headers({ "content-type": "text/html; charset=utf-8" }), + new Request("https://dev.heyclau.de/"), + ); + expect(devHeaders.get("x-robots-tag")).toContain("noindex"); + + const prodHeaders = applySecurityHeaders( + new Headers({ "content-type": "text/html; charset=utf-8" }), + new Request("https://heyclau.de/"), + ); + expect(prodHeaders.get("x-robots-tag")).toBeNull(); + expect(prodHeaders.get("link")).toContain('rel="api-catalog"'); }); it("keeps llms.txt and corpus exports as cacheable security-headered discovery surfaces", () => { diff --git a/tests/sitemap-policy.test.ts b/tests/sitemap-policy.test.ts index f8fd053711..47719196b1 100644 --- a/tests/sitemap-policy.test.ts +++ b/tests/sitemap-policy.test.ts @@ -49,11 +49,15 @@ describe("sitemap policy", () => { expect(source).toContain('"/feed.xml"'); expect(source).toContain('"/atom.xml"'); expect(source).toContain('"/feeds/trending.xml"'); - expect(source).toContain('"/data/feeds/index.json"'); + // /data/** is robots-disallowed, so it must not be advertised in the sitemap. + expect(source).not.toContain('"/data/feeds/index.json"'); expect(source).toContain('"/validators"'); expect(source).not.toContain('"/validators/mcp-config"'); expect(source).not.toContain('"/validators/skill-package"'); expect(source).not.toContain("lastModified: new Date()"); expect(source).toContain("ENTRIES.map"); + // Category hub landing pages with per-category + per-entry lastmod. + expect(source).toContain("categoryLastmod"); + expect(source).toContain("entry.reviewedAt ?? entry.dateAdded"); }); }); From 154a78222c777591df09c1acc35ac13fa171cd55 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:16:37 -0700 Subject: [PATCH 4/7] feat(seo): tag hubs, platform landing pages, guide HowTo, thin-entry copy - /tags + /tags/:tag topic hubs (ItemList+breadcrumb JSON-LD); entry tag chips now link out - /for + /for/:platform landing pages per platform (ItemList+FAQ+breadcrumb), reusing search() - HowTo JSON-LD for guides (steps from headings) - Unique, per-entry fallback copy for bodyless entries (category/tags/platforms) - Sitemap: tag hubs + platform pages Co-Authored-By: Claude Opus 4.8 --- apps/web/src/lib/tags.ts | 39 +++++ apps/web/src/routes/entry.$category.$slug.tsx | 59 ++++++- apps/web/src/routes/for.$platform.tsx | 162 ++++++++++++++++++ apps/web/src/routes/for.index.tsx | 75 ++++++++ apps/web/src/routes/sitemap[.]xml.ts | 7 +- apps/web/src/routes/tags.$tag.tsx | 110 ++++++++++++ apps/web/src/routes/tags.index.tsx | 68 ++++++++ 7 files changed, 511 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/lib/tags.ts create mode 100644 apps/web/src/routes/for.$platform.tsx create mode 100644 apps/web/src/routes/for.index.tsx create mode 100644 apps/web/src/routes/tags.$tag.tsx create mode 100644 apps/web/src/routes/tags.index.tsx diff --git a/apps/web/src/lib/tags.ts b/apps/web/src/lib/tags.ts new file mode 100644 index 0000000000..b289b0ce0a --- /dev/null +++ b/apps/web/src/lib/tags.ts @@ -0,0 +1,39 @@ +import { ENTRIES } from "@/data/entries"; +import type { Entry } from "@/types/registry"; + +export function tagSlug(tag: string) { + return tag + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export type TagGroup = { slug: string; name: string; entries: Entry[] }; + +let cache: TagGroup[] | null = null; + +export function getAllTagGroups(): TagGroup[] { + if (cache) return cache; + const map = new Map(); + for (const entry of ENTRIES) { + for (const tag of entry.tags ?? []) { + const slug = tagSlug(tag); + if (!slug) continue; + const group = map.get(slug); + if (group) group.entries.push(entry); + else map.set(slug, { slug, name: tag, entries: [entry] }); + } + } + cache = [...map.values()].sort((a, b) => b.entries.length - a.entries.length); + return cache; +} + +export function getTagGroup(slug: string): TagGroup | undefined { + return getAllTagGroups().find((group) => group.slug === slug); +} + +// Tags with enough entries to be a non-thin, indexable hub. +export function getIndexableTagGroups(): TagGroup[] { + return getAllTagGroups().filter((group) => group.entries.length >= 2); +} diff --git a/apps/web/src/routes/entry.$category.$slug.tsx b/apps/web/src/routes/entry.$category.$slug.tsx index b5cf03e295..a0278ec708 100644 --- a/apps/web/src/routes/entry.$category.$slug.tsx +++ b/apps/web/src/routes/entry.$category.$slug.tsx @@ -34,6 +34,8 @@ import { CopyButton } from "@/components/copy-button"; import { ResourceCard } from "@/components/resource-card"; import { stringifyJsonLd } from "@/lib/json-ld"; import { absoluteUrl } from "@/lib/seo"; +import { categoryLabels, categoryUsageHints } from "@/lib/site"; +import { tagSlug } from "@/lib/tags"; // (HoverChevrons removed — related uses static grid) import { ShareMenu } from "@/components/share-menu"; import { DossierTOC, type TocItem } from "@/components/dossier-toc"; @@ -109,6 +111,23 @@ function entrySchema(e: Entry, url: string): Record { return { ...base, "@type": "CreativeWork" }; } +// Guides are how-to content: emit a HowTo whose steps come from the guide's H2/H3 headings, +// so step-by-step guides become eligible for HowTo rich results. +function guideHowTo(e: Entry, url: string) { + return { + "@context": "https://schema.org", + "@type": "HowTo", + name: e.title, + description: e.description, + step: (e.headings ?? []).map((heading, index) => ({ + "@type": "HowToStep", + position: index + 1, + name: heading.text, + url: `${url}#${heading.id}`, + })), + }; +} + export const Route = createFileRoute("/entry/$category/$slug")({ loader: async ({ params }): Promise<{ entry: import("@/types/registry").Entry }> => { const fullEntry = await loadFullEntry({ @@ -169,6 +188,9 @@ export const Route = createFileRoute("/entry/$category/$slug")({ { type: "application/ld+json", children: stringifyJsonLd(ld) }, { type: "application/ld+json", children: stringifyJsonLd(breadcrumbs) }, { type: "application/ld+json", children: stringifyJsonLd(entrySchema(e, url)) }, + ...(e.category === "guides" && (e.headings?.length ?? 0) >= 2 + ? [{ type: "application/ld+json", children: stringifyJsonLd(guideHowTo(e, url)) }] + : []), ], }; }, @@ -485,11 +507,30 @@ function Dossier() { {entry.body} ) : ( -

- {entry.title} is curated in the HeyClaude registry. Review the source repository - before installing. Trust and source signals are derived from metadata review, not - from runtime scanning. -

+
+

+ {entry.title} is a{" "} + {categoryLabels[entry.category] ?? entry.category} resource for Claude + {entry.author ? ` by ${entry.author}` : ""}, curated and metadata-reviewed in the + HeyClaude registry.{" "} + {categoryUsageHints[entry.category] ?? + "Open the source to review it before installing."} +

+ {entry.platforms.length > 0 && ( +

+ Compatible with{" "} + {entry.platforms.join(", ")}. +

+ )} + {entry.tags.length > 0 && ( +

Covers {entry.tags.slice(0, 8).join(", ")}.

+ )} +

+ Trust and source signals come from metadata review, not runtime scanning — always + read the source before installing anything that touches your filesystem, network, + or credentials. +

+
)} {entry.headings && entry.headings.length > 0 && (
@@ -507,12 +548,14 @@ function Dossier() { )}
{entry.tags.map((t) => ( - #{t} - + ))}
diff --git a/apps/web/src/routes/for.$platform.tsx b/apps/web/src/routes/for.$platform.tsx new file mode 100644 index 0000000000..a7f1b740ff --- /dev/null +++ b/apps/web/src/routes/for.$platform.tsx @@ -0,0 +1,162 @@ +import { createFileRoute, Link, notFound } from "@tanstack/react-router"; +import { ArrowRight } from "lucide-react"; +import { CATEGORIES, PLATFORM_LABEL, type Platform } from "@/types/registry"; +import { search } from "@/data/search"; +import { categoryLabels } from "@/lib/site"; +import { ResourceCard } from "@/components/resource-card"; +import { Breadcrumbs } from "@/components/breadcrumbs"; +import { NewsletterInline } from "@/components/newsletter-inline"; +import { stringifyJsonLd } from "@/lib/json-ld"; +import { absoluteUrl } from "@/lib/seo"; + +const PLATFORM_IDS = new Set(Object.keys(PLATFORM_LABEL)); + +function platformEntries(platform: string) { + return search({ platforms: [platform as Platform] }); +} + +export const Route = createFileRoute("/for/$platform")({ + loader: ({ params }) => { + if (!PLATFORM_IDS.has(params.platform)) throw notFound(); + return {}; + }, + head: ({ params }) => { + if (!PLATFORM_IDS.has(params.platform)) return { meta: [] }; + const label = PLATFORM_LABEL[params.platform as Platform]; + const entries = platformEntries(params.platform); + const url = absoluteUrl(`/for/${params.platform}`); + const title = `Claude resources for ${label} — HeyClaude`; + const description = `${entries.length} source-backed Claude resources that work with ${label}: MCP servers, agents, skills, hooks, commands, and rules, curated in HeyClaude.`; + const itemList = { + "@context": "https://schema.org", + "@type": "ItemList", + name: `Claude resources for ${label}`, + description, + numberOfItems: entries.length, + itemListElement: entries.slice(0, 30).map((e, i) => ({ + "@type": "ListItem", + position: i + 1, + name: e.title, + url: absoluteUrl(`/entry/${e.category}/${e.slug}`), + })), + }; + const breadcrumbs = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Directory", item: absoluteUrl("/browse") }, + { "@type": "ListItem", position: 2, name: "Platforms", item: absoluteUrl("/for") }, + { "@type": "ListItem", position: 3, name: label, item: url }, + ], + }; + const faq = { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: [ + { + "@type": "Question", + name: `What Claude resources work with ${label}?`, + acceptedAnswer: { + "@type": "Answer", + text: `HeyClaude lists ${entries.length} ${label}-compatible resources across MCP servers, agents, skills, hooks, commands, rules, and more — each metadata-reviewed for source and safety signals.`, + }, + }, + ], + }; + return { + meta: [ + { title }, + { name: "description", content: description }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: url }, + { name: "twitter:card", content: "summary_large_image" }, + ], + links: [{ rel: "canonical", href: url }], + scripts: [ + { type: "application/ld+json", children: stringifyJsonLd(itemList) }, + { type: "application/ld+json", children: stringifyJsonLd(breadcrumbs) }, + { type: "application/ld+json", children: stringifyJsonLd(faq) }, + ], + }; + }, + component: PlatformPage, + notFoundComponent: () => ( +
+

Platform not found

+

That platform isn't tracked yet.

+ + All platforms + +
+ ), +}); + +function PlatformPage() { + const { platform } = Route.useParams(); + const label = PLATFORM_LABEL[platform as Platform] ?? platform; + const all = platformEntries(platform); + const sections = CATEGORIES.map((c) => ({ + category: c, + entries: all.filter((e) => e.category === c.id).slice(0, 6), + })).filter((s) => s.entries.length > 0); + + return ( +
+ +
+
{all.length} compatible resources
+

Claude resources for {label}

+

+ Source-backed MCP servers, agents, skills, hooks, commands, and rules that work with{" "} + {label} — curated and metadata-reviewed in HeyClaude. +

+
+ + Browse & filter all {label} resources + +
+
+ + {sections.map((section) => ( +
+
+

+ {categoryLabels[section.category.id] ?? section.category.label} +

+ + All {categoryLabels[section.category.id] ?? section.category.label} → + +
+
+ {section.entries.map((e) => ( + + ))} +
+
+ ))} + + +
+ ); +} diff --git a/apps/web/src/routes/for.index.tsx b/apps/web/src/routes/for.index.tsx new file mode 100644 index 0000000000..e09266d395 --- /dev/null +++ b/apps/web/src/routes/for.index.tsx @@ -0,0 +1,75 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { PLATFORM_LABEL, type Platform } from "@/types/registry"; +import { search } from "@/data/search"; +import { Breadcrumbs } from "@/components/breadcrumbs"; +import { stringifyJsonLd } from "@/lib/json-ld"; +import { absoluteUrl } from "@/lib/seo"; + +const PLATFORMS = Object.keys(PLATFORM_LABEL) as Platform[]; + +export const Route = createFileRoute("/for/")({ + head: () => { + const url = absoluteUrl("/for"); + const title = "Claude resources by platform — HeyClaude"; + const description = + "Find Claude Code resources for your platform — Claude Code, Cursor, VS Code, Windsurf, Codex, Gemini, and more."; + return { + meta: [ + { title }, + { name: "description", content: description }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: url }, + { name: "twitter:card", content: "summary_large_image" }, + ], + links: [{ rel: "canonical", href: url }], + scripts: [ + { + type: "application/ld+json", + children: stringifyJsonLd({ + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Directory", item: absoluteUrl("/browse") }, + { "@type": "ListItem", position: 2, name: "Platforms", item: url }, + ], + }), + }, + ], + }; + }, + component: PlatformsIndex, +}); + +function PlatformsIndex() { + const counts = new Map( + PLATFORMS.map((p) => [p, search({ platforms: [p] }).length]), + ); + return ( +
+ +
+
{PLATFORMS.length} platforms
+

Claude resources by platform

+

+ Pick your editor or runtime to see every compatible Claude resource in the directory. +

+
+
+ {PLATFORMS.map((p) => ( + + + {PLATFORM_LABEL[p]} + + {counts.get(p) ?? 0} + + ))} +
+
+ ); +} diff --git a/apps/web/src/routes/sitemap[.]xml.ts b/apps/web/src/routes/sitemap[.]xml.ts index a536bea828..9e585a127b 100644 --- a/apps/web/src/routes/sitemap[.]xml.ts +++ b/apps/web/src/routes/sitemap[.]xml.ts @@ -6,7 +6,8 @@ import atlasRegistry from "@/generated/atlas-registry.json"; import { getJobs } from "@/lib/jobs"; import { siteConfig } from "@/lib/site"; import { applySecurityHeaders } from "@/lib/security-headers"; -import { CATEGORIES } from "@/types/registry"; +import { CATEGORIES, PLATFORM_LABEL } from "@/types/registry"; +import { getIndexableTagGroups } from "@/lib/tags"; function escapeXml(value: string) { return value @@ -35,6 +36,8 @@ async function renderSitemap() { const staticPaths = [ "", "/browse", + "/tags", + "/for", "/best", "/about", "/tools", @@ -89,6 +92,8 @@ async function renderSitemap() { ...CATEGORIES.map((category) => urlItem(`/${category.id}`, "0.8", "weekly", categoryLastmod.get(category.id)), ), + ...getIndexableTagGroups().map((group) => urlItem(`/tags/${group.slug}`, "0.5")), + ...Object.keys(PLATFORM_LABEL).map((platform) => urlItem(`/for/${platform}`, "0.6")), ...bestPaths.map((pathname) => urlItem(pathname, "0.75")), ...ENTRIES.map((entry) => urlItem( diff --git a/apps/web/src/routes/tags.$tag.tsx b/apps/web/src/routes/tags.$tag.tsx new file mode 100644 index 0000000000..7504633b3b --- /dev/null +++ b/apps/web/src/routes/tags.$tag.tsx @@ -0,0 +1,110 @@ +import { createFileRoute, Link, notFound } from "@tanstack/react-router"; +import { ArrowRight } from "lucide-react"; +import { ResourceCard } from "@/components/resource-card"; +import { Breadcrumbs } from "@/components/breadcrumbs"; +import { NewsletterInline } from "@/components/newsletter-inline"; +import { stringifyJsonLd } from "@/lib/json-ld"; +import { absoluteUrl } from "@/lib/seo"; +import { getTagGroup } from "@/lib/tags"; + +export const Route = createFileRoute("/tags/$tag")({ + loader: ({ params }) => { + if (!getTagGroup(params.tag)) throw notFound(); + return {}; + }, + head: ({ params }) => { + const group = getTagGroup(params.tag); + if (!group) return { meta: [] }; + const url = absoluteUrl(`/tags/${params.tag}`); + const title = `Claude ${group.name} resources — HeyClaude`; + const description = `${group.entries.length} Claude Code resources tagged "${group.name}" — MCP servers, agents, skills, hooks, commands, rules, and more, curated in HeyClaude.`; + const itemList = { + "@context": "https://schema.org", + "@type": "ItemList", + name: `Claude resources tagged ${group.name}`, + description, + numberOfItems: group.entries.length, + itemListElement: group.entries.slice(0, 30).map((e, i) => ({ + "@type": "ListItem", + position: i + 1, + name: e.title, + url: absoluteUrl(`/entry/${e.category}/${e.slug}`), + })), + }; + const breadcrumbs = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Directory", item: absoluteUrl("/browse") }, + { "@type": "ListItem", position: 2, name: "Tags", item: absoluteUrl("/tags") }, + { "@type": "ListItem", position: 3, name: group.name, item: url }, + ], + }; + return { + meta: [ + { title }, + { name: "description", content: description }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: url }, + { name: "twitter:card", content: "summary_large_image" }, + ], + links: [{ rel: "canonical", href: url }], + scripts: [ + { type: "application/ld+json", children: stringifyJsonLd(itemList) }, + { type: "application/ld+json", children: stringifyJsonLd(breadcrumbs) }, + ], + }; + }, + component: TagHub, + notFoundComponent: () => ( +
+

Tag not found

+

No resources use that tag yet.

+ + Browse all tags + +
+ ), +}); + +function TagHub() { + const { tag } = Route.useParams(); + const group = getTagGroup(tag); + if (!group) return null; + const entries = group.entries; + + return ( +
+ +
+
{entries.length} entries
+

Claude resources tagged “{group.name}”

+

+ Every source-backed Claude Code resource tagged {group.name} in + the HeyClaude directory — across MCP servers, agents, skills, hooks, commands, and more. +

+
+ +
+ {entries.map((e) => ( + + ))} +
+ + +
+ ); +} diff --git a/apps/web/src/routes/tags.index.tsx b/apps/web/src/routes/tags.index.tsx new file mode 100644 index 0000000000..bc604ed6d2 --- /dev/null +++ b/apps/web/src/routes/tags.index.tsx @@ -0,0 +1,68 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { Breadcrumbs } from "@/components/breadcrumbs"; +import { stringifyJsonLd } from "@/lib/json-ld"; +import { absoluteUrl } from "@/lib/seo"; +import { getIndexableTagGroups } from "@/lib/tags"; + +export const Route = createFileRoute("/tags/")({ + head: () => { + const url = absoluteUrl("/tags"); + const title = "Browse Claude resources by tag — HeyClaude"; + const description = + "Topic index for the HeyClaude directory: browse Claude Code MCP servers, agents, skills, hooks, commands, and rules by tag."; + return { + meta: [ + { title }, + { name: "description", content: description }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: url }, + { name: "twitter:card", content: "summary_large_image" }, + ], + links: [{ rel: "canonical", href: url }], + scripts: [ + { + type: "application/ld+json", + children: stringifyJsonLd({ + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { "@type": "ListItem", position: 1, name: "Directory", item: absoluteUrl("/browse") }, + { "@type": "ListItem", position: 2, name: "Tags", item: url }, + ], + }), + }, + ], + }; + }, + component: TagsIndex, +}); + +function TagsIndex() { + const groups = getIndexableTagGroups(); + return ( +
+ +
+
{groups.length} topics
+

Browse by tag

+

+ Jump to a topic to see every Claude Code resource tagged with it across the directory. +

+
+
+ {groups.map((group) => ( + + {group.name} + {group.entries.length} + + ))} +
+
+ ); +} From dc445f7a55207f36423b08f25319ba8bd73e27ea Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:16:39 -0700 Subject: [PATCH 5/7] chore(agents): MCP card version from integration metadata; retire /data/llms - MCP server-card version sourced from the mcp-server integration (synced to packages/mcp) - Drop deprecated /data/llms llms-url tolerance; generators already use /api/registry/.../llms Co-Authored-By: Claude Opus 4.8 --- .../routes/[.]well-known.mcp.server-card[.]json.ts | 8 +++++--- integrations/raycast/test/feed.test.ts | 14 +++++++------- scripts/validate-raycast-feed.mjs | 4 +--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/web/src/routes/[.]well-known.mcp.server-card[.]json.ts b/apps/web/src/routes/[.]well-known.mcp.server-card[.]json.ts index 5d7bde8fab..8e77cf677a 100644 --- a/apps/web/src/routes/[.]well-known.mcp.server-card[.]json.ts +++ b/apps/web/src/routes/[.]well-known.mcp.server-card[.]json.ts @@ -1,10 +1,11 @@ import { createFileRoute } from "@tanstack/react-router"; import { siteConfig } from "@/lib/site"; +import { getIntegration } from "@/data/integrations"; import { applySecurityHeaders } from "@/lib/security-headers"; // MCP Server Card (SEP-1649) for agent discovery of the hosted HeyClaude MCP server. -// Keep MCP_VERSION in sync with packages/mcp/package.json on each MCP release. -const MCP_VERSION = "0.3.0"; +// Version is sourced from the mcp-server integration metadata, which is kept in sync with +// packages/mcp/package.json (enforced by tests/atlas-production-data.test.ts). const MCP_TOOLS = [ "search_registry", "search_duplicate_entries", @@ -31,8 +32,9 @@ const MCP_TOOLS = [ function serverCard() { const base = siteConfig.url; + const version = getIntegration("mcp-server")?.version ?? "0.3.0"; return { - serverInfo: { name: "@heyclaude/mcp", title: "HeyClaude", version: MCP_VERSION }, + serverInfo: { name: "@heyclaude/mcp", title: "HeyClaude", version }, description: "Search and inspect the HeyClaude directory of Claude Code MCP servers, agents, skills, hooks, commands, rules, collections, and tools.", transport: { type: "streamable-http", endpoint: `${base}/api/mcp` }, diff --git a/integrations/raycast/test/feed.test.ts b/integrations/raycast/test/feed.test.ts index c138ad4fc3..f2bc5f4577 100644 --- a/integrations/raycast/test/feed.test.ts +++ b/integrations/raycast/test/feed.test.ts @@ -1221,7 +1221,7 @@ describe("Raycast feed helpers", () => { }; const lazyDetail = { detailMarkdown: "# Detail", - llmsUrl: "/data/llms/mcp/context7.txt", + llmsUrl: "/api/registry/entries/mcp/context7/llms", }; assert.equal(isRaycastDetail(detail), true); assert.equal(isRaycastDetail(lazyDetail), true); @@ -2187,21 +2187,21 @@ describe("Raycast feed helpers", () => { feedUrl: devFeed, fetchFn: async (input) => { requestedUrls.push(String(input)); - if (String(input).endsWith("/data/llms/mcp/context7.txt")) { + if (String(input).endsWith("/api/registry/entries/mcp/context7/llms")) { return response("remote full text", { headers: { "content-type": "text/plain" }, }); } return response({ detailMarkdown: "# Remote", - llmsUrl: "/data/llms/mcp/context7.txt", + llmsUrl: "/api/registry/entries/mcp/context7/llms", }); }, }); assert.deepEqual(detail, { copyText: "remote full text", detailMarkdown: "# Remote", - llmsUrl: "/data/llms/mcp/context7.txt", + llmsUrl: "/api/registry/entries/mcp/context7/llms", }); assert.equal( requestedUrls[0], @@ -2209,7 +2209,7 @@ describe("Raycast feed helpers", () => { ); assert.equal( requestedUrls[1], - "https://preview.example.com/data/llms/mcp/context7.txt", + "https://preview.example.com/api/registry/entries/mcp/context7/llms", ); assert.match( cache.get(detailCacheKey(sampleEntry, devFeed)) || "", @@ -2262,14 +2262,14 @@ describe("Raycast feed helpers", () => { cache, feedUrl: devFeed, fetchFn: async (input) => { - if (String(input).endsWith("/data/llms/mcp/context7.txt")) { + if (String(input).endsWith("/api/registry/entries/mcp/context7/llms")) { return response("current detail", { headers: { "content-type": "text/plain" }, }); } return response({ detailMarkdown: "# Current", - llmsUrl: "/data/llms/mcp/context7.txt", + llmsUrl: "/api/registry/entries/mcp/context7/llms", }); }, }); diff --git a/scripts/validate-raycast-feed.mjs b/scripts/validate-raycast-feed.mjs index 894cf3e756..1b4477c870 100644 --- a/scripts/validate-raycast-feed.mjs +++ b/scripts/validate-raycast-feed.mjs @@ -281,9 +281,7 @@ for (const entry of payload.entries) { } if (detail.copyText === undefined) { const llmsUrl = String(detail.llmsUrl || ""); - const validLlmsUrl = - /^\/api\/registry\/entries\/[^/]+\/[^/]+\/llms(?:\/.*)?$/.test(llmsUrl) || - llmsUrl.startsWith("/data/llms/"); + const validLlmsUrl = /^\/api\/registry\/entries\/[^/]+\/[^/]+\/llms(?:\/.*)?$/.test(llmsUrl); if (!validLlmsUrl) { fail(`${key}: detail without copyText must expose llmsUrl`); } From 53af2ab56f55d5651893b76301bc4d8e07426470 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:21:54 -0700 Subject: [PATCH 6/7] fix(seo): address CodeRabbit review on #2135 - agent-skills index: url points at the download artifact matching sha256 (not entry page) - validators Dataset: omit datePublished/dateModified when generatedAt is missing - feeds: add absolute og:url - docs: language hint on DNS-AID fenced block Co-Authored-By: Claude Opus 4.8 --- .../src/routes/[.]well-known.agent-skills.index[.]json.ts | 3 ++- apps/web/src/routes/feeds.tsx | 1 + apps/web/src/routes/validators.tsx | 8 ++++++-- docs/agent-discovery.md | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/routes/[.]well-known.agent-skills.index[.]json.ts b/apps/web/src/routes/[.]well-known.agent-skills.index[.]json.ts index 95b0667eb5..1579c348cb 100644 --- a/apps/web/src/routes/[.]well-known.agent-skills.index[.]json.ts +++ b/apps/web/src/routes/[.]well-known.agent-skills.index[.]json.ts @@ -11,7 +11,8 @@ function skillsIndex() { name: e.title, type: "skill", description: e.description, - url: absoluteUrl(`/entry/${e.category}/${e.slug}`), + // url must point at the artifact whose bytes match sha256 (not the HTML entry page). + url: absoluteUrl(String(e.downloadUrl)), sha256: e.downloadSha256, })); return { diff --git a/apps/web/src/routes/feeds.tsx b/apps/web/src/routes/feeds.tsx index 81ef2a1e0d..ad00bc0020 100644 --- a/apps/web/src/routes/feeds.tsx +++ b/apps/web/src/routes/feeds.tsx @@ -20,6 +20,7 @@ export const Route = createFileRoute("/feeds")({ property: "og:description", content: "RSS, Atom, and email subscriptions for the HeyClaude registry.", }, + { property: "og:url", content: absoluteUrl("/feeds") }, ], links: [ { rel: "canonical", href: absoluteUrl("/feeds") }, diff --git a/apps/web/src/routes/validators.tsx b/apps/web/src/routes/validators.tsx index 6d0b5a75d3..841c2960ec 100644 --- a/apps/web/src/routes/validators.tsx +++ b/apps/web/src/routes/validators.tsx @@ -51,8 +51,12 @@ export const Route = createFileRoute("/validators")({ name: siteConfig.name, url: siteConfig.url, }, - datePublished: String(atlasRegistry.generatedAt || "").slice(0, 10), - dateModified: String(atlasRegistry.generatedAt || "").slice(0, 10), + ...(atlasRegistry.generatedAt + ? { + datePublished: String(atlasRegistry.generatedAt).slice(0, 10), + dateModified: String(atlasRegistry.generatedAt).slice(0, 10), + } + : {}), keywords: [ "Claude", "registry", diff --git a/docs/agent-discovery.md b/docs/agent-discovery.md index 0198958701..86f026cdb4 100644 --- a/docs/agent-discovery.md +++ b/docs/agent-discovery.md @@ -33,7 +33,7 @@ that point agents at the discovery entrypoints, then sign the zone with DNSSEC. Suggested records: -``` +```dns ; MCP endpoint (Streamable HTTP) _a2a._agents.heyclau.de. 3600 IN SVCB 1 api.heyclau.de. ( alpn="h2" From 88010d93f97b225c819d3f713c7cbeb4947a619c Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:37:11 -0700 Subject: [PATCH 7/7] fix(seo): address CodeRabbit round 2 on #2135 - entry tags: render a static chip when the tag slugifies to empty (no broken /tags link) - guide HowTo: emit steps from H2/H3 headings only (filter by depth), shared with the guard - raycast feed validator: anchor the llms-url regex to the exact endpoint shape - tags: pick the most-frequent raw casing as a tag's canonical display name Co-Authored-By: Claude Opus 4.8 --- apps/web/src/lib/tags.ts | 21 +++++++--- apps/web/src/routes/entry.$category.$slug.tsx | 40 +++++++++++++------ scripts/validate-raycast-feed.mjs | 2 +- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/apps/web/src/lib/tags.ts b/apps/web/src/lib/tags.ts index b289b0ce0a..a1bfe20315 100644 --- a/apps/web/src/lib/tags.ts +++ b/apps/web/src/lib/tags.ts @@ -15,17 +15,28 @@ let cache: TagGroup[] | null = null; export function getAllTagGroups(): TagGroup[] { if (cache) return cache; - const map = new Map(); + const map = new Map }>(); for (const entry of ENTRIES) { for (const tag of entry.tags ?? []) { const slug = tagSlug(tag); if (!slug) continue; - const group = map.get(slug); - if (group) group.entries.push(entry); - else map.set(slug, { slug, name: tag, entries: [entry] }); + let group = map.get(slug); + if (!group) { + group = { entries: [], names: new Map() }; + map.set(slug, group); + } + group.entries.push(entry); + group.names.set(tag, (group.names.get(tag) ?? 0) + 1); } } - cache = [...map.values()].sort((a, b) => b.entries.length - a.entries.length); + cache = [...map.entries()] + .map(([slug, group]) => ({ + slug, + // Canonical display name: most frequent raw casing (ties broken alphabetically). + name: [...group.names.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0][0], + entries: group.entries, + })) + .sort((a, b) => b.entries.length - a.entries.length); return cache; } diff --git a/apps/web/src/routes/entry.$category.$slug.tsx b/apps/web/src/routes/entry.$category.$slug.tsx index a0278ec708..09570b95be 100644 --- a/apps/web/src/routes/entry.$category.$slug.tsx +++ b/apps/web/src/routes/entry.$category.$slug.tsx @@ -113,13 +113,16 @@ function entrySchema(e: Entry, url: string): Record { // Guides are how-to content: emit a HowTo whose steps come from the guide's H2/H3 headings, // so step-by-step guides become eligible for HowTo rich results. +function guideHeadingSteps(e: Entry) { + return (e.headings ?? []).filter((heading) => heading.depth === 2 || heading.depth === 3); +} function guideHowTo(e: Entry, url: string) { return { "@context": "https://schema.org", "@type": "HowTo", name: e.title, description: e.description, - step: (e.headings ?? []).map((heading, index) => ({ + step: guideHeadingSteps(e).map((heading, index) => ({ "@type": "HowToStep", position: index + 1, name: heading.text, @@ -188,7 +191,7 @@ export const Route = createFileRoute("/entry/$category/$slug")({ { type: "application/ld+json", children: stringifyJsonLd(ld) }, { type: "application/ld+json", children: stringifyJsonLd(breadcrumbs) }, { type: "application/ld+json", children: stringifyJsonLd(entrySchema(e, url)) }, - ...(e.category === "guides" && (e.headings?.length ?? 0) >= 2 + ...(e.category === "guides" && guideHeadingSteps(e).length >= 2 ? [{ type: "application/ld+json", children: stringifyJsonLd(guideHowTo(e, url)) }] : []), ], @@ -547,16 +550,29 @@ function Dossier() {
)}
- {entry.tags.map((t) => ( - - #{t} - - ))} + {entry.tags.map((t) => { + const slug = tagSlug(t); + const base = + "inline-flex rounded-md border border-border bg-surface px-2 py-0.5 text-xs text-ink-muted"; + // Tags that slugify to empty (all-symbol) have no hub — render a static chip. + if (!slug) { + return ( + + #{t} + + ); + } + return ( + + #{t} + + ); + })}
diff --git a/scripts/validate-raycast-feed.mjs b/scripts/validate-raycast-feed.mjs index 1b4477c870..0176b6bb49 100644 --- a/scripts/validate-raycast-feed.mjs +++ b/scripts/validate-raycast-feed.mjs @@ -281,7 +281,7 @@ for (const entry of payload.entries) { } if (detail.copyText === undefined) { const llmsUrl = String(detail.llmsUrl || ""); - const validLlmsUrl = /^\/api\/registry\/entries\/[^/]+\/[^/]+\/llms(?:\/.*)?$/.test(llmsUrl); + const validLlmsUrl = /^\/api\/registry\/entries\/[^/]+\/[^/]+\/llms\/?$/.test(llmsUrl); if (!validLlmsUrl) { fail(`${key}: detail without copyText must expose llmsUrl`); }