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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions app/resources/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions lib/markdown/twins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

/**
Expand All @@ -17,6 +18,11 @@ import { resourceEntries } from "@/lib/seo/resources"
* alternative, or a new comparison gets a twin at `<path>.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
Expand All @@ -25,8 +31,11 @@ import { resourceEntries } from "@/lib/seo/resources"
*/
const twinEntries: readonly MarkdownContentEntry[] = [
home,
resourcesHub,
...resourceEntries,
alternativesHub,
...alternatives,
compareHub,
...comparisons,
developers,
]
Expand Down
2 changes: 1 addition & 1 deletion lib/seo/alternative-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/seo/compare-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
184 changes: 184 additions & 0 deletions lib/seo/hubs.ts
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions lib/web-mcp-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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(
Expand Down
22 changes: 20 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,9 @@ const nextConfig = {
//
// Scoped to the URL shapes that have a Markdown twin instead of the whole
// site: `/<something>-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.
Expand Down Expand Up @@ -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],
Expand Down
3 changes: 2 additions & 1 deletion public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading