diff --git a/layers/registry/app/pages/gh/[owner]/[repo]/index.vue b/layers/registry/app/pages/gh/[owner]/[repo]/index.vue index e3d986cb..4cdfe857 100644 --- a/layers/registry/app/pages/gh/[owner]/[repo]/index.vue +++ b/layers/registry/app/pages/gh/[owner]/[repo]/index.vue @@ -5,7 +5,7 @@ import type { RepoHistoryResponse } from '../../../../../server/api/repos/[owner import type { RepoRouteResolution } from '../../../../../server/api/repos/[owner]/[repo]/route-target.get' import { entityRobots } from '#shared/entity-robots' import { avatarProxyUrl } from '#shared/image-proxy' -import { resolveMissingRepoRedirect } from '../../../../utils/missing-repo-recovery' +import { repoPageProfile } from '../../../../utils/repo-profile' import { parseRepoSkillSort, REPO_SKILL_SORT_OPTIONS, sortRepoSkills } from '../../../../utils/repo-skill-layout' import RepoSkillDependencies from './_RepoSkillDependencies.vue' import RepoSparkline from './_RepoSparkline.vue' @@ -37,15 +37,17 @@ const repoRouteTarget = computed(() => { }) const fetchRepoDetailsOnServer = isBot.value && repoRouteTarget.value?._tag !== 'skill' -const repoProfileFetch = useFetch( +const repoProfileFetch = useFetch( () => `/api/orgs/${sourceHub.value.owner}`, { + key: () => `repo-page-profile:${repoKey.value}`, watch: false, lazy: !fetchRepoDetailsOnServer, immediate: fetchRepoDetailsOnServer, server: fetchRepoDetailsOnServer, + transform: (profile: OrgProfile) => repoPageProfile(profile, repoHub.value.repo), }, -) as ReturnType> +) const { data: repoProfile, status: repoProfileStatus, refresh: refreshRepo } = repoProfileFetch const repoSourceFetch = useFetch( @@ -76,14 +78,10 @@ if (fetchRepoDetailsOnServer) if (fetchRepoDetailsOnServer && repoSourceError.value?.statusCode === 404) { // Before the tombstone, check whether the repo segment is actually a skill // name. `/gh//` is what an inbound link looks like when the - // author drops the repository, and the owner profile above already carries - // every skill this owner publishes, so the recovery costs no extra request. - const recovery = resolveMissingRepoRedirect({ - owner: owner.value, - repo: repo.value, - skills: repoProfile.value?.skills ?? [], - }) - if (recovery._tag === 'redirect') + // author drops the repository. The transform decides recovery from the full + // Owner profile before it drops unrelated Skills from this page's payload. + const recovery = repoProfile.value?.missingRepoTarget + if (recovery?._tag === 'redirect') await navigateTo(recovery.location, { redirectCode: 301, replace: true }) else throw createError({ statusCode: 404, statusMessage: 'Repository not found', fatal: true }) diff --git a/layers/registry/app/pages/gh/[owner]/index.vue b/layers/registry/app/pages/gh/[owner]/index.vue index 39c77738..0c8b0e89 100644 --- a/layers/registry/app/pages/gh/[owner]/index.vue +++ b/layers/registry/app/pages/gh/[owner]/index.vue @@ -3,6 +3,7 @@ import type { OrgProfile } from '../../../../server/api/orgs/[owner].get' import { entityRobots } from '#shared/entity-robots' import { avatarProxyUrl } from '#shared/image-proxy' import { resolveOwnerProfileHandoff } from '../../../utils/owner-profile-handoff' +import { ownerPageProfile } from '../../../utils/repo-profile' const route = useRoute() const ownerParam = computed(() => (route.params.owner as string).toLowerCase()) @@ -10,12 +11,13 @@ const { state: auth, loginUrl } = useAuth() const { isBot } = useBotDetection() -const orgProfile = useFetch( +const orgProfile = useFetch( () => `/api/orgs/${ownerParam.value}`, { key: `org-${ownerParam.value}`, watch: [ownerParam], lazy: !isBot.value, + transform: (profile: OrgProfile) => ownerPageProfile(profile), }, ) const { data, status, error, refresh } = orgProfile @@ -100,7 +102,7 @@ function ensureProtocol(url: string): string { const skillsByRepo = computed(() => { if (!data.value) return new Map() - const map = new Map() + const map = new Map['skills']>() for (const skill of data.value.skills) { const list = map.get(skill.repo) ?? [] list.push(skill) diff --git a/layers/registry/app/utils/repo-profile.ts b/layers/registry/app/utils/repo-profile.ts index 6571c27b..fbe9fc55 100644 --- a/layers/registry/app/utils/repo-profile.ts +++ b/layers/registry/app/utils/repo-profile.ts @@ -1,14 +1,51 @@ import type { OrgProfile } from '../../server/api/orgs/[owner].get' +import { resolveMissingRepoRedirect } from './missing-repo-recovery' -type RepoSkill = OrgProfile['skills'][number] -type RepoInfo = OrgProfile['repos'][number] +type ProfileSkill = Pick + +function profileSkill(skill: OrgProfile['skills'][number]): ProfileSkill { + return { + owner: skill.owner, + repo: skill.repo, + name: skill.name, + registryPath: skill.registryPath, + slug: skill.slug, + displayName: skill.displayName, + description: skill.description, + stars: skill.stars, + likeCount: skill.likeCount, + modifiedAt: skill.modifiedAt, + pushedAt: skill.pushedAt, + firstSeenAt: skill.firstSeenAt, + authorName: skill.authorName, + skillFileUrl: skill.skillFileUrl, + dependencies: skill.dependencies, + } +} + +/** Keep card content; admission and source hashes do not render on these pages. */ +export function ownerPageProfile(profile: OrgProfile) { + return { ...profile, skills: profile.skills.map(profileSkill) } +} + +/** Decide missing-path recovery before dropping the other Repositories' Skills. */ +export function repoPageProfile(profile: OrgProfile, repo: string) { + return { + ...profile, + skills: selectRepoSkills(profile, repo).map(profileSkill), + repos: profile.repos.filter(candidate => candidate.repo.toLowerCase() === repo.toLowerCase()), + missingRepoTarget: resolveMissingRepoRedirect({ owner: profile.owner, repo, skills: profile.skills }), + } +} /** * `useFetch` asserts its response type, so a partial body reaches the * page as a truthy object whose arrays are missing (Sentry SKILLD-E). Read them * defensively here rather than at each call site. */ -export function selectRepoSkills(profile: OrgProfile | null | undefined, repo: string): RepoSkill[] { +export function selectRepoSkills(profile: { skills: T[] } | null | undefined, repo: string): T[] { const wanted = repo.toLowerCase() const skills = profile?.skills if (!Array.isArray(skills)) @@ -24,7 +61,7 @@ export function selectRepoSkills(profile: OrgProfile | null | undefined, repo: s }) } -export function selectRepoInfo(profile: OrgProfile | null | undefined, repo: string): RepoInfo | null { +export function selectRepoInfo(profile: { repos: T[] } | null | undefined, repo: string): T | null { const repos = profile?.repos if (!Array.isArray(repos)) return null diff --git a/test/unit/profile-payload.test.ts b/test/unit/profile-payload.test.ts new file mode 100644 index 00000000..627f9a05 --- /dev/null +++ b/test/unit/profile-payload.test.ts @@ -0,0 +1,36 @@ +import type { OrgProfile } from '../../layers/registry/server/api/orgs/[owner].get' +import { describe, expect, it } from 'vitest' +import { ownerPageProfile, repoPageProfile, selectRepoSkills } from '../../layers/registry/app/utils/repo-profile' + +const profile = { + owner: 'acme', + skills: [ + { owner: 'acme', repo: 'widgets', name: 'design', registryPath: '/gh/acme/widgets/design', slug: 'acme/widgets/design', displayName: 'Design', description: 'Design widgets', stars: 5, likeCount: 2, modifiedAt: 10, pushedAt: 8, firstSeenAt: 4, dependencies: ['testing'], renderedRawSha256: 'hash', trustScore: 100, seoIndexable: true }, + { owner: 'acme', repo: 'tools', name: 'testing', registryPath: '/gh/acme/tools/testing', slug: 'acme/tools/testing' }, + ], + repos: [{ repo: 'widgets', count: 1 }, { repo: 'tools', count: 1 }], +} as unknown as OrgProfile + +describe('page profile payloads', () => { + it('preserves card content and canonical links without serializing admission evidence', () => { + const result = ownerPageProfile(profile) + expect(result.skills[0]).toEqual({ owner: 'acme', repo: 'widgets', name: 'design', registryPath: '/gh/acme/widgets/design', slug: 'acme/widgets/design', displayName: 'Design', description: 'Design widgets', stars: 5, likeCount: 2, modifiedAt: 10, pushedAt: 8, firstSeenAt: 4, dependencies: ['testing'], authorName: undefined, skillFileUrl: undefined }) + expect(profile.skills[0]?.trustScore).toBe(100) + }) + + it('keeps only the requested Repository, including case-insensitive selection', () => { + const result = repoPageProfile(profile, 'WIDGETS') + expect(result.skills.map(skill => skill.registryPath)).toEqual(['/gh/acme/widgets/design']) + expect(result.repos).toEqual([{ repo: 'widgets', count: 1 }]) + expect(selectRepoSkills(result, 'widgets')[0]?.dependencies).toEqual(['testing']) + }) + + it('preserves recovery to an unambiguous Skill outside the requested Repository', () => { + expect(repoPageProfile(profile, 'testing').missingRepoTarget).toEqual({ _tag: 'redirect', location: '/gh/acme/tools/testing' }) + }) + + it('does not guess when two Repositories publish the missing name', () => { + const ambiguous = { ...profile, skills: [...profile.skills, { ...profile.skills[1]!, repo: 'other', registryPath: '/gh/acme/other/testing' }] } + expect(repoPageProfile(ambiguous, 'testing').missingRepoTarget).toEqual({ _tag: 'not_found' }) + }) +})