From d8e7ac4fcaa822f5dd5445ed9f3a459114d72b99 Mon Sep 17 00:00:00 2001 From: Harlan GitHub Agent Date: Tue, 20 Oct 2026 02:09:16 +1100 Subject: [PATCH] fix: claim Skill file routes before Markdown negotiation --- nuxt.config.ts | 2 -- server/handlers/skill-file-page.ts | 1 + server/plugins/skill-file-page.ts | 7 +++++ test/unit/skill-file-page.test.ts | 49 ++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 server/plugins/skill-file-page.ts create mode 100644 test/unit/skill-file-page.test.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index ada75e35..d1fad7c4 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -492,8 +492,6 @@ 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' }, ], diff --git a/server/handlers/skill-file-page.ts b/server/handlers/skill-file-page.ts index cc2bef6b..04478c20 100644 --- a/server/handlers/skill-file-page.ts +++ b/server/handlers/skill-file-page.ts @@ -11,6 +11,7 @@ import { isSkillFilePagePath } from '#shared/skill-asset-path' * * Content negotiation skips the same requests. The file is already the * content, so no Markdown twin exists to redirect to. + * The request plugin calls this before the module caches its routing decision. */ export default defineEventHandler((event) => { if (isSkillFilePagePath(event.path)) diff --git a/server/plugins/skill-file-page.ts b/server/plugins/skill-file-page.ts new file mode 100644 index 00000000..84e6a500 --- /dev/null +++ b/server/plugins/skill-file-page.ts @@ -0,0 +1,7 @@ +import claimSkillFilePage from '../handlers/skill-file-page' + +// nuxt-ai-ready prepends h3 middleware that caches the request's Markdown +// decision. Nitro's request hook runs before that middleware can claim a file. +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', claimSkillFilePage) +}) diff --git a/test/unit/skill-file-page.test.ts b/test/unit/skill-file-page.test.ts new file mode 100644 index 00000000..ad7d749d --- /dev/null +++ b/test/unit/skill-file-page.test.ts @@ -0,0 +1,49 @@ +import type { H3Event } from 'h3' +import { createApp, defineEventHandler, getRequestHeader, toWebHandler } from 'h3' +import { describe, expect, it, vi } from 'vitest' +import registerPlugin from '../../server/plugins/skill-file-page' + +vi.hoisted(() => { + vi.stubGlobal('defineEventHandler', (handler: unknown) => handler) + vi.stubGlobal('defineNitroPlugin', (plugin: unknown) => plugin) +}) + +async function request(path: string) { + let onRequest: (event: H3Event) => void = () => {} + const app = createApp({ onRequest: event => onRequest(event) }) + const nitro = { + hooks: { + hook(_name: string, callback: typeof onRequest) { + onRequest = callback + }, + }, + } + registerPlugin(nitro as unknown as Parameters[0]) + // A module can prepend its own h3 middleware. It must see the guard before + // it decides whether a .md URL names a file or a page's Markdown twin. + app.use(defineEventHandler(event => ({ + path: event.path, + internal: getRequestHeader(event, 'x-ai-ready-internal') ?? null, + }))) + return toWebHandler(app)(new Request(`http://localhost${path}`)) +} + +describe('skill file request ownership', () => { + it.each([ + '/gh/huggingface/skills/huggingface-lora-space-builder/-/references/adapting-to-the-lora.md', + '/gh/anthropics/skills/pdf/-/reference.md?section=setup', + '/gh/anthropics/skills/pdf/-/scripts/check_bounding_boxes.py', + ])('claims %s before h3 middleware runs', async (path) => { + const response = await request(path) + expect(await response.json()).toEqual({ path, internal: '1' }) + }) + + it.each([ + '/gh/anthropics/skills/pdf.md', + '/gh/anthropics/skills/pdf', + '/index.md', + ])('leaves the page representation for %s to the module', async (path) => { + const response = await request(path) + expect(await response.json()).toEqual({ path, internal: null }) + }) +})