Skip to content
Open
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
26 changes: 21 additions & 5 deletions server/middleware/canonical-redirects.global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,26 +46,42 @@ export default defineEventHandler(async event => {
return
}

// /llms.txt at root is handled by the llms-txt middleware
if (path === '/llms.txt') {
return
}

// /@org/pkg or /pkg → /package/org/pkg or /package/pkg
let pkgMatch = path.match(/^\/(?:(?<org>@[^/]+)\/)?(?<name>[^/@]+)$/)
// Also handles trailing /llms.txt or /llms_full.txt suffixes
let pkgMatch = path.match(
/^\/(?:(?<org>@[^/]+)\/)?(?<name>[^/@]+)(?<suffix>\/(?:llms\.txt|llms_full\.txt))?$/,
)
if (pkgMatch?.groups) {
const args = [pkgMatch.groups.org, pkgMatch.groups.name].filter(Boolean).join('/')
const suffix = pkgMatch.groups.suffix ?? ''
setHeader(event, 'cache-control', cacheControl)
return sendRedirect(event, `/package/${args}` + (query ? '?' + query : ''), 301)
return sendRedirect(event, `/package/${args}${suffix}` + (query ? '?' + query : ''), 301)
}

// /@org/pkg/v/version or /@org/pkg@version → /package/org/pkg/v/version
// /pkg/v/version or /pkg@version → /package/pkg/v/version
// Also handles trailing /llms.txt or /llms_full.txt suffixes
const pkgVersionMatch =
path.match(/^\/(?:(?<org>@[^/]+)\/)?(?<name>[^/@]+)\/v\/(?<version>[^/]+)$/) ||
path.match(/^\/(?:(?<org>@[^/]+)\/)?(?<name>[^/@]+)@(?<version>[^/]+)$/)
path.match(
/^\/(?:(?<org>@[^/]+)\/)?(?<name>[^/@]+)\/v\/(?<version>[^/]+)(?<suffix>\/(?:llms\.txt|llms_full\.txt))?$/,
) ||
path.match(
/^\/(?:(?<org>@[^/]+)\/)?(?<name>[^/@]+)@(?<version>[^/]+)(?<suffix>\/(?:llms\.txt|llms_full\.txt))?$/,
)

if (pkgVersionMatch?.groups) {
const args = [pkgVersionMatch.groups.org, pkgVersionMatch.groups.name].filter(Boolean).join('/')
const versionSuffix = pkgVersionMatch.groups.suffix ?? ''
setHeader(event, 'cache-control', cacheControl)
return sendRedirect(
event,
`/package/${args}/v/${pkgVersionMatch.groups.version}` + (query ? '?' + query : ''),
`/package/${args}/v/${pkgVersionMatch.groups.version}${versionSuffix}` +
(query ? '?' + query : ''),
301,
)
}
Expand Down
105 changes: 105 additions & 0 deletions server/middleware/llms-txt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as v from 'valibot'
import { PackageRouteParamsSchema } from '#shared/schemas/package'
import { handleApiError } from '#server/utils/error-handler'
import { handleLlmsTxt, handleOrgLlmsTxt, generateRootLlmsTxt } from '#server/utils/llms-txt'

const CACHE_HEADER = 's-maxage=3600, stale-while-revalidate=86400'

/**
* Middleware to handle ALL llms.txt / llms_full.txt routes.
*
* All llms.txt handling lives here rather than in file-based routes because
* Vercel's ISR route rules with glob patterns (e.g. `/package/ ** /llms.txt`)
* create catch-all serverless functions that interfere with Nitro's file-based
* route resolution — scoped packages and versioned paths fail to match.
*
* Handles:
* - /llms.txt (root discovery page)
* - /package/@:org/llms.txt (org package listing)
* - /package/:name/llms.txt (unscoped, latest)
* - /package/:name/llms_full.txt (unscoped, latest, full)
* - /package/@:org/:name/llms.txt (scoped, latest)
* - /package/@:org/:name/llms_full.txt (scoped, latest, full)
* - /package/:name/v/:version/llms.txt (unscoped, versioned)
* - /package/:name/v/:version/llms_full.txt (unscoped, versioned, full)
* - /package/@:org/:name/v/:version/llms.txt (scoped, versioned)
* - /package/@:org/:name/v/:version/llms_full.txt (scoped, versioned, full)
*/
export default defineEventHandler(async event => {
const path = event.path.split('?')[0] ?? '/'

if (!path.endsWith('/llms.txt') && !path.endsWith('/llms_full.txt')) return

const full = path.endsWith('/llms_full.txt')
const suffix = full ? '/llms_full.txt' : '/llms.txt'

// Root /llms.txt
if (path === '/llms.txt') {
const url = getRequestURL(event)
const baseUrl = `${url.protocol}//${url.host}`
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
setHeader(event, 'Cache-Control', CACHE_HEADER)
return generateRootLlmsTxt(baseUrl)
}

if (!path.startsWith('/package/')) return

// Strip /package/ prefix and /llms[_full].txt suffix
const inner = path.slice('/package/'.length, -suffix.length)

// Org-level: /package/@org/llms.txt (inner = "@org")
if (!full && inner.startsWith('@') && !inner.includes('/')) {
const orgName = inner.slice(1)
try {
const url = getRequestURL(event)
const baseUrl = `${url.protocol}//${url.host}`
const content = await handleOrgLlmsTxt(orgName, baseUrl)
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
setHeader(event, 'Cache-Control', CACHE_HEADER)
return content
} catch (error: unknown) {
handleApiError(error, { statusCode: 502, message: 'Failed to generate org llms.txt.' })
}
}

// Parse package name and optional version from inner path
let rawPackageName: string
let rawVersion: string | undefined

if (inner.includes('/v/')) {
// Versioned path
if (inner.startsWith('@')) {
const match = inner.match(/^(@[^/]+\/[^/]+)\/v\/(.+)$/)
if (!match?.[1] || !match[2]) return
rawPackageName = match[1]
rawVersion = match[2]
} else {
const match = inner.match(/^([^/]+)\/v\/(.+)$/)
if (!match?.[1] || !match[2]) return
rawPackageName = match[1]
rawVersion = match[2]
}
} else {
// Latest version — inner is just the package name
rawPackageName = inner
}

if (!rawPackageName) return

try {
const { packageName, version } = v.parse(PackageRouteParamsSchema, {
packageName: rawPackageName,
version: rawVersion,
})

const content = await handleLlmsTxt(packageName, version, { includeAgentFiles: full })
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8')
setHeader(event, 'Cache-Control', CACHE_HEADER)
return content
} catch (error: unknown) {
handleApiError(error, {
statusCode: 502,
message: `Failed to generate ${full ? 'llms_full.txt' : 'llms.txt'}.`,
})
}
})
Loading
Loading