From 121cf958768fc13b616aa20aea292a8d8890f53d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 30 Sep 2026 14:17:56 +1000 Subject: [PATCH 1/9] fix(registry): return 404 for missing skill pages Any made-up /gh/// URL answered 200 with a thin page and a canonical to the homepage. Google filed 2,621 of them as soft 404s, and the open URL space wastes a small crawl budget. A missing Skill now answers 404 with noindex and no canonical. A page with no Skill never canonicalises to the homepage. --- .../registry/app/components/SkillDetail.vue | 36 +++++++--- layers/registry/app/utils/skill-page-state.ts | 56 +++++++++++++++ test/e2e/missing-skill-status.spec.ts | 12 ++++ test/unit/skill-page-state.test.ts | 69 +++++++++++++++++++ 4 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 layers/registry/app/utils/skill-page-state.ts create mode 100644 test/e2e/missing-skill-status.spec.ts create mode 100644 test/unit/skill-page-state.test.ts diff --git a/layers/registry/app/components/SkillDetail.vue b/layers/registry/app/components/SkillDetail.vue index 9feb94e3..d69336bc 100644 --- a/layers/registry/app/components/SkillDetail.vue +++ b/layers/registry/app/components/SkillDetail.vue @@ -5,6 +5,7 @@ import { resolveSkillAuditOverview } from '~~/app/utils/skill-audit-overview' import { avatarProxyUrl, githubAvatarProxyUrl } from '#shared/image-proxy' import { skillPageUrl as exactSkillPageUrl, skillInstallCmd, skillRunCmd, skillRunPrompt } from '#shared/skill-commands' 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 SkillCommandPanel from './_SkillCommandPanel.vue' @@ -790,10 +791,28 @@ const canonicalSkillPagePath = computed(() => { const canonical = duplicateGroup.value?.canonical return canonical?.registryPath ?? skillPagePath.value }) -const canonicalSkillPageUrl = computed(() => { - return `${siteOrigin}${canonicalSkillPagePath.value}` + +const pageState = computed(() => { + const skill = data.value + if (isMissingSkill.value) + return resolveSkillPageState({ _tag: 'missing' }) + if (!skill) + return resolveSkillPageState({ _tag: error.value ? 'failed' : 'loading' }) + return resolveSkillPageState({ + _tag: 'loaded', + indexable: skill.seo.indexable, + sourceGone: skill.sourceGone, + registryPath: skill.registryPath, + duplicateCanonicalPath: isWeakerDuplicate.value ? canonicalSkillPagePath.value : null, + }) }) +// 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) + // 2026-08-22: the shared "A Claude Code skill for Cursor, Codex, and other // agents." suffix is gone. Identical boilerplate across 1,300+ meta // descriptions was the last scaled-content signature in Google snippets @@ -884,9 +903,7 @@ const skillDescription = computed(() => { useSeoMeta({ title: () => skillTitle.value, description: () => skillDescription.value, - robots: () => { - return data.value?.seo.indexable && !isWeakerDuplicate.value ? 'index,follow' : 'noindex,follow' - }, + robots: () => pageState.value.robots, ogTitle: () => skillTitle.value, ogDescription: () => skillDescription.value, twitterTitle: () => skillTitle.value, @@ -894,12 +911,9 @@ useSeoMeta({ }) useHead(computed(() => ({ - link: [ - { - rel: 'canonical', - href: canonicalSkillPageUrl.value, - }, - ], + link: pageState.value.canonicalPath + ? [{ rel: 'canonical', href: `${siteOrigin}${pageState.value.canonicalPath}` }] + : [], }))) diff --git a/layers/registry/app/utils/skill-page-state.ts b/layers/registry/app/utils/skill-page-state.ts new file mode 100644 index 00000000..e1c290a5 --- /dev/null +++ b/layers/registry/app/utils/skill-page-state.ts @@ -0,0 +1,56 @@ +/** + * What a `/gh///` page knows about its Skill, as data. + * + * `missing` is a 404 from the Skill API: the registry holds no row. A Skill + * that is real on GitHub but outside the registry lands here too, because the + * skill route never proxies GitHub. The repository hub is the proxied surface. + */ +export type SkillPageInput + = | { _tag: 'loading' } + | { _tag: 'failed' } + | { _tag: 'missing' } + | { + _tag: 'loaded' + indexable: boolean + /** The SKILL.md was deleted upstream; the site keeps a tombstone. */ + sourceGone: boolean + registryPath: string + /** Set when a stronger duplicate owns this content. */ + duplicateCanonicalPath: string | null + } + +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' + /** Site path for the canonical link, or null to omit the link. */ + canonicalPath: string | null +} + +/** + * Decide the status, robots directive, and canonical for a Skill page. + * + * A canonical must name a page that exists. The old code fell back to the + * 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. + */ +export function resolveSkillPageState(input: SkillPageInput): SkillPageState { + switch (input._tag) { + case 'missing': + return { _tag: 'missing', status: 404, robots: 'noindex,follow', canonicalPath: null } + case 'loading': + case 'failed': + return { _tag: input._tag, status: null, robots: 'noindex,follow', canonicalPath: null } + case 'loaded': { + if (input.sourceGone) + return { _tag: 'gone', status: 410, robots: 'noindex,follow', canonicalPath: input.registryPath } + if (input.duplicateCanonicalPath) + return { _tag: 'duplicate', status: 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 } + } + } +} diff --git a/test/e2e/missing-skill-status.spec.ts b/test/e2e/missing-skill-status.spec.ts new file mode 100644 index 00000000..298c0ee7 --- /dev/null +++ b/test/e2e/missing-skill-status.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from './test-utils' + +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`) + expect(res.status()).toBe(404) + const html = await res.text() + expect(html).toContain('noindex,follow') + expect(html).not.toMatch(/ { + it('answers 404 with no canonical for a Skill the registry lacks', () => { + expect(resolveSkillPageState({ _tag: 'missing' })).toEqual({ + _tag: 'missing', + status: 404, + robots: 'noindex,follow', + canonicalPath: null, + }) + }) + + it('answers 410 and self-canonicalises a Skill removed upstream', () => { + expect(resolveSkillPageState({ + _tag: 'loaded', + indexable: true, + sourceGone: true, + registryPath: path, + duplicateCanonicalPath: null, + })).toEqual({ _tag: 'gone', status: 410, robots: 'noindex,follow', canonicalPath: path }) + }) + + it('indexes a curated Skill under its own URL', () => { + expect(resolveSkillPageState({ + _tag: 'loaded', + indexable: true, + sourceGone: false, + registryPath: path, + duplicateCanonicalPath: null, + })).toEqual({ _tag: 'indexable', status: null, robots: 'index,follow', canonicalPath: path }) + }) + + it('keeps a registered but non-indexable Skill noindex with a self canonical', () => { + expect(resolveSkillPageState({ + _tag: 'loaded', + indexable: false, + sourceGone: false, + registryPath: path, + duplicateCanonicalPath: null, + })).toEqual({ _tag: 'noindex', status: null, robots: 'noindex,follow', canonicalPath: path }) + }) + + it('points a weaker duplicate at its canonical Skill', () => { + expect(resolveSkillPageState({ + _tag: 'loaded', + indexable: true, + sourceGone: false, + registryPath: path, + duplicateCanonicalPath: '/gh/other/tools/lint', + })).toEqual({ + _tag: 'duplicate', + status: null, + robots: 'noindex,follow', + canonicalPath: '/gh/other/tools/lint', + }) + }) + + it('never canonicalises to the homepage while loading or after a failure', () => { + for (const input of [{ _tag: 'loading' }, { _tag: 'failed' }] as const) { + const state = resolveSkillPageState(input) + expect(state.canonicalPath).toBeNull() + expect(state.robots).toBe('noindex,follow') + expect(state.status).toBeNull() + } + }) +}) From 7a6d0333696c35ad88eb7f8bd68c679aa42f718e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 30 Sep 2026 14:32:05 +1000 Subject: [PATCH 2/9] feat(seo): add the unique value experiment gate and data shaping Experiment C tests whether Skill pages that carry data GitHub lacks get crawled sooner. One constant holds both groups and the cull path. --- .../utils/skill-unique-value-experiment.ts | 65 ++++++++++ .../registry/app/utils/skill-unique-value.ts | 119 +++++++++++++++++ .../skill-unique-value-experiment.test.ts | 33 +++++ test/unit/skill-unique-value.test.ts | 120 ++++++++++++++++++ 4 files changed, 337 insertions(+) create mode 100644 layers/registry/app/utils/skill-unique-value-experiment.ts create mode 100644 layers/registry/app/utils/skill-unique-value.ts create mode 100644 test/unit/skill-unique-value-experiment.test.ts create mode 100644 test/unit/skill-unique-value.test.ts diff --git a/layers/registry/app/utils/skill-unique-value-experiment.ts b/layers/registry/app/utils/skill-unique-value-experiment.ts new file mode 100644 index 00000000..a18c0461 --- /dev/null +++ b/layers/registry/app/utils/skill-unique-value-experiment.ts @@ -0,0 +1,65 @@ +/** + * SEO experiment C, "unique value": the gate and the two groups. Delete this + * file after the decision. Nothing else decides which Skill page is treated. + * + * Started 2026-09-30. Read the result at the Search Console gate on 2026-11-11. + * Google holds the curated Skill URLs as "Discovered, currently not indexed", + * and about 72% of a Skill page's text is the GitHub SKILL.md word for word. + * 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 + * 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. + * + * 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. + * + * Cull path: after the decision, delete this file, `skill-unique-value.ts`, + * `_SkillUniqueValue.vue`, and the `isUniqueValueTreatment` branches in + * `SkillDetail.vue`. If the treatment wins, roll the template out by + * removing the gate; if not, remove the template. + */ +export const UNIQUE_VALUE_EXPERIMENT = { + startedOn: '2026-09-30', + endsOn: '2026-11-11', + /** Treatment: `owner/repo/name`. */ + treatment: [ + 'emilkowalski/skills/emil-design-eng', + 'vercel-labs/agent-skills/react-best-practices', + 'microsoft/playwright-cli/playwright-cli', + 'getsentry/skills/prompt-optimizer', + '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: [ + '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', + ], +} as const + +const TREATMENT = new Set(UNIQUE_VALUE_EXPERIMENT.treatment.map(slug => slug.toLowerCase())) + +/** True when the Skill page `owner/repo/name` gets the experiment template. */ +export function isUniqueValueTreatment(slug: string): boolean { + return TREATMENT.has(slug.toLowerCase()) +} diff --git a/layers/registry/app/utils/skill-unique-value.ts b/layers/registry/app/utils/skill-unique-value.ts new file mode 100644 index 00000000..b2337a49 --- /dev/null +++ b/layers/registry/app/utils/skill-unique-value.ts @@ -0,0 +1,119 @@ +import { skillInstallCmd, skillRunCmd } from '#shared/skill-commands' + +/** + * Data shaping for the experiment C template. Pure functions of data the Skill + * API already returns. Delete with `skill-unique-value-experiment.ts`. + */ + +export interface SkillFileEntry { + path: string + size: number + type: 'markdown' | 'code' | 'image' | 'data' | 'other' +} + +export type SkillFileFacts + = | { _tag: 'only-skill-md' } + | { + _tag: 'has-files' + /** Every file beside SKILL.md, as the sync counted them. */ + total: number + markdown: number + code: number + other: number + codePaths: string[] + /** False when the API listed fewer files than the Skill holds. */ + complete: boolean + } + +export function resolveSkillFileFacts(input: { assets: readonly SkillFileEntry[], total: number }): SkillFileFacts { + if (input.total === 0 && input.assets.length === 0) + return { _tag: 'only-skill-md' } + const codePaths = input.assets.filter(a => a.type === 'code').map(a => a.path) + return { + _tag: 'has-files', + total: Math.max(input.total, input.assets.length), + markdown: input.assets.filter(a => a.type === 'markdown').length, + code: codePaths.length, + other: input.assets.filter(a => a.type === 'other').length, + codePaths, + complete: input.assets.length >= input.total, + } +} + +export type SkillCommand + = | { _tag: 'run', command: string } + | { _tag: 'install', command: string, reason: 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`. + */ +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) } +} + +const HEADING = /])/g +const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'col', 'wbr']) + +function isBalanced(html: string): boolean { + const stack: string[] = [] + for (const match of html.matchAll(/<(\/?)([a-z][a-z0-9]*)\b[^>]*?(\/?)>/gi)) { + const tag = match[2]!.toLowerCase() + if (VOID_TAGS.has(tag) || match[3] === '/') + continue + if (match[1] === '/') { + if (stack.pop() !== tag) + return false + } + else { + stack.push(tag) + } + } + return stack.length === 0 +} + +/** + * 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. + */ +export function excerptSkillHtml(html: string | null | undefined, maxChars = 1600): string | null { + 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 fits = candidates.find(c => c.length <= maxChars && isBalanced(c)) + return fits ?? null +} diff --git a/test/unit/skill-unique-value-experiment.test.ts b/test/unit/skill-unique-value-experiment.test.ts new file mode 100644 index 00000000..7d95f934 --- /dev/null +++ b/test/unit/skill-unique-value-experiment.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + isUniqueValueTreatment, + UNIQUE_VALUE_EXPERIMENT, +} from '../../layers/registry/app/utils/skill-unique-value-experiment' + +describe('unique value experiment gate', () => { + it('treats every listed treatment Skill', () => { + for (const slug of UNIQUE_VALUE_EXPERIMENT.treatment) + expect(isUniqueValueTreatment(slug)).toBe(true) + }) + + it('leaves every listed control Skill unchanged', () => { + for (const slug of UNIQUE_VALUE_EXPERIMENT.control) + expect(isUniqueValueTreatment(slug)).toBe(false) + }) + + it('leaves a Skill outside both lists unchanged', () => { + expect(isUniqueValueTreatment('someone/else/not-listed')).toBe(false) + }) + + it('ignores letter case, because GitHub logins ignore it', () => { + const [first] = UNIQUE_VALUE_EXPERIMENT.treatment + expect(isUniqueValueTreatment(first!.toUpperCase())).toBe(true) + }) + + it('keeps the two groups the same size and apart', () => { + const { treatment, control } = UNIQUE_VALUE_EXPERIMENT + expect(treatment).toHaveLength(10) + expect(control).toHaveLength(10) + expect(new Set([...treatment, ...control].map(s => s.toLowerCase())).size).toBe(20) + }) +}) diff --git a/test/unit/skill-unique-value.test.ts b/test/unit/skill-unique-value.test.ts new file mode 100644 index 00000000..663f3fc4 --- /dev/null +++ b/test/unit/skill-unique-value.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { + excerptSkillHtml, + resolveSkillCommand, + resolveSkillFileFacts, +} from '../../layers/registry/app/utils/skill-unique-value' + +const ref = { owner: 'acme', repo: 'skills', name: 'lint' } + +function asset(path: string, type: 'markdown' | 'code' | 'image' | 'data' | 'other') { + return { path, size: 10, type } +} + +describe('resolveSkillFileFacts', () => { + it('reports a Skill that is only SKILL.md', () => { + expect(resolveSkillFileFacts({ assets: [], total: 0 })).toEqual({ + _tag: 'only-skill-md', + }) + }) + + it('counts files by type and lists code files', () => { + const facts = resolveSkillFileFacts({ + assets: [ + asset('references/a.md', 'markdown'), + asset('references/b.md', 'markdown'), + asset('scripts/run.py', 'code'), + asset('data.json', 'data'), + ], + total: 4, + }) + expect(facts).toEqual({ + _tag: 'has-files', + total: 4, + markdown: 2, + code: 1, + other: 0, + codePaths: ['scripts/run.py'], + complete: true, + }) + }) + + it('marks a truncated list as incomplete', () => { + const facts = resolveSkillFileFacts({ assets: [asset('a.md', 'markdown')], total: 300 }) + expect(facts._tag === 'has-files' && facts.complete).toBe(false) + }) +}) + +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', + }) + }) + + 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') + }) +}) + +describe('excerptSkillHtml', () => { + it('keeps the first heading and its intro, and drops later sections', () => { + const html = '

Title

\n

Intro text.

\n

Step 1

\n

Later.

' + expect(excerptSkillHtml(html)).toBe('

Title

\n

Intro text.

') + }) + + it('keeps text that comes before the first heading', () => { + const html = '

Lead.

\n

First

\n

Body.

' + expect(excerptSkillHtml(html)).toBe('

Lead.

') + }) + + it('returns the whole body when there is no second heading and it is short', () => { + const html = '

Only

\n

Short.

' + expect(excerptSkillHtml(html)).toBe(html) + }) + + it('cuts a long section at a paragraph boundary', () => { + const para = `

${'word '.repeat(60).trim()}

` + const html = `

Title

\n${para}\n${para}\n${para}\n${para}` + const out = excerptSkillHtml(html, 700)! + expect(out.length).toBeLessThanOrEqual(700) + expect(out.endsWith('

')).toBe(true) + expect(out.startsWith('

Title

')).toBe(true) + }) + + it('never returns unbalanced tags', () => { + const html = '
\n

Inside

\n

Quote.

\n
\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 Wilton Date: 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(() => ({
-