From 121cf958768fc13b616aa20aea292a8d8890f53d Mon Sep 17 00:00:00 2001
From: Harlan Wilton
Intro text.
\nLater.
' + expect(excerptSkillHtml(html)).toBe('Intro text.
') + }) + + it('keeps text that comes before the first heading', () => { + const html = 'Lead.
\nBody.
' + expect(excerptSkillHtml(html)).toBe('Lead.
') + }) + + it('returns the whole body when there is no second heading and it is short', () => { + const html = 'Short.
' + expect(excerptSkillHtml(html)).toBe(html) + }) + + it('cuts a long section at a paragraph boundary', () => { + const para = `${'word '.repeat(60).trim()}
` + const html = `\n\nInside
\nQuote.
\n
After.
' + const out = excerptSkillHtml(html) + const opens = (out?.match(//g) ?? []).length + const closes = (out?.match(/<\/blockquote>/g) ?? []).length + expect(opens).toBe(closes) + }) + + it('returns null for an empty body', () => { + expect(excerptSkillHtml(' ')).toBeNull() + expect(excerptSkillHtml(null)).toBeNull() + }) +}) From 59620b4a736bc9334ef0a69e0419a1c4f7afa9b3 Mon Sep 17 00:00:00 2001 From: Harlan WiltonDate: Wed, 30 Sep 2026 14:35:05 +1000 Subject: [PATCH 3/9] feat(seo): show the unique value template on treatment Skill pages Treatment pages swap the full SKILL.md body for facts, an excerpt, and related Skills, all from data skilld already holds. Controls stay as is. --- .../registry/app/components/SkillDetail.vue | 27 +- .../app/components/_SkillUniqueValue.vue | 243 ++++++++++++++++++ .../utils/skill-unique-value-experiment.ts | 51 ++-- 3 files changed, 295 insertions(+), 26 deletions(-) create mode 100644 layers/registry/app/components/_SkillUniqueValue.vue diff --git a/layers/registry/app/components/SkillDetail.vue b/layers/registry/app/components/SkillDetail.vue index d69336bc..f3c63fcf 100644 --- a/layers/registry/app/components/SkillDetail.vue +++ b/layers/registry/app/components/SkillDetail.vue @@ -8,9 +8,11 @@ import { partitionMetadataEntries } from '../utils/skill-metadata' import { resolveSkillPageState } from '../utils/skill-page-state' import { resolveSkillRawUrl } from '../utils/skill-raw-url' import { resolveSkillTitle } from '../utils/skill-title' +import { isUniqueValueTreatment } from '../utils/skill-unique-value-experiment' import SkillCommandPanel from './_SkillCommandPanel.vue' import SkillReceiptsPanel from './_SkillReceiptsPanel.vue' import SkillThirdPartyChecks from './_SkillThirdPartyChecks.vue' +import SkillUniqueValue from './_SkillUniqueValue.vue' const props = defineProps<{ owner: string @@ -22,6 +24,8 @@ const owner = computed(() => props.owner) const repo = computed(() => props.repo) const name = computed(() => props.name) const slug = computed(() => `${owner.value}/${repo.value}/${name.value}`) +// SEO experiment C (skill-unique-value-experiment.ts). Same page for every visitor. +const uniqueValueTreatment = computed(() => isUniqueValueTreatment(slug.value)) interface RelatedSkill { name: string @@ -1338,8 +1342,26 @@ useHead(computed(() => ({ +- +@@ -1774,6 +1796,7 @@ useHead(computed(() => ({ @@ -2091,7 +2114,7 @@ useHead(computed(() => ({ +// SEO experiment C, treatment pages only. See skill-unique-value-experiment.ts. +// Every block is built from data skilld already holds. Delete with the gate. +import type { RepoHistoryResponse } from '../../server/api/repos/[owner]/[repo]/history.get' +import type { SkillFileEntry } from '../utils/skill-unique-value' +import { useTimeAgo } from '@vueuse/core' +import RepoSparkline from '../pages/gh/[owner]/[repo]/_RepoSparkline.vue' +import { excerptSkillHtml, resolveSkillCommand, resolveSkillFileFacts } from '../utils/skill-unique-value' + +interface RelatedItem { + name: string + owner: string + repo: string + displayName: string + description: string | null + registryPath: string +} + +interface RelatedResponse { + relatedRepoSkills: RelatedItem[] + relatedOwnerSkills: RelatedItem[] + coOccurrenceSkills: RelatedItem[] + semanticSiblings: RelatedItem[] +} + +const props = defineProps<{ + owner: string + repo: string + name: string + description: string | null + stars: number + pushedAt: string | null + maturity: { sinceUpdateDays: number, cadence: 'active' | 'steady' | 'dormant' } | null + assets: SkillFileEntry[] + assetCount: number + contentHtml: string | null + compatibility: string | null + curators: { handle: string, collectionName: string, collectionSlug: string, reason?: string }[] + skillFileUrl: string + githubUrl: string + registryPath: string +}>() + +// Both requests run on the server, so the blocks sit in the HTML a crawler +// reads. Only the ten treatment pages pay for them. Related Skills come from +// the edge-cached endpoint that the browser already uses. +const { data: related } = await useFetch ( + () => `/api/skill-related/${props.owner}/${props.repo}/${props.name}`, + { key: () => `uv-related:${props.owner}/${props.repo}/${props.name}` }, +) +const { data: history } = await useFetch ( + () => `/api/repos/${props.owner}/${props.repo}/history`, + { key: () => `uv-history:${props.owner}/${props.repo}` }, +) + +const files = computed(() => resolveSkillFileFacts({ assets: props.assets, total: props.assetCount })) +const command = computed(() => resolveSkillCommand(props, files.value)) +const excerpt = computed(() => excerptSkillHtml(props.contentHtml)) +const markdownUrl = computed(() => `${props.registryPath}.md`) + +const pushedAtAgo = useTimeAgo(() => new Date(props.pushedAt ?? 0)) + +const MONTH_FORMAT = new Intl.DateTimeFormat('en-GB', { month: 'short', year: 'numeric', timeZone: 'UTC' }) +const starTrend = computed(() => { + const h = history.value?.starHistory + if (h?._tag === 'ready') + return { _tag: 'ready' as const, points: h.points } + if (h?._tag === 'collecting') + return { _tag: 'tracking' as const, label: `Tracking since ${MONTH_FORMAT.format(h.trackedSince * 1000)}` } + return null +}) + +const relatedGroups = computed(() => { + const r = related.value + if (!r) + return [] + return [ + { label: 'Similar', items: r.semanticSiblings }, + { label: 'Paired with', items: r.coOccurrenceSkills }, + { label: `From ${props.owner}/${props.repo}`, items: r.relatedRepoSkills }, + { label: `Other by ${props.owner}`, items: r.relatedOwnerSkills }, + ] + .map(g => ({ ...g, items: g.items.slice(0, 6) })) + .filter(g => g.items.length > 0) +}) + +const CADENCE_LABEL = { active: 'active', steady: 'steady', dormant: 'dormant' } as const + + + + ++ diff --git a/layers/registry/app/utils/skill-unique-value-experiment.ts b/layers/registry/app/utils/skill-unique-value-experiment.ts index a18c0461..d16e975f 100644 --- a/layers/registry/app/utils/skill-unique-value-experiment.ts +++ b/layers/registry/app/utils/skill-unique-value-experiment.ts @@ -13,13 +13,16 @@ * full body. Every visitor sees the same page, crawler or not. Control pages * stay unchanged. * - * Every Skill below sat in the production skills sitemap and was indexable on - * 2026-09-30 (checked against `/api/skills`, not against an HTTP status). - * Only 7 trending Skills met that bar and render on the Skill page, so three - * treatment Skills come from the sitemap at other star counts. Each control - * Skill has the nearest star count among the remaining repositories. That - * leaves the trending signal uneven between the groups; read the result - * with that in mind. + * Every Skill below sits on a trending list (week, month, or all-time), in the + * production skills sitemap, and was indexable on 2026-09-30. A parallel + * experiment keeps only trending Skills indexable, so a control outside those + * lists would leave the index. Membership came from `/api/feed/trending`, + * `/api/skills/leaderboard`, and the sitemap, checked against `/api/skills`, + * not against an HTTP status. Only Skills whose canonical page is the + * three-segment `SkillDetail` route qualify; single-Skill repositories render + * on the repository hub. Twenty-two Skills qualified. Two were dropped and the + * rest were paired by repository stars. Within each pair, the treatment Skill + * alternates between the higher and the lower star count. * * Cull path: after the decision, delete this file, `skill-unique-value.ts`, * `_SkillUniqueValue.vue`, and the `isUniqueValueTreatment` branches in @@ -31,29 +34,29 @@ export const UNIQUE_VALUE_EXPERIMENT = { endsOn: '2026-11-11', /** Treatment: `owner/repo/name`. */ treatment: [ + 'obra/superpowers/writing-plans', + 'imbad0202/academic-research-skills/academic-pipeline', 'emilkowalski/skills/emil-design-eng', 'vercel-labs/agent-skills/react-best-practices', - 'microsoft/playwright-cli/playwright-cli', - 'getsentry/skills/prompt-optimizer', + 'ibelick/ui-skills/create-design-md', + 'remotion-dev/skills/remotion-best-practices', + 'dimillian/skills/bug-hunt-swarm', + 'onmax/nuxt-skills/arkenv', 'neondatabase/agent-skills/neon-postgres', 'clerk/skills/clerk-orgs', - 'prisma/skills/prisma-database-setup', - 'remotion-dev/skills/remotion-best-practices', - 'makenotion/claude-code-notion-plugin/knowledge-capture', - 'resend/resend-skills/agent-email-inbox', ], - /** Control: matched to the treatment Skill on the same line by repository stars. */ + /** Control: the star-matched partner of the treatment Skill at the same index. */ control: [ - 'vercel-labs/agent-browser/agent-browser', - 'openai/skills/aspnet-core', - 'huggingface/skills/hf-cli', - 'kotlin/kotlin-agent-skills/kotlin-backend-jpa-entity-mapping', - 'wdm0006/python-skills/verifying-external-behavior', - 'mapbox/mapbox-agent-skills/mapbox-android-patterns', - 'get-convex/agent-skills/convex-quickstart', - 'google-gemini/gemini-skills/gemini-api-dev', - 'tavily-ai/skills/tavily-best-practices', - 'sanity-io/agent-toolkit/content-experimentation-best-practices', + 'mattpocock/skills/ask-matt', + 'coreyhaines31/marketingskills/ai-seo', + 'kepano/obsidian-skills/knap', + 'othmanadi/planning-with-files/planning-with-files', + 'microsoft/playwright-cli/playwright-cli', + 'antfu/skills/nitro', + 'addyosmani/web-quality-skills/accessibility', + 'getsentry/skills/prompt-optimizer', + 'pbakaus/agent-reviews/resolve-agent-reviews', + 'prisma/skills/prisma-database-setup', ], } as const From 7abea95334ae43eabe2a3bfad829d7c908f2173b Mon Sep 17 00:00:00 2001 From: Harlan Wilton+ + ++ Facts from the repository +
++
+++ +- + {{ command._tag === 'run' ? 'Run command' : 'Install command' }} +
+- +
+{{ command.command }}++ {{ command.reason }} +
++ Your Agent reads the Skill now. Nothing lands in your repository. +
+++ +- + Files beside SKILL.md +
+- + + None. This Skill is one file. + + +
++ {{ files.total.toLocaleString() }} files +
++ {{ files.markdown.toLocaleString() }} markdown, {{ files.code.toLocaleString() }} code +
++ {{ files.codePaths.slice(0, 3).join(', ') }} + and {{ (files.codePaths.length - 3).toLocaleString() }} more + +
+ +++ +- + GitHub stars +
+- + {{ stars.toLocaleString() }} + +
++ + {{ starTrend.label }} + + + ++ +- + Repository updated +
+- +
++ {{ pushedAtAgo }} +
++ Update pace: {{ CADENCE_LABEL[maturity.cadence] }} +
+++ +- + Agent compatibility +
+- + {{ compatibility }} +
+++- + In collections +
+- +
++
++ {{ curator.collectionName }} + + by @{{ curator.handle }} + : {{ curator.reason }} + ++ + ++ Excerpt from SKILL.md +
++++ {{ description }} +
+ + ++ + Read the full SKILL.md on GitHub + + + Full SKILL.md as markdown + +
++ ++ Related skills +
++++++ {{ group.label }} +
++ Date: Wed, 30 Sep 2026 14:41:15 +1000 Subject: [PATCH 4/9] fix(seo): cut a fuller SKILL.md excerpt for treatment pages The excerpt showed a heading and no body, and repeated the description already in the page header. It now keeps whole blocks up to about 250 words. --- .../app/components/_SkillUniqueValue.vue | 5 +- .../registry/app/utils/skill-unique-value.ts | 186 +++++++++++++++--- test/unit/skill-unique-value.test.ts | 83 ++++++-- 3 files changed, 229 insertions(+), 45 deletions(-) diff --git a/layers/registry/app/components/_SkillUniqueValue.vue b/layers/registry/app/components/_SkillUniqueValue.vue index a9af7cc6..84a2fdb8 100644 --- a/layers/registry/app/components/_SkillUniqueValue.vue +++ b/layers/registry/app/components/_SkillUniqueValue.vue @@ -55,7 +55,7 @@ const { data: history } = await useFetch ( const files = computed(() => resolveSkillFileFacts({ assets: props.assets, total: props.assetCount })) const command = computed(() => resolveSkillCommand(props, files.value)) -const excerpt = computed(() => excerptSkillHtml(props.contentHtml)) +const excerpt = computed(() => excerptSkillHtml(props.contentHtml, { description: props.description })) const markdownUrl = computed(() => `${props.registryPath}.md`) const pushedAtAgo = useTimeAgo(() => new Date(props.pushedAt ?? 0)) @@ -205,9 +205,6 @@ const CADENCE_LABEL = { active: 'active', steady: 'steady', dormant: 'dormant' } Excerpt from SKILL.md -diff --git a/layers/registry/app/utils/skill-unique-value.ts b/layers/registry/app/utils/skill-unique-value.ts index b2337a49..fd42e438 100644 --- a/layers/registry/app/utils/skill-unique-value.ts +++ b/layers/registry/app/utils/skill-unique-value.ts @@ -68,12 +68,12 @@ export function resolveSkillCommand( return { _tag: 'run', command: skillRunCmd(skill.owner, skill.repo, skill.name) } } -const HEADING = /- {{ description }} -
])/g const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'col', 'wbr']) +const TAG = /<(\/?)([a-z][a-z0-9]*)\b[^>]*?(\/?)>/gi function isBalanced(html: string): boolean { const stack: string[] = [] - for (const match of html.matchAll(/<(\/?)([a-z][a-z0-9]*)\b[^>]*?(\/?)>/gi)) { + for (const match of html.matchAll(TAG)) { const tag = match[2]!.toLowerCase() if (VOID_TAGS.has(tag) || match[3] === '/') continue @@ -88,32 +88,172 @@ function isBalanced(html: string): boolean { return stack.length === 0 } +/** Top-level blocks of rendered HTML. Malformed markup yields no blocks. */ +function splitBlocks(html: string): string[] { + const blocks: string[] = [] + let depth = 0 + let start = 0 + const push = (end: number): void => { + const block = html.slice(start, end).trim() + if (block) + blocks.push(block) + start = end + } + for (const match of html.matchAll(TAG)) { + const tag = match[2]!.toLowerCase() + const end = match.index + match[0].length + if (VOID_TAGS.has(tag) || match[3] === '/') { + if (depth === 0) + push(end) + } + else if (match[1] === '/') { + depth-- + if (depth < 0) + return [] + if (depth === 0) + push(end) + } + else { + depth++ + } + } + if (depth !== 0) + return [] + push(html.length) + return blocks +} + +function textOf(html: string): string { + return html + .replace(/<[^>]*>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '\'') + .replace(/\s+/g, ' ') + .trim() +} + +/** Words a reader sees in rendered HTML. */ +export function countHtmlWords(html: string): number { + const text = textOf(html) + return text ? text.split(' ').length : 0 +} + +const isHeading = (block: string): boolean => /^ ]/i.test(block) + +/** A code fence stays only when it is short enough to read as part of the excerpt. */ +const MAX_CODE_LINES = 12 +const MAX_CODE_WORDS = 60 + +/** Cut one block down to whole sentences or list items within `budget` words, or null. */ +function trimBlock(block: string, budget: number): string | null { + const open = /^<(p|ul|ol)(?:\s[^>]*)?>/i.exec(block) + if (!open) + return null + const tag = open[1]!.toLowerCase() + const inner = block.slice(open[0].length, block.length - `${tag}>`.length) + if (tag === 'p') { + const ends = [...inner.matchAll(/[.!?](?=\s|$)/g)].map(m => m.index + 1) + for (const end of ends.reverse()) { + const cut = inner.slice(0, end) + if (countHtmlWords(cut) <= budget && isBalanced(cut)) + return `${open[0]}${cut}` + } + return null + } + const items = splitBlocks(inner) + const kept: string[] = [] + let used = 0 + for (const item of items) { + const w = countHtmlWords(item) + if (used + w > budget) + break + kept.push(item) + used += w + } + return kept.length ? `${open[0]}\n${kept.join('\n')}\n${tag}>` : null +} + +export interface ExcerptOptions { + /** The frontmatter description. A block that repeats it is dropped. */ + description?: string | null + /** Upper bound on words. */ + maxWords?: number + /** A first section with fewer body words pulls in the second one. */ + shortSectionWords?: number +} + /** - * The first section of the rendered SKILL.md: everything before the second - * heading, or before the first when text leads. The API already renders the - * body to HTML, so this cuts that HTML and never reads markdown. The result is - * always balanced HTML, or null when nothing is left. + * The opening of the rendered SKILL.md: the first section's blocks, plus the + * second section when the first is short, capped near `maxWords`. The API + * already renders the body to HTML, so this cuts that HTML and never reads + * markdown. It cuts only at block boundaries, or at a sentence or list item + * when one block alone exceeds the cap, so it never ends mid-sentence. It + * returns balanced HTML, or null when no body text is left. */ -export function excerptSkillHtml(html: string | null | undefined, maxChars = 1600): string | null { +export function excerptSkillHtml(html: string | null | undefined, options: ExcerptOptions = {}): string | null { + const { description = null, maxWords = 250, shortSectionWords = 120 } = options const source = html?.trim() if (!source) return null - const headings = [...source.matchAll(HEADING)].map(m => m.index) - // Text before any heading is the lead; otherwise the first heading opens the section. - const cut = headings[0] === 0 ? headings[1] : headings[0] - const section = (cut === undefined ? source : source.slice(0, cut)).trim() - - const candidates = [section] - if (section.length > maxChars) { - const window = section.slice(0, maxChars) - const boundary = window.lastIndexOf('') - if (boundary > 0) - candidates.unshift(window.slice(0, boundary + ''.length)) - const firstBlock = section.indexOf('') - if (firstBlock > 0) - candidates.push(section.slice(0, firstBlock + ''.length)) + const described = description ? textOf(description) : null + const usable = splitBlocks(source).filter((block) => { + if (described && !isHeading(block) && textOf(block) === described) + return false + if (/^ ]/i.test(block)) + return block.split('\n').length <= MAX_CODE_LINES && countHtmlWords(block) <= MAX_CODE_WORDS + return true + }) + + // Sections start at a heading. Text ahead of the first heading is its own section. + const sections: string[][] = [] + for (const block of usable) { + if (isHeading(block) || sections.length === 0) + sections.push([block]) + else + sections.at(-1)!.push(block) + } + const bodyWords = (section: string[]): number => + section.filter(b => !isHeading(b)).reduce((sum, b) => sum + countHtmlWords(b), 0) + + const chosen = [sections[0] ?? []] + let next = 1 + while (bodyWords(chosen.flat()) < shortSectionWords && sections[next] && chosen.length < 2) { + chosen.push(sections[next]!) + next++ + } + // A first section with no body pulls in following sections until one has text. + while (bodyWords(chosen.flat()) === 0 && sections[next]) { + chosen.push(sections[next]!) + next++ + } + + const out: string[] = [] + let words = 0 + let bodyBlocks = 0 + for (const block of chosen.flat()) { + const w = countHtmlWords(block) + if (words + w <= maxWords) { + out.push(block) + words += w + if (!isHeading(block)) + bodyBlocks++ + continue + } + if (isHeading(block)) + break + const trimmed = trimBlock(block, maxWords - words) + if (trimmed) { + out.push(trimmed) + bodyBlocks++ + } + break } - const fits = candidates.find(c => c.length <= maxChars && isBalanced(c)) - return fits ?? null + while (out.length && isHeading(out.at(-1)!)) + out.pop() + return bodyBlocks > 0 ? out.join('\n') : null } diff --git a/test/unit/skill-unique-value.test.ts b/test/unit/skill-unique-value.test.ts index 663f3fc4..d3a18e44 100644 --- a/test/unit/skill-unique-value.test.ts +++ b/test/unit/skill-unique-value.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + countHtmlWords, excerptSkillHtml, resolveSkillCommand, resolveSkillFileFacts, @@ -80,37 +81,83 @@ describe('resolveSkillCommand', () => { }) }) +const words = (n: number, w = 'word') => Array.from({ length: n }).fill(w).join(' ') +const sentences = (n: number) => Array.from({ length: n }, (_, i) => `Sentence number ${i} says something.`).join(' ') + describe('excerptSkillHtml', () => { - it('keeps the first heading and its intro, and drops later sections', () => { - const html = 'Title
\nIntro text.
\nStep 1
\nLater.
' - expect(excerptSkillHtml(html)).toBe('Title
\nIntro text.
') + it('keeps the first heading with its body text', () => { + const html = `Title
\nIntro text.
\n\n
\n- one
\nNext
\n${words(200)}
` + const out = excerptSkillHtml(html)! + expect(out).toContain('Intro text.
') + expect(out).toContain('one ') + }) + + it('adds the second section when the first is short', () => { + const html = `One
\n${words(20)}
\nTwo
\nSecond body.
\nThree
\nNever shown.
` + const out = excerptSkillHtml(html)! + expect(out).toContain('Second body.') + expect(out).not.toContain('Never shown.') }) - it('keeps text that comes before the first heading', () => { - const html = 'Lead.
\nFirst
\nBody.
' - expect(excerptSkillHtml(html)).toBe('Lead.
') + it('skips ahead when the first section is only a heading', () => { + const html = 'Writing Plans
\nOverview
\nThe real body starts here.
\nLater
\nNot shown.
' + const out = excerptSkillHtml(html)! + expect(out).toContain('The real body starts here.') + expect(out).not.toContain('Not shown.') }) - it('returns the whole body when there is no second heading and it is short', () => { - const html = 'Only
\nShort.
' - expect(excerptSkillHtml(html)).toBe(html) + it('returns null when the page holds only headings', () => { + expect(excerptSkillHtml('A
\nB
')).toBeNull() }) - it('cuts a long section at a paragraph boundary', () => { - const para = `${'word '.repeat(60).trim()}
` + it('does not add the second section when the first is long enough', () => { + const html = `One
\n${words(130)}
\nTwo
\nSecond body.
` + expect(excerptSkillHtml(html)!).not.toContain('Second body.') + }) + + it('caps a long section at a block boundary', () => { + const para = `${words(100)}
` const html = `Title
\n${para}\n${para}\n${para}\n${para}` - const out = excerptSkillHtml(html, 700)! - expect(out.length).toBeLessThanOrEqual(700) + const out = excerptSkillHtml(html, { maxWords: 250 })! + expect(countHtmlWords(out)).toBeLessThanOrEqual(250) expect(out.endsWith('')).toBe(true) - expect(out.startsWith('Title
')).toBe(true) + expect((out.match(//g) ?? []).length).toBe(2) + }) + + it('cuts a single oversized paragraph at a sentence end', () => { + const html = `
Title
\n${sentences(80)}
` + const out = excerptSkillHtml(html, { maxWords: 100 })! + expect(countHtmlWords(out)).toBeLessThanOrEqual(100) + expect(out).toMatch(/says something\.<\/p>$/) + }) + + it('cuts an oversized list at an item boundary', () => { + const items = Array.from({ length: 60 }, (_, i) => `item ${i} ${words(4)} `).join('\n') + const out = excerptSkillHtml(`Title
\n\n${items}\n
`, { maxWords: 50 })! + expect(countHtmlWords(out)).toBeLessThanOrEqual(50) + expect(out.endsWith('\n')).toBe(true) + }) + + it('drops a block that repeats the description', () => { + const description = 'Use when you have a spec, before touching code' + const html = `Title
\n${description}
\nActual guidance.
` + const out = excerptSkillHtml(html, { description })! + expect(out).not.toContain('before touching code') + expect(out).toContain('Actual guidance.') + }) + + it('keeps a short code fence and drops a long one', () => { + const short = '' + const long = `npm run build` + const out = excerptSkillHtml(`${Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\n')}T
\nIntro.
\n${short}\n${long}`)! + expect(out).toContain('npm run build') + expect(out).not.toContain('line 29') }) it('never returns unbalanced tags', () => { const html = '\n\nInside
\nQuote.
\nAfter.
' - const out = excerptSkillHtml(html) - const opens = (out?.match(//g) ?? []).length - const closes = (out?.match(/<\/blockquote>/g) ?? []).length - expect(opens).toBe(closes) + const out = excerptSkillHtml(html)! + expect((out.match(//g) ?? []).length).toBe((out.match(/<\/blockquote>/g) ?? []).length) }) it('returns null for an empty body', () => { From 1ad515dd8df2c481582fe7cf9e1f9689088a49e2 Mon Sep 17 00:00:00 2001 From: Harlan WiltonDate: Wed, 30 Sep 2026 15:06:58 +1000 Subject: [PATCH 5/9] feat(seo): lead treatment pages with run, install as the opt-in VISION says every Skill surface leads with the run command. The reason text that pushed install for Skills with code is gone. --- .../app/components/_SkillUniqueValue.vue | 20 ++++++---- .../utils/skill-unique-value-experiment.ts | 2 +- .../registry/app/utils/skill-unique-value.ts | 34 ++++++----------- test/unit/skill-unique-value.test.ts | 38 +++---------------- 4 files changed, 30 insertions(+), 64 deletions(-) diff --git a/layers/registry/app/components/_SkillUniqueValue.vue b/layers/registry/app/components/_SkillUniqueValue.vue index 84a2fdb8..764862eb 100644 --- a/layers/registry/app/components/_SkillUniqueValue.vue +++ b/layers/registry/app/components/_SkillUniqueValue.vue @@ -5,7 +5,7 @@ import type { RepoHistoryResponse } from '../../server/api/repos/[owner]/[repo]/ import type { SkillFileEntry } from '../utils/skill-unique-value' import { useTimeAgo } from '@vueuse/core' import RepoSparkline from '../pages/gh/[owner]/[repo]/_RepoSparkline.vue' -import { excerptSkillHtml, resolveSkillCommand, resolveSkillFileFacts } from '../utils/skill-unique-value' +import { excerptSkillHtml, resolveSkillCommands, resolveSkillFileFacts } from '../utils/skill-unique-value' interface RelatedItem { name: string @@ -54,7 +54,7 @@ const { data: history } = await useFetch ( ) const files = computed(() => resolveSkillFileFacts({ assets: props.assets, total: props.assetCount })) -const command = computed(() => resolveSkillCommand(props, files.value)) +const commands = computed(() => resolveSkillCommands(props)) const excerpt = computed(() => excerptSkillHtml(props.contentHtml, { description: props.description })) const markdownUrl = computed(() => `${props.registryPath}.md`) @@ -96,16 +96,20 @@ const CADENCE_LABEL = { active: 'active', steady: 'steady', dormant: 'dormant' }
diff --git a/layers/registry/app/utils/skill-unique-value-experiment.ts b/layers/registry/app/utils/skill-unique-value-experiment.ts index d16e975f..51d6e7ae 100644 --- a/layers/registry/app/utils/skill-unique-value-experiment.ts +++ b/layers/registry/app/utils/skill-unique-value-experiment.ts @@ -8,7 +8,7 @@ * The question: do pages that carry data GitHub lacks get crawled and indexed * sooner than pages that stay as they are? * - * Treatment pages show the run or install command, the file inventory, the + * Treatment pages show the run command with install beneath it, the file inventory, the * repository star trend, related Skills, and a SKILL.md excerpt in place of the * full body. Every visitor sees the same page, crawler or not. Control pages * stay unchanged. diff --git a/layers/registry/app/utils/skill-unique-value.ts b/layers/registry/app/utils/skill-unique-value.ts index fd42e438..5294dc43 100644 --- a/layers/registry/app/utils/skill-unique-value.ts +++ b/layers/registry/app/utils/skill-unique-value.ts @@ -40,32 +40,20 @@ export function resolveSkillFileFacts(input: { assets: readonly SkillFileEntry[] } } -export type SkillCommand - = | { _tag: 'run', command: string } - | { _tag: 'install', command: string, reason: string } +export interface SkillCommands { + run: string + install: string +} /** - * A remote run prints SKILL.md and text files only. It never prints - * executable or binary files, so a Skill that carries code, or carries files - * the page cannot inspect, gets the install command and a reason. See - * "Running is the default; installing is the opt-in" in `AGENTS.md`. + * Every Skill surface leads with the run command. The install command sits + * beneath it as the opt-in for keeping the Skill (`VISION.md`). */ -export function resolveSkillCommand( - skill: { owner: string, repo: string, name: string }, - files: SkillFileFacts, -): SkillCommand { - const install = (reason: string): SkillCommand => ({ - _tag: 'install', - command: skillInstallCmd(skill.owner, skill.repo, skill.name), - reason, - }) - if (files._tag === 'only-skill-md') - return { _tag: 'run', command: skillRunCmd(skill.owner, skill.repo, skill.name) } - if (files.code > 0 || files.other > 0) - return install('This Skill ships code files. A remote run prints only text, so install it to use the code.') - if (!files.complete) - return install('This Skill ships more files than this page lists. A remote run cannot show they are all text, so install it.') - return { _tag: 'run', command: skillRunCmd(skill.owner, skill.repo, skill.name) } +export function resolveSkillCommands(skill: { owner: string, repo: string, name: string }): SkillCommands { + return { + run: skillRunCmd(skill.owner, skill.repo, skill.name), + install: skillInstallCmd(skill.owner, skill.repo, skill.name), + } } const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'col', 'wbr']) diff --git a/test/unit/skill-unique-value.test.ts b/test/unit/skill-unique-value.test.ts index d3a18e44..5f14eb8a 100644 --- a/test/unit/skill-unique-value.test.ts +++ b/test/unit/skill-unique-value.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { countHtmlWords, excerptSkillHtml, - resolveSkillCommand, + resolveSkillCommands, resolveSkillFileFacts, } from '../../layers/registry/app/utils/skill-unique-value' @@ -46,39 +46,13 @@ describe('resolveSkillFileFacts', () => { }) }) -describe('resolveSkillCommand', () => { - it('offers the run command when the Skill ships only text', () => { - const files = resolveSkillFileFacts({ assets: [asset('a.md', 'markdown')], total: 1 }) - expect(resolveSkillCommand(ref, files)).toEqual({ - _tag: 'run', - command: 'npx skilld run acme/skills/lint', +describe('resolveSkillCommands', () => { + it('leads with run and offers install as the opt-in', () => { + expect(resolveSkillCommands(ref)).toEqual({ + run: 'npx skilld run acme/skills/lint', + install: 'npx skilld install acme/skills/lint', }) }) - - it('offers the run command when the Skill is only SKILL.md', () => { - const files = resolveSkillFileFacts({ assets: [], total: 0 }) - expect(resolveSkillCommand(ref, files)._tag).toBe('run') - }) - - it('offers only install when the Skill ships code, and says why', () => { - const files = resolveSkillFileFacts({ assets: [asset('scripts/x.sh', 'code')], total: 1 }) - const command = resolveSkillCommand(ref, files) - expect(command._tag).toBe('install') - if (command._tag === 'install') { - expect(command.command).toBe('npx skilld install acme/skills/lint') - expect(command.reason).toMatch(/code/) - } - }) - - it('offers only install when a binary file has no known type', () => { - const files = resolveSkillFileFacts({ assets: [asset('bin/tool', 'other')], total: 1 }) - expect(resolveSkillCommand(ref, files)._tag).toBe('install') - }) - - it('offers only install when the file list is cut short', () => { - const files = resolveSkillFileFacts({ assets: [asset('a.md', 'markdown')], total: 400 }) - expect(resolveSkillCommand(ref, files)._tag).toBe('install') - }) }) const words = (n: number, w = 'word') => Array.from({ length: n }).fill(w).join(' ') From bf11ae69fc1ed6ec3d8c7af22f698e0c28f12d07 Mon Sep 17 00:00:00 2001 From: Harlan Wilton- - {{ command._tag === 'run' ? 'Run command' : 'Install command' }} + Run command
- -
{{ command.command }}-- {{ command.reason }} -
-+
{{ commands.run }}+Your Agent reads the Skill now. Nothing lands in your repository.
++ Install command +
+{{ commands.install }}++ Install it to keep the Skill in every session. +
Date: Wed, 30 Sep 2026 16:03:22 +1000 Subject: [PATCH 6/9] test(e2e): serve the built Worker on the Playwright port `nuxi preview --port 5678` starts wrangler on 8787 for the Cloudflare preset, so Playwright timed out waiting for 5678. The Worker also read an empty D1 under .output, so every Skill API call failed with 500 and a missing Skill page rendered its error state with status 200. The script now applies local migrations and runs wrangler dev against the same local D1 that `pnpm dev` uses. A fresh checkout gets a migrated, empty D1. --- package.json | 2 +- playwright.config.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2ed21ca5..47d22f63 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "cron:parity": "tsx scripts/check-schedule-parity.ts", "design:eyebrows": "tsx scripts/check-eyebrows.ts", "cli:grammar": "tsx scripts/check-published-cli-grammar.ts", - "start:playwright:webserver": "NODE_ENV=test nuxi preview --port 5678", + "start:playwright:webserver": "wrangler d1 migrations apply DB --local --config wrangler.jsonc && NODE_ENV=test wrangler dev --config .output/server/wrangler.json --persist-to .wrangler/state --port 5678", "db:migrations:dev": "wrangler d1 migrations apply DB --local", "db:migrations:prod": "wrangler d1 migrations apply DB --remote", "checkin": "nuxt-checkin prepare && nuxt-checkin" diff --git a/playwright.config.ts b/playwright.config.ts index a223862a..732a9147 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,6 +10,10 @@ export default defineConfig({ retries: process.env.CI ? 2 : 0, reporter: 'html', timeout: 120_000, + // The script runs the built Worker with wrangler. `nuxi preview` ignores + // `--port` on the Cloudflare preset and listens on 8787. The Worker reads + // the migrated local D1 that `pnpm dev` uses. Without it, every Skill API + // call fails with 500 and pages render their error state. webServer: { command: 'pnpm start:playwright:webserver', url: baseURL, From 43da1e4ba6e12cb8cbcc15903bbd028c82369f62 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 30 Sep 2026 16:03:23 +1000 Subject: [PATCH 7/9] test(e2e): accept the self canonical on a missing Skill page The page sets no canonical for a missing Skill, but nuxt-seo-utils adds a self canonical to every page that is not a Nuxt error. That is harmless on a 404. The spec now fails only when a canonical names another URL, which is the homepage bug the PR fixes. --- test/e2e/missing-skill-status.spec.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/test/e2e/missing-skill-status.spec.ts b/test/e2e/missing-skill-status.spec.ts index 298c0ee7..ec5cc6d9 100644 --- a/test/e2e/missing-skill-status.spec.ts +++ b/test/e2e/missing-skill-status.spec.ts @@ -1,12 +1,20 @@ import { expect, test } from './test-utils' +const CANONICAL_HREF_RE = /]*rel="canonical"[^>]*href="([^"]*)"/g + test.describe('missing Skill URL', () => { - test('answers 404, noindex, and no homepage canonical', async ({ page, baseURL }) => { - const res = await page.request.get(`${baseURL}/gh/some-nonexistent-owner/repo/skill`) + test('answers 404, noindex, and no canonical to another page', async ({ page, baseURL }) => { + const path = '/gh/some-nonexistent-owner/repo/skill' + const res = await page.request.get(`${baseURL}${path}`) expect(res.status()).toBe(404) const html = await res.text() expect(html).toContain('noindex,follow') - expect(html).not.toMatch(/ match[1]) + for (const href of canonicals) + expect(href).toBe(`https://skilld.dev${path}`) }) }) From 544034dfee5037a1b9f678c764100243368c2166 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 30 Sep 2026 16:11:54 +1000 Subject: [PATCH 8/9] fix(registry): answer 503 with Retry-After when the Skill API fails A transient API error rendered a real Skill page as 200 noindex, which could drop it from Google. The failed state now answers 503 with Retry-After and emits no robots directive, so Google retries. --- .../registry/app/components/SkillDetail.vue | 11 ++++-- layers/registry/app/utils/skill-page-state.ts | 28 ++++++++++----- test/unit/skill-page-state.test.ts | 36 +++++++++++++++---- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/layers/registry/app/components/SkillDetail.vue b/layers/registry/app/components/SkillDetail.vue index d69336bc..3c37ebb3 100644 --- a/layers/registry/app/components/SkillDetail.vue +++ b/layers/registry/app/components/SkillDetail.vue @@ -810,8 +810,13 @@ const pageState = computed(() => { // Set the status while rendering on the server. A missing Skill keeps its // helpful page, but the response says 404 so Google drops the URL instead of // filing it as a soft 404. -if (import.meta.server && pageState.value.status) - setResponseStatus(useRequestEvent()!, pageState.value.status) +if (import.meta.server && pageState.value.status) { + const event = useRequestEvent()! + setResponseStatus(event, pageState.value.status) + // A 503 tells Google the failure is transient, so it retries the URL. + if (pageState.value.retryAfterSeconds) + useResponseHeader('Retry-After').value = String(pageState.value.retryAfterSeconds) +} // 2026-08-22: the shared "A Claude Code skill for Cursor, Codex, and other // agents." suffix is gone. Identical boilerplate across 1,300+ meta @@ -903,7 +908,7 @@ const skillDescription = computed(() => { useSeoMeta({ title: () => skillTitle.value, description: () => skillDescription.value, - robots: () => pageState.value.robots, + robots: () => pageState.value.robots ?? undefined, ogTitle: () => skillTitle.value, ogDescription: () => skillDescription.value, twitterTitle: () => skillTitle.value, diff --git a/layers/registry/app/utils/skill-page-state.ts b/layers/registry/app/utils/skill-page-state.ts index e1c290a5..e89a62d1 100644 --- a/layers/registry/app/utils/skill-page-state.ts +++ b/layers/registry/app/utils/skill-page-state.ts @@ -22,12 +22,18 @@ export type SkillPageInput export interface SkillPageState { _tag: 'loading' | 'failed' | 'missing' | 'gone' | 'duplicate' | 'indexable' | 'noindex' /** HTTP status to force, or null to leave the response alone. */ - status: 404 | 410 | null - robots: 'index,follow' | 'noindex,follow' + status: 404 | 410 | 503 | null + /** Seconds for the `Retry-After` header. Set only with status 503. */ + retryAfterSeconds: number | null + /** The robots directive, or null to emit none and let the site default apply. */ + robots: 'index,follow' | 'noindex,follow' | null /** Site path for the canonical link, or null to omit the link. */ canonicalPath: string | null } +/** How long Google should wait before it retries a Skill page that errored. */ +export const SKILL_PAGE_RETRY_AFTER_SECONDS = 300 + /** * Decide the status, robots directive, and canonical for a Skill page. * @@ -35,22 +41,28 @@ export interface SkillPageState { * site root when no Skill loaded, which told Google that every made-up URL * duplicated the homepage. Google read those as soft 404s (2,621 pages on * 2026-09-30). A page with no Skill now omits the canonical. + * + * `failed` is an API error that is not a 404: the registry could not answer, + * so the page may be real. It answers 503 with `Retry-After` and emits no + * robots directive, so Google retries and keeps the URL. `noindex` on a + * transient error could drop a real page (D1 overload, September 2026). */ export function resolveSkillPageState(input: SkillPageInput): SkillPageState { switch (input._tag) { case 'missing': - return { _tag: 'missing', status: 404, robots: 'noindex,follow', canonicalPath: null } + return { _tag: 'missing', status: 404, retryAfterSeconds: null, robots: 'noindex,follow', canonicalPath: null } case 'loading': + return { _tag: 'loading', status: null, retryAfterSeconds: null, robots: 'noindex,follow', canonicalPath: null } case 'failed': - return { _tag: input._tag, status: null, robots: 'noindex,follow', canonicalPath: null } + return { _tag: 'failed', status: 503, retryAfterSeconds: SKILL_PAGE_RETRY_AFTER_SECONDS, robots: null, canonicalPath: null } case 'loaded': { if (input.sourceGone) - return { _tag: 'gone', status: 410, robots: 'noindex,follow', canonicalPath: input.registryPath } + return { _tag: 'gone', status: 410, retryAfterSeconds: null, robots: 'noindex,follow', canonicalPath: input.registryPath } if (input.duplicateCanonicalPath) - return { _tag: 'duplicate', status: null, robots: 'noindex,follow', canonicalPath: input.duplicateCanonicalPath } + return { _tag: 'duplicate', status: null, retryAfterSeconds: null, robots: 'noindex,follow', canonicalPath: input.duplicateCanonicalPath } return input.indexable - ? { _tag: 'indexable', status: null, robots: 'index,follow', canonicalPath: input.registryPath } - : { _tag: 'noindex', status: null, robots: 'noindex,follow', canonicalPath: input.registryPath } + ? { _tag: 'indexable', status: null, retryAfterSeconds: null, robots: 'index,follow', canonicalPath: input.registryPath } + : { _tag: 'noindex', status: null, retryAfterSeconds: null, robots: 'noindex,follow', canonicalPath: input.registryPath } } } } diff --git a/test/unit/skill-page-state.test.ts b/test/unit/skill-page-state.test.ts index 9e7f17bc..a12d5185 100644 --- a/test/unit/skill-page-state.test.ts +++ b/test/unit/skill-page-state.test.ts @@ -8,6 +8,7 @@ describe('skill page state', () => { expect(resolveSkillPageState({ _tag: 'missing' })).toEqual({ _tag: 'missing', status: 404, + retryAfterSeconds: null, robots: 'noindex,follow', canonicalPath: null, }) @@ -20,7 +21,7 @@ describe('skill page state', () => { sourceGone: true, registryPath: path, duplicateCanonicalPath: null, - })).toEqual({ _tag: 'gone', status: 410, robots: 'noindex,follow', canonicalPath: path }) + })).toEqual({ _tag: 'gone', status: 410, retryAfterSeconds: null, robots: 'noindex,follow', canonicalPath: path }) }) it('indexes a curated Skill under its own URL', () => { @@ -30,7 +31,7 @@ describe('skill page state', () => { sourceGone: false, registryPath: path, duplicateCanonicalPath: null, - })).toEqual({ _tag: 'indexable', status: null, robots: 'index,follow', canonicalPath: path }) + })).toEqual({ _tag: 'indexable', status: null, retryAfterSeconds: null, robots: 'index,follow', canonicalPath: path }) }) it('keeps a registered but non-indexable Skill noindex with a self canonical', () => { @@ -40,7 +41,7 @@ describe('skill page state', () => { sourceGone: false, registryPath: path, duplicateCanonicalPath: null, - })).toEqual({ _tag: 'noindex', status: null, robots: 'noindex,follow', canonicalPath: path }) + })).toEqual({ _tag: 'noindex', status: null, retryAfterSeconds: null, robots: 'noindex,follow', canonicalPath: path }) }) it('points a weaker duplicate at its canonical Skill', () => { @@ -53,17 +54,38 @@ describe('skill page state', () => { })).toEqual({ _tag: 'duplicate', status: null, + retryAfterSeconds: null, robots: 'noindex,follow', canonicalPath: '/gh/other/tools/lint', }) }) + it('answers 503 with Retry-After and no noindex when the Skill API fails', () => { + expect(resolveSkillPageState({ _tag: 'failed' })).toEqual({ + _tag: 'failed', + status: 503, + retryAfterSeconds: 300, + robots: null, + canonicalPath: null, + }) + }) + it('never canonicalises to the homepage while loading or after a failure', () => { - for (const input of [{ _tag: 'loading' }, { _tag: 'failed' }] as const) { + for (const input of [{ _tag: 'loading' }, { _tag: 'failed' }] as const) + expect(resolveSkillPageState(input).canonicalPath).toBeNull() + }) + + it('sends Retry-After only with a 503', () => { + const inputs = [ + { _tag: 'loading' }, + { _tag: 'missing' }, + { _tag: 'failed' }, + { _tag: 'loaded', indexable: true, sourceGone: true, registryPath: path, duplicateCanonicalPath: null }, + { _tag: 'loaded', indexable: true, sourceGone: false, registryPath: path, duplicateCanonicalPath: null }, + ] as const + for (const input of inputs) { const state = resolveSkillPageState(input) - expect(state.canonicalPath).toBeNull() - expect(state.robots).toBe('noindex,follow') - expect(state.status).toBeNull() + expect(state.retryAfterSeconds !== null).toBe(state.status === 503) } }) }) From c52a0d33957b196aa1b153ba3999a49202a1c60f Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 30 Sep 2026 20:35:30 +1000 Subject: [PATCH 9/9] fix(seo): swap two noindexed Skills in experiment C Neither Skill sat on a trending board after #322, so both went noindex,follow and no longer matched their indexable partners. --- .../app/utils/skill-unique-value-experiment.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/layers/registry/app/utils/skill-unique-value-experiment.ts b/layers/registry/app/utils/skill-unique-value-experiment.ts index 51d6e7ae..931a5f14 100644 --- a/layers/registry/app/utils/skill-unique-value-experiment.ts +++ b/layers/registry/app/utils/skill-unique-value-experiment.ts @@ -29,6 +29,12 @@ * `SkillDetail.vue`. If the treatment wins, roll the template out by * removing the gate; if not, remove the template. */ +/* + * 2026-09-30: swapped `onmax/nuxt-skills/arkenv` (treatment) for `ferdinandobons/startup-skill/startup-pitch`, + * and `pbakaus/agent-reviews/resolve-agent-reviews` (control) for `browser-use/plugins/browser-use`. + * The trending-only rule (#322) left both originals off every board, so they went + * `noindex,follow` and no longer matched their indexable partners. + */ export const UNIQUE_VALUE_EXPERIMENT = { startedOn: '2026-09-30', endsOn: '2026-11-11', @@ -41,7 +47,7 @@ export const UNIQUE_VALUE_EXPERIMENT = { 'ibelick/ui-skills/create-design-md', 'remotion-dev/skills/remotion-best-practices', 'dimillian/skills/bug-hunt-swarm', - 'onmax/nuxt-skills/arkenv', + 'ferdinandobons/startup-skill/startup-pitch', 'neondatabase/agent-skills/neon-postgres', 'clerk/skills/clerk-orgs', ], @@ -55,7 +61,7 @@ export const UNIQUE_VALUE_EXPERIMENT = { 'antfu/skills/nitro', 'addyosmani/web-quality-skills/accessibility', 'getsentry/skills/prompt-optimizer', - 'pbakaus/agent-reviews/resolve-agent-reviews', + 'browser-use/plugins/browser-use', 'prisma/skills/prisma-database-setup', ], } as const