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
11 changes: 8 additions & 3 deletions layers/registry/app/components/SkillFileTreeNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,14 @@ function onInlineClick(event: MouseEvent) {
</ul>
</template>
<template v-else>
<NuxtLink
<!--
A plain anchor, so a click swaps the document in place. A router link
would first navigate to the file view page. The href still opens that
page in a new tab.
-->
<a
v-if="node.asset?.type === 'markdown'"
:to="node.path === 'SKILL.md' ? registryPath : `${repoSkillPath(owner, repo, name)}/-/${node.path}`"
:href="node.path === 'SKILL.md' ? registryPath : `${repoSkillPath(owner, repo, name)}/-/${node.path}`"
rel="nofollow"
class="tree-row file"
:class="{ active: isActive }"
Expand All @@ -117,7 +122,7 @@ function onInlineClick(event: MouseEvent) {
aria-hidden="true"
/>
<span class="tree-label">{{ node.name }}</span>
</NuxtLink>
</a>
<button
v-else-if="inlineRenderable"
type="button"
Expand Down
2 changes: 2 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ export default defineNuxtConfig({
// Registered here, not scanned from `server/middleware`, because nuxt-ai-ready
// claims every `.md` path and scanned middleware runs after a module's.
handlers: [
// First, so content negotiation also leaves Skill file URLs alone.
{ middleware: true, handler: '~~/server/handlers/skill-file-page.ts' },
{ middleware: true, handler: '~~/server/handlers/skill-md-probe.ts' },
{ middleware: true, handler: '~~/server/handlers/content-negotiation.ts' },
],
Expand Down
18 changes: 18 additions & 0 deletions server/handlers/skill-file-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { AI_READY_INTERNAL_HEADER } from '#shared/content-negotiation'
import { isSkillFilePagePath } from '#shared/skill-asset-path'

/**
* Send every Skill file URL to the file view page.
*
* nuxt-ai-ready answers any `.md` URL with the page at the same path minus
* `.md`, converted to Markdown. A Skill file URL keeps the file's own name, so
* `/-/reference.md` came back as the Skill page in Markdown. The module offers
* no per-route opt-out; it skips only requests that carry its internal header.
*
* Content negotiation skips the same requests. The file is already the
* content, so no Markdown twin exists to redirect to.
*/
export default defineEventHandler((event) => {
if (isSkillFilePagePath(event.path))
event.node.req.headers[AI_READY_INTERNAL_HEADER] = '1'
})
11 changes: 11 additions & 0 deletions shared/skill-asset-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,14 @@ export function normalizeSkillAssetFilePath(input: SkillAssetPathInput): string
)
return (isDuplicated ? parts.slice(duplicatedPrefix.length) : parts).join('/')
}

const SKILL_FILE_PAGE_RE = /^\/gh\/[^/?#]+\/[^/?#]+\/[^/?#]+\/-\/[^/?#]/

/**
* Whether a request path opens one file inside a Skill:
* `/gh/<owner>/<repo>/<name>/-/<file>`. The URL ends in the file's own name,
* so `/-/reference.md` is reference.md itself, never a page's Markdown twin.
*/
export function isSkillFilePagePath(path: string): boolean {
return SKILL_FILE_PAGE_RE.test(path)
}
23 changes: 23 additions & 0 deletions test/e2e/skill-file-deep-link.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { expect, test } from './test-utils'

const BROWSER_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'
const TITLE_RE = /<title>([^<]*)<\/title>/

// The file view titles itself from the route, so these hold whatever the local D1 holds.
test.describe('Skill file deep link', () => {
for (const { file, title } of [
{ file: 'scripts/check_bounding_boxes.py', title: 'check_bounding_boxes.py Β· pdf' },
{ file: 'LICENSE.txt', title: 'LICENSE.txt Β· pdf' },
// nuxt-ai-ready claims every `.md` URL as a page's Markdown twin.
{ file: 'reference.md', title: 'reference.md Β· pdf' },
]) {
test(`opens the file view for /-/${file}`, async ({ request }) => {
const response = await request.get(`/gh/anthropics/skills/pdf/-/${file}`, {
headers: { 'accept': BROWSER_ACCEPT, 'sec-fetch-dest': 'document' },
})

expect(response.headers()['content-type']).toContain('text/html')
expect((await response.text()).match(TITLE_RE)?.[1]).toContain(title)
})
}
})
96 changes: 96 additions & 0 deletions test/unit/page-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Component } from 'vue'
import type { Router, RouteRecordRaw } from 'vue-router'
import { relative } from 'node:path'
import { glob } from 'tinyglobby'
import { buildTree, toVueRouter4 } from 'unrouting'
import { defineComponent } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'

// Nuxt merges every layer's pages into one router with unrouting. This builds
// the same tree, with a stub component named after each page file.
const PAGE_ROOTS = [
'app/pages',
'layers/admin/app/pages',
'layers/identity/app/pages',
'layers/marketing/app/pages',
'layers/registry/app/pages',
].map(root => `${process.cwd()}/${root}`)

interface PageRoute {
/** Page file relative to its pages directory. */
page: string
/** Absolute route path, with parent paths joined in. */
path: string
}

async function pageRouter(): Promise<{ router: Router, pages: PageRoute[] }> {
const files = await glob(PAGE_ROOTS.map(root => `${root}/**/*.vue`), { absolute: true })
const routes = toVueRouter4(buildTree(files, { roots: PAGE_ROOTS }))
const pages: PageRoute[] = []

function toRecord(route: (typeof routes)[number], parentPath: string): RouteRecordRaw {
const path = route.path.startsWith('/') ? route.path : `${parentPath.replace(/\/$/, '')}/${route.path}`
const page = pageName(route.file)
pages.push({ page, path })
return {
path: route.path,
component: defineComponent({ name: page }) as Component,
children: route.children.map(child => toRecord(child, path)),
}
}

const records = routes.map(route => toRecord(route, ''))
return { router: createRouter({ history: createMemoryHistory(), routes: records }), pages }
}

function pageName(file: string | undefined): string {
const root = PAGE_ROOTS.find(root => file?.startsWith(`${root}/`))
return root && file ? relative(root, file) : String(file)
}

/**
* The page a visitor sees at a URL. The root `<NuxtPage>` renders the first
* matched record, and a nested page shows only if its parent renders its own
* `<NuxtPage>`.
*/
function renderedPage(router: Router, url: string): string | undefined {
return (router.resolve(url).matched[0]?.components?.default as { name?: string } | undefined)?.name
}

/** A concrete URL for a route path: every param filled, catch-alls two deep. */
function sampleUrl(path: string): string {
return path.replace(/:(\w+)(\([^)]*\))?([?*+])?/g, (_, name: string, _pattern: string | undefined, modifier: string | undefined) =>
modifier === '*' || modifier === '+' ? `sample-${name}/nested.txt` : `sample-${name}`)
}

describe('page routing', () => {
it.each([
'/gh/anthropics/skills/pdf/-/scripts/check_bounding_boxes.py',
'/gh/anthropics/skills/pdf/-/LICENSE.txt',
'/gh/anthropics/skills/pdf/-/reference.md',
])('renders the file view for the Skill file deep link %s', async (url) => {
const { router } = await pageRouter()

expect(renderedPage(router, url)).toBe('gh/[owner]/[repo]/[name]/-/[...file].vue')
})

it('renders the Skill page at the Skill URL', async () => {
const { router } = await pageRouter()

expect(renderedPage(router, '/gh/anthropics/skills/pdf')).toBe('gh/[owner]/[repo]/[name]/index.vue')
})

// A page file with a sibling folder of the same name becomes the parent
// route of every page in that folder. Without its own `<NuxtPage>`, direct
// links to those pages render the parent instead. It happened to `/me` and
// then to Skill file links.
it('renders every page file at its own URL', async () => {
const { router, pages } = await pageRouter()

const misrouted = pages
.map(({ page, path }) => ({ page, url: sampleUrl(path), rendered: renderedPage(router, sampleUrl(path)) }))
.filter(({ page, rendered }) => rendered !== page)

expect(misrouted).toEqual([])
})
})
23 changes: 22 additions & 1 deletion test/unit/skill-asset-path.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { normalizeSkillAssetFilePath } from '../../shared/skill-asset-path'
import { isSkillFilePagePath, normalizeSkillAssetFilePath } from '../../shared/skill-asset-path'

describe('skill asset path normalization', () => {
it('collapses a stale relative registry link duplicated into the asset path', () => {
Expand Down Expand Up @@ -42,3 +42,24 @@ describe('skill asset path normalization', () => {
})).toBe('gh/acme/skills/review/checklist.md')
})
})

describe('isSkillFilePagePath', () => {
it.each([
'/gh/anthropics/skills/pdf/-/reference.md',
'/gh/anthropics/skills/pdf/-/scripts/check_bounding_boxes.py',
'/gh/anthropics/skills/pdf/-/LICENSE.txt?ref=main',
])('claims the Skill file URL %s', (path) => {
expect(isSkillFilePagePath(path)).toBe(true)
})

it.each([
// The Skill page and its Markdown twin stay with nuxt-ai-ready.
'/gh/anthropics/skills/pdf',
'/gh/anthropics/skills/pdf.md',
'/gh/anthropics/skills/pdf/-/',
'/gh/anthropics/skills.md',
'/api/skill-asset/anthropics/skills/pdf/reference.md',
])('leaves %s alone', (path) => {
expect(isSkillFilePagePath(path)).toBe(false)
})
})
2 changes: 1 addition & 1 deletion test/unit/skill-detail-route-title.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ mockNuxtImport('useRoute', () => {
describe('skill detail route title', () => {
it('sets a useful title before the skill payload loads', async () => {
const wrapper = await mountSuspended(
await import('../../layers/registry/app/pages/gh/[owner]/[repo]/[name].vue').then(module => module.default),
await import('../../layers/registry/app/pages/gh/[owner]/[repo]/[name]/index.vue').then(module => module.default),
{
global: {
stubs: {
Expand Down