diff --git a/app/resources/page.tsx b/app/resources/page.tsx index 9913d1e..1bcc7e2 100644 --- a/app/resources/page.tsx +++ b/app/resources/page.tsx @@ -4,6 +4,8 @@ import { ArrowUpRightIcon } from "lucide-react" import { JsonLd } from "@/components/json-ld" import { ResourceCta } from "@/components/resources/resource-chrome" +import { markdownTwinAlternates } from "@/lib/markdown/twins" +import { resourcesIndexDescription } from "@/lib/seo/hubs" import { manageAiSkillsPath } from "@/lib/seo/manage-ai-skills/types" import { buildResourceIndexSchema } from "@/lib/seo/resource-schema" import { @@ -13,20 +15,16 @@ import { import { siteConfig } from "@/lib/site" /** - * The hub describes itself as an index, not as an answer. - * - * The previous description repeated the head phrase of the pages it lists, - * which is one reason Google was routing their queries here instead of to - * them. What this page is for is browsing: say that, and let each page below - * own its own question. + * The hub describes itself as an index, not as an answer, and says so in one + * place: `lib/seo/hubs` holds the sentence, because the Markdown twin of this + * URL opens with it too. */ -const description = - "Browse the full index of Skills Board guides, comparisons, and reference pages, then open the one that answers your question." +const description = resourcesIndexDescription export const metadata: Metadata = { title: { absolute: "AI Skill Resources: Guides and Comparisons | Skills Board" }, description, - alternates: { canonical: resourcePaths.index }, + alternates: markdownTwinAlternates(resourcePaths.index), openGraph: { type: "website", url: resourcePaths.index, diff --git a/lib/markdown/twins.ts b/lib/markdown/twins.ts index 54a0f78..5b568c8 100644 --- a/lib/markdown/twins.ts +++ b/lib/markdown/twins.ts @@ -9,6 +9,7 @@ import { alternatives } from "@/lib/seo/alternatives" import { comparisons } from "@/lib/seo/compare" import { developers } from "@/lib/seo/developers" import { home } from "@/lib/seo/home" +import { alternativesHub, compareHub, resourcesHub } from "@/lib/seo/hubs" import { resourceEntries } from "@/lib/seo/resources" /** @@ -17,6 +18,11 @@ import { resourceEntries } from "@/lib/seo/resources" * alternative, or a new comparison gets a twin at `.md` with no change * here. * + * Each hub sits immediately above the collection it lists, which is the order + * an agent reads them in: the hub twin names the pages below it, and each of + * those names the hub above it. `lib/seo/hubs` holds the three definitions, + * built from the same registries the HTML hubs render. + * * The home page and the developer docs are listed on their own because * neither is in any collection: the home page is built from section * components, with `lib/seo/home` as the content definition written for the @@ -25,8 +31,11 @@ import { resourceEntries } from "@/lib/seo/resources" */ const twinEntries: readonly MarkdownContentEntry[] = [ home, + resourcesHub, ...resourceEntries, + alternativesHub, ...alternatives, + compareHub, ...comparisons, developers, ] diff --git a/lib/seo/alternative-metadata.ts b/lib/seo/alternative-metadata.ts index 8b551b6..b0948b1 100644 --- a/lib/seo/alternative-metadata.ts +++ b/lib/seo/alternative-metadata.ts @@ -71,7 +71,7 @@ export function buildAlternativesIndexMetadata(): Metadata { "Skills Board Alternatives: Honest Comparisons for Team AI Skills", }, description: alternativesIndexDescription, - alternates: { canonical: alternativesIndexPath }, + alternates: markdownTwinAlternates(alternativesIndexPath), openGraph: { type: "website", url: alternativesIndexPath, diff --git a/lib/seo/compare-metadata.ts b/lib/seo/compare-metadata.ts index 01b5e34..c02725e 100644 --- a/lib/seo/compare-metadata.ts +++ b/lib/seo/compare-metadata.ts @@ -69,7 +69,7 @@ export function buildCompareIndexMetadata(): Metadata { return { title: { absolute: compareIndexSeoTitle }, description: compareIndexDescription, - alternates: { canonical: compareIndexPath }, + alternates: markdownTwinAlternates(compareIndexPath), openGraph: { type: "website", url: compareIndexPath, diff --git a/lib/seo/hubs.ts b/lib/seo/hubs.ts new file mode 100644 index 0000000..0c1356d --- /dev/null +++ b/lib/seo/hubs.ts @@ -0,0 +1,184 @@ +import { + alternatives, + alternativesIndexDescription, + alternativesIndexModifiedAt, + alternativesIndexPath, +} from "@/lib/seo/alternatives" +import { + compareIndexDescription, + compareIndexModifiedAt, + compareIndexPath, + compareIndexTitle, + comparisons, +} from "@/lib/seo/compare" +import { + resourceClusters, + resourceEntries, + resourcePaths, +} from "@/lib/seo/resources" + +/** + * The three hub pages as content definitions, so each one carries the Markdown + * twin the pages under it already carry. + * + * Every page below a hub answers in Markdown, and until now no hub did: an + * agent that arrived at `/compare/claude-skills-vs-plugins.md` could read that + * page and nothing above it, because `/compare.md` answered 404. The descent + * from a hub to a page existed only in HTML, and the climb back did not exist + * at all. + * + * A hub states what it indexes and then lists what it indexes, and nothing + * else: every claim belongs to the page it points at. The lists are derived + * from the same registries the HTML hubs render, so a page added to a registry + * appears in the twin with no change here. + */ + +interface DatedEntry { + publishedAt: string + modifiedAt: string +} + +interface ListedEntry { + path: string + title: string + description: string +} + +/** A hub is as old as the first page it lists. */ +function firstPublished(entries: readonly DatedEntry[]): string { + return entries.reduce( + (earliest, entry) => + entry.publishedAt < earliest ? entry.publishedAt : earliest, + entries[0]?.publishedAt ?? "1970-01-01", + ) +} + +/** And as recent as the most recently changed one. */ +function lastModified(entries: readonly DatedEntry[]): string { + return entries.reduce( + (latest, entry) => (entry.modifiedAt > latest ? entry.modifiedAt : latest), + "1970-01-01", + ) +} + +/** + * One listed page, as a link the twin builder annotates. + * + * `note` rather than `description`: the builder treats `description` as the + * page-level field it prints in the header and skips it everywhere else, and + * `note` is the key the source lists on the comparison pages already use for + * the sentence that follows a link. + */ +function hubLink(entry: ListedEntry, note = entry.description) { + return { label: entry.title, href: entry.path, note } +} + +const machineReadableIndex = { + label: "Machine-readable site overview", + href: "/llms.txt", + note: "llms.txt, with the Markdown twin of every public page", +} + +/** + * The one sentence the resources hub says about itself, shared with the HTML + * page so the two cannot describe the same URL differently. + * + * The hub describes itself as an index rather than as an answer: repeating the + * head phrase of the pages it lists is one reason search engines routed their + * queries here instead of to them. + */ +export const resourcesIndexDescription = + "Browse the full index of Skills Board guides, comparisons, and reference pages, then open the one that answers your question." + +export const resourcesHub = { + path: resourcePaths.index, + title: "Skills Board resources", + description: resourcesIndexDescription, + publishedAt: firstPublished(resourceEntries), + modifiedAt: lastModified(resourceEntries), + intro: [ + "This page is the index of the Skills Board resource pages: guides, skill format explainers, and reference pages for teams that share and operate AI skills. It answers nothing itself, and each entry below is the page that answers its own question.", + "Every page in the topics below names its publisher, its first-party sources, and the date its claims were last checked. Every one of them is also published as Markdown at the same URL with a `.md` suffix.", + ], + clustersTitle: "Topics", + clusters: resourceClusters.map((cluster) => ({ + title: cluster.title, + intro: cluster.description, + entries: cluster.entries.map((entry) => hubLink(entry)), + })), + relatedTitle: "Related resources", + related: [ + { + label: "Comparisons", + href: compareIndexPath, + note: "The hub for the AI agent primitives that look interchangeable", + }, + { + label: "Skills Board alternatives", + href: alternativesIndexPath, + note: "The hub for the other ways a team can share skills", + }, + { + label: "About Skills Board", + href: resourcePaths.about, + note: "Why Skills Board exists", + }, + machineReadableIndex, + ], +} as const + +export const compareHub = { + path: compareIndexPath, + title: compareIndexTitle, + description: compareIndexDescription, + publishedAt: firstPublished(comparisons), + modifiedAt: compareIndexModifiedAt, + intro: [ + "This page is the index of the Skills Board comparisons: pairs of AI agent primitives that look interchangeable from the outside, set next to each other on the dimensions that decide between them.", + "Each comparison opens with a short answer, then a table of what differs, then the cases where each side is the wrong pick. Every section names the first-party documentation behind it, and every page carries the date its claims were last checked. Where the documentation is silent, the page says so.", + ], + comparisonsTitle: "Comparisons", + comparisons: comparisons.map((entry) => hubLink(entry, entry.cardSummary)), + relatedTitle: "Related resources", + related: [ + { + label: "Skills Board resources", + href: resourcePaths.index, + note: "The guide and explainer index", + }, + { + label: "Skills Board alternatives", + href: alternativesIndexPath, + note: "The hub for the other ways a team can share skills", + }, + machineReadableIndex, + ], +} as const + +export const alternativesHub = { + path: alternativesIndexPath, + title: "Skills Board alternatives", + description: alternativesIndexDescription, + publishedAt: firstPublished(alternatives), + modifiedAt: alternativesIndexModifiedAt, + intro: [ + "This page is the index of the Skills Board alternatives: one page per option a team already has for passing skills around, with Skills Board next to it.", + "Each page says plainly when the other option is the better fit, and links every claim about it to a public page you can check yourself.", + ], + alternativesTitle: "Alternatives", + alternatives: alternatives.map((entry) => hubLink(entry, entry.cardSummary)), + relatedTitle: "Related resources", + related: [ + { + label: "Skills Board resources", + href: resourcePaths.index, + note: "The guide and explainer index", + }, + { + label: "Comparisons", + href: compareIndexPath, + note: "The hub for the AI agent primitives that look interchangeable", + }, + machineReadableIndex, + ], +} as const diff --git a/lib/web-mcp-pages.ts b/lib/web-mcp-pages.ts index 1877956..fc9e311 100644 --- a/lib/web-mcp-pages.ts +++ b/lib/web-mcp-pages.ts @@ -3,6 +3,7 @@ import { alternatives } from "@/lib/seo/alternatives" import { comparisons } from "@/lib/seo/compare" import { developers } from "@/lib/seo/developers" import { home } from "@/lib/seo/home" +import { alternativesHub, compareHub, resourcesHub } from "@/lib/seo/hubs" import { resourceEntries } from "@/lib/seo/resources" import type { WebMcpPage } from "@/lib/web-mcp-tools" @@ -23,8 +24,11 @@ import type { WebMcpPage } from "@/lib/web-mcp-tools" */ export const webMcpPages: readonly WebMcpPage[] = [ home, + resourcesHub, ...resourceEntries, + alternativesHub, ...alternatives, + compareHub, ...comparisons, developers, ].map( diff --git a/next.config.ts b/next.config.ts index b8bc1fd..140b298 100644 --- a/next.config.ts +++ b/next.config.ts @@ -266,8 +266,9 @@ const nextConfig = { // // Scoped to the URL shapes that have a Markdown twin instead of the whole // site: `/-skills`, a top-level article that does not end in - // `-skills`, a guide, an alternative, or a comparison. A request for a - // page outside those shapes keeps returning HTML rather than a 404. + // `-skills`, one of the three hubs, a guide, an alternative, or a + // comparison. A request for a page outside those shapes keeps returning + // HTML rather than a 404. beforeFiles: [ // The home page. It is the URL an agent scanning the site reaches // first, so it is the one that most needs to answer in Markdown. @@ -316,6 +317,23 @@ const nextConfig = { has: [MARKDOWN_ACCEPT], destination: "/api/markdown?path=/claude-code-for-teams", }, + // The three hubs. Each one is a page in its own right, so the rules + // below it, which all carry a slug, never match it. + { + source: "/resources", + has: [MARKDOWN_ACCEPT], + destination: "/api/markdown?path=/resources", + }, + { + source: "/alternatives", + has: [MARKDOWN_ACCEPT], + destination: "/api/markdown?path=/alternatives", + }, + { + source: "/compare", + has: [MARKDOWN_ACCEPT], + destination: "/api/markdown?path=/compare", + }, { source: "/guides/:slug", has: [MARKDOWN_ACCEPT], diff --git a/public/llms.txt b/public/llms.txt index 798ad2f..8fa7130 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -14,7 +14,7 @@ Key facts: - MCP limits: cannot edit or delete saved team skills, install skills in an agent, or execute skills - Hosted product: free forever, no trial, credit card, or paid tier -Markdown versions: the home page and every guide, skill format explainer, and alternatives page listed below is also served as Markdown at the same URL with a `.md` suffix (for example https://www.skillsboard.sh/codex-skills.md; the home page is https://www.skillsboard.sh/index.md), and the page URL returns the same Markdown when the request sends `Accept: text/markdown`. Those responses carry `Content-Type: text/markdown` and an `x-markdown-tokens` estimate. +Markdown versions: the home page, the resources, comparisons, and alternatives hubs, and every guide, skill format explainer, comparison, and alternatives page listed below is also served as Markdown at the same URL with a `.md` suffix (for example https://www.skillsboard.sh/codex-skills.md; the home page is https://www.skillsboard.sh/index.md), and the page URL returns the same Markdown when the request sends `Accept: text/markdown`. Those responses carry `Content-Type: text/markdown` and an `x-markdown-tokens` estimate. ## Core pages - [Sitemap](https://www.skillsboard.sh/sitemap.xml): Indexable public URLs @@ -74,6 +74,7 @@ Markdown versions: the home page and every guide, skill format explainer, and al - [Claude skills vs MCP](https://www.skillsboard.sh/compare/claude-skills-vs-mcp): What a skill is for and what the Model Context Protocol is for, why they are not alternatives, and what a repository that commits both looks like - [Claude skills vs plugins](https://www.skillsboard.sh/compare/claude-skills-vs-plugins): A skill as content and a Claude Code plugin as packaging, what each can carry, how each is distributed and versioned, and how a plugin ships a skill - [Claude skills vs slash commands](https://www.skillsboard.sh/compare/claude-skills-vs-slash-commands): Why Claude Code documents custom commands as merged into skills, what still differs between a command file and a SKILL.md, and which file wins the same command name +- [Alternatives](https://www.skillsboard.sh/alternatives): Skills Board next to a shared GitHub repository, skills.sh, Smithery, and Superpowers, with the case for each one - [Skills Board vs Superpowers](https://www.skillsboard.sh/alternatives/superpowers): What the MIT-licensed Superpowers plugin installs, the fourteen skill folders and per-harness install steps, and where a team registry sits next to it ## Team skill workflows diff --git a/tests/hub-markdown-twins.test.mjs b/tests/hub-markdown-twins.test.mjs new file mode 100644 index 0000000..abcff9d --- /dev/null +++ b/tests/hub-markdown-twins.test.mjs @@ -0,0 +1,225 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import { test } from "node:test" + +import "./helpers/register-app-aliases.mjs" + +const { GET } = await import("../app/api/markdown/route.ts") +const { + hasMarkdownTwin, + markdownTwinAlternates, + markdownTwinPath, + renderMarkdownTwin, +} = await import("../lib/markdown/twins.ts") +const { alternatives } = await import("../lib/seo/alternatives.ts") +const { comparisons } = await import("../lib/seo/compare/index.ts") +const { alternativesHub, compareHub, resourcesHub } = await import( + "../lib/seo/hubs.ts" +) +const { resourceClusters } = await import("../lib/seo/resources.ts") +const { webMcpPages } = await import("../lib/web-mcp-pages.ts") +const { siteConfig } = await import("../lib/site.ts") +const { default: sitemap } = await import("../app/sitemap.ts") +const { default: nextConfig } = await import("../next.config.ts") + +/** + * The three hubs, each with the pages it indexes. + * + * A hub twin exists to be the node above those pages: an agent that reached a + * child through its `.md` URL has somewhere to climb to, and finds the sibling + * pages there. A hub that listed none of its children would answer 200 and + * still leave the graph broken, so the list is what these tests check. + */ +const hubs = [ + { + hub: resourcesHub, + children: resourceClusters.flatMap((cluster) => cluster.entries), + }, + { hub: alternativesHub, children: alternatives }, + { hub: compareHub, children: comparisons }, +] + +async function markdownResponse(url) { + const response = await GET( + new Request(url, { headers: { Accept: "text/markdown" } }), + ) + return { response, body: await response.text() } +} + +test("every page a hub lists as a topic or collection entry carries a Markdown twin", () => { + // The resources hub intro promises a `.md` suffix for the pages under its + // topics. This test is that promise: a page added to a hub list without a + // twin breaks the build here, not in an agent's 404. + for (const { hub, children } of hubs) { + for (const child of children) { + assert.ok( + hasMarkdownTwin(child.path), + `${hub.path} lists ${child.path}, which has no Markdown twin`, + ) + } + } +}) + +test("each hub publishes a twin at its own path plus .md", () => { + for (const { hub } of hubs) { + assert.ok(hasMarkdownTwin(hub.path), `${hub.path} has no Markdown twin`) + assert.equal(markdownTwinPath(hub.path), `${hub.path}.md`) + + const markdown = renderMarkdownTwin(hub.path) ?? "" + assert.ok( + markdown.startsWith(`# ${hub.title}\n`), + `${hub.path} twin does not open with its own title`, + ) + assert.ok(markdown.includes(`> ${hub.description}`)) + assert.ok( + markdown.includes(`Canonical URL: ${siteConfig.url}${hub.path}`), + `${hub.path} twin does not name the HTML page as canonical`, + ) + assert.ok( + markdown.includes(`Markdown URL: ${siteConfig.url}${hub.path}.md`), + `${hub.path} twin does not name its own URL`, + ) + } +}) + +test("a hub twin links to every page it indexes", () => { + for (const { hub, children } of hubs) { + const markdown = renderMarkdownTwin(hub.path) ?? "" + assert.ok(children.length > 0) + + for (const child of children) { + assert.ok( + markdown.includes(`](${siteConfig.url}${child.path})`), + `the ${hub.path} twin does not link to ${child.path}`, + ) + assert.ok( + markdown.includes(`[${child.title}]`), + `the ${hub.path} twin links to ${child.path} without naming it`, + ) + } + } +}) + +test("a hub twin says what each page it lists is about", () => { + for (const { hub, children } of hubs) { + const markdown = renderMarkdownTwin(hub.path) ?? "" + + for (const child of children) { + const note = child.cardSummary ?? child.description + assert.ok( + markdown.includes(`](${siteConfig.url}${child.path}): ${note}`), + `the ${hub.path} twin lists ${child.path} with no summary beside it`, + ) + } + } +}) + +test("the hub twins point at each other, so the three are one graph", () => { + for (const { hub } of hubs) { + const markdown = renderMarkdownTwin(hub.path) ?? "" + const others = hubs + .map(({ hub: other }) => other.path) + .filter((path) => path !== hub.path) + + for (const path of others) { + assert.ok( + markdown.includes(`](${siteConfig.url}${path})`), + `the ${hub.path} twin does not reach the ${path} hub`, + ) + } + } +}) + +test("the .md URL and the negotiated page URL answer with the same document", async () => { + for (const { hub } of hubs) { + const expected = renderMarkdownTwin(hub.path) + + for (const url of [ + // The rewrite destination, with and without the extension. + `${siteConfig.url}/api/markdown?path=${hub.path}`, + `${siteConfig.url}/api/markdown?path=${hub.path}.md`, + // The same request where the destination query was dropped, which is + // what `next start` does and Vercel does not. + `${siteConfig.url}${hub.path}.md`, + `${siteConfig.url}${hub.path}`, + ]) { + const { response, body } = await markdownResponse(url) + + assert.equal(response.status, 200, `${url} did not answer with the twin`) + assert.equal(body, expected, `${url} answered with another document`) + assert.equal( + response.headers.get("content-type"), + "text/markdown; charset=utf-8", + ) + assert.equal(response.headers.get("vary"), "Accept") + } + } +}) + +test("each hub path negotiates Markdown the way its children do", async () => { + const { beforeFiles } = await nextConfig.rewrites() + + for (const { hub } of hubs) { + const rule = beforeFiles.find((candidate) => candidate.source === hub.path) + + assert.ok(rule, `${hub.path} has no content negotiation rule`) + assert.equal(rule.destination, `/api/markdown?path=${hub.path}`) + assert.equal(rule.has?.[0]?.key, "accept") + } +}) + +test("each hub advertises its twin as an alternate of the HTML page", () => { + for (const { hub } of hubs) { + assert.deepEqual(markdownTwinAlternates(hub.path), { + canonical: hub.path, + types: { "text/markdown": `${hub.path}.md` }, + }) + } +}) + +test("WebMCP reaches the hubs, and each one sits above its own collection", () => { + const paths = webMcpPages.map((page) => page.path) + + for (const { hub, children } of hubs) { + const hubIndex = paths.indexOf(hub.path) + assert.notEqual(hubIndex, -1, `${hub.path} is missing from the catalogue`) + + for (const child of children) { + assert.ok( + paths.indexOf(child.path) > hubIndex, + `${child.path} is listed before the hub that indexes it`, + ) + } + } +}) + +test("the hubs are listed in llms.txt by their page URL", async () => { + const llms = await readFile(new URL("../public/llms.txt", import.meta.url), "utf8") + + for (const { hub } of hubs) { + assert.ok( + llms.includes(`${siteConfig.url}${hub.path})`), + `${hub.path} is missing from public/llms.txt`, + ) + } +}) + +test("a twin stays out of the sitemap, and the page it belongs to stays in", () => { + const urls = sitemap().map((entry) => entry.url) + + // The HTML page is the canonical document, and the twin is a representation + // of it rather than a second page. Listing both would offer a crawler two + // URLs for one document. + assert.equal( + urls.filter((url) => url.endsWith(".md")).length, + 0, + "a Markdown twin is in the sitemap", + ) + + for (const { hub } of hubs) { + assert.ok( + urls.includes(`${siteConfig.url}${hub.path}`), + `${hub.path} left the sitemap`, + ) + } +}) diff --git a/tests/markdown-twins.test.mjs b/tests/markdown-twins.test.mjs index aa0f714..ad8fe46 100644 --- a/tests/markdown-twins.test.mjs +++ b/tests/markdown-twins.test.mjs @@ -10,6 +10,7 @@ const { home } = await import("../lib/seo/home.ts") const { alternatives } = await import("../lib/seo/alternatives.ts") const { comparisons } = await import("../lib/seo/compare/index.ts") const { developers } = await import("../lib/seo/developers.ts") +const { alternativesHub, compareHub, resourcesHub } = await import("../lib/seo/hubs.ts") const { resourceEntries } = await import("../lib/seo/resources.ts") const { default: nextConfig } = await import("../next.config.ts") @@ -20,10 +21,14 @@ test("every registered resource, alternative, and comparison has a Markdown twin // built from section components, with `lib/seo/home` as the content // definition written for it, and the developer docs describe an interface // rather than being a resource article, so they carry their own definition. + // Each hub sits immediately above the collection it lists. const registered = [ home, + resourcesHub, ...resourceEntries, + alternativesHub, ...alternatives, + compareHub, ...comparisons, developers, ].map((entry) => entry.path)