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
207 changes: 207 additions & 0 deletions packages/layer/modules/markdown-rewrite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { defineNuxtModule, logger } from '@nuxt/kit'

const log = logger.withTag('docs-please:markdown-rewrite')

type I18nLocale = string | { code: string }

interface VercelHeaderHas {
type: 'header'
key: string
value: string
}

interface VercelRoute {
src: string
dest?: string
headers?: Record<string, string>
has?: VercelHeaderHas[]
continue?: boolean
[key: string]: unknown
}

interface VercelBuildOutputConfig {
version: number
routes?: VercelRoute[]
[key: string]: unknown
}

const MARKDOWN_HEADERS: Record<string, string> = {
'content-type': 'text/markdown; charset=utf-8',
}

const ACCEPT_MATCH: VercelHeaderHas = {
type: 'header',
key: 'accept',
value: '(.*)text/markdown(.*)',
}

const USER_AGENT_MATCH: VercelHeaderHas = {
type: 'header',
key: 'user-agent',
value: 'curl/.*',
}

/**
* Build a pair of Vercel routes (one matching `Accept: text/markdown`, one matching
* `User-Agent: curl/*`) for a given `src` → `dest` rewrite.
*
* Vercel's `has` array is AND-ed, so OR semantics across header matchers require
* emitting two separate route entries.
*/
function buildMarkdownRoutePair(src: string, dest: string): VercelRoute[] {
return [
{
src,
dest,
headers: MARKDOWN_HEADERS,
has: [ACCEPT_MATCH],
continue: true,
},
{
src,
dest,
headers: MARKDOWN_HEADERS,
has: [USER_AGENT_MATCH],
continue: true,
},
]
}

export default defineNuxtModule({
meta: {
name: 'docs-markdown-rewrite',
},
setup(_options, nuxt) {
nuxt.hooks.hook('nitro:init', (nitro) => {
nitro.hooks.hook('compiled', async () => {

Check failure on line 78 in packages/layer/modules/markdown-rewrite.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=pleaseai_docs&issues=AZ5vJOwKK2pZvhKfl6h7&open=AZ5vJOwKK2pZvhKfl6h7&pullRequest=32
// Only run for Vercel presets (`vercel`, `vercel-edge`, `vercel-static`, ...).
const preset = nitro.options.preset
if (!preset || !preset.startsWith('vercel')) {

Check warning on line 81 in packages/layer/modules/markdown-rewrite.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=pleaseai_docs&issues=AZ5vJOwKK2pZvhKfl6h8&open=AZ5vJOwKK2pZvhKfl6h8&pullRequest=32
return
}

// Vercel build output config lives at <output.dir>/config.json, i.e. the
// parent of <output.publicDir>.
// https://vercel.com/docs/build-output-api
const vcConfigPath = resolve(nitro.options.output.publicDir, '..', 'config.json')

let raw: string
try {
raw = await readFile(vcConfigPath, 'utf8')
}
catch (err) {
log.warn(`Could not read Vercel build output config at ${vcConfigPath}; skipping markdown rewrites.`, err)
return
}

let vcConfig: VercelBuildOutputConfig
try {
vcConfig = JSON.parse(raw) as VercelBuildOutputConfig
}
catch (err) {
log.warn(`Could not parse Vercel build output config at ${vcConfigPath}; skipping markdown rewrites.`, err)
return
}

// Confirm llms.txt is actually present in the build output before we wire
// rewrites that target it.
const llmsTxtPath = resolve(nitro.options.output.publicDir, 'llms.txt')
let llmsTxt: string
try {
llmsTxt = await readFile(llmsTxtPath, 'utf8')
}
catch {
log.warn('llms.txt not found in publicDir; skipping markdown rewrite routes.')
return
}

const routes: VercelRoute[] = []

// 1. Homepage → /llms.txt
routes.push(...buildMarkdownRoutePair('^/$', '/llms.txt'))

// 2. Per-locale homepage → /llms.txt
// Locales are read from the actual `@nuxtjs/i18n` module options on
// `nuxt.options.i18n` (the same source used by the `nitro:config`
// hook in nuxt.config.ts). `runtimeConfig.public.i18n` is not a
// reliable source — the module does not always populate it, so
// relying on it can cause locale routes to silently never be
// generated. If absent, no per-locale routes are added.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const i18nOptions = (nuxt.options as any).i18n as

Check warning on line 133 in packages/layer/modules/markdown-rewrite.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=pleaseai_docs&issues=AZ5vRrjpQs1mZLiW2GPo&open=AZ5vRrjpQs1mZLiW2GPo&pullRequest=32
| { locales?: I18nLocale[] }
| undefined
const locales: I18nLocale[] = i18nOptions?.locales ?? []
const localeCodes: string[] = locales
.map(locale => (typeof locale === 'string' ? locale : locale.code))
.filter((code): code is string => typeof code === 'string' && code.length > 0)

if (localeCodes.length > 0) {
// Escape regex metacharacters defensively — locale codes are usually
// safe (`en`, `pt-BR`, `zh-Hans`), but we shouldn't trust them blindly.
const localePattern = localeCodes
.map(code => code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))

Check warning on line 145 in packages/layer/modules/markdown-rewrite.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=pleaseai_docs&issues=AZ5vJOwKK2pZvhKfl6h9&open=AZ5vJOwKK2pZvhKfl6h9&pullRequest=32
.join('|')
routes.push(...buildMarkdownRoutePair(`^/(${localePattern})/?$`, '/llms.txt'))
}

// 3. Docs pages → /raw<path>.md
// Enumerate pages from llms.txt links (the source of truth for what was
// prerendered for AI agents). This mirrors upstream docus (`6fd8686b`,
// `9ceafe6f`) and avoids accidentally rewriting asset URLs.
const urlRegex = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g
const seenPagePaths = new Set<string>()

for (const match of llmsTxt.matchAll(urlRegex)) {
const url = match[2]
if (!url) continue

let rawPath: string
try {
// Decode the pathname so URL-encoded characters (e.g. `%20` for
// spaces) are matched against Vercel's router, which compares
// against the decoded request pathname.
rawPath = decodeURIComponent(new URL(url).pathname)
}
catch {
continue
}
Comment thread
amondnet marked this conversation as resolved.

// Only handle /raw/<...>.md entries; skip the homepage and anything else.
if (rawPath === '/' || !rawPath.startsWith('/raw/')) {
continue
}

// Convert /raw/en/getting-started/installation.md → /en/getting-started/installation
const pagePath = rawPath.replace(/^\/raw/, '').replace(/\.md$/, '')

// Skip locale homepages (already covered by rule 2).
if (localeCodes.some(code => pagePath === `/${code}`)) {
continue
}

if (seenPagePaths.has(pagePath)) continue
seenPagePaths.add(pagePath)

// Escape regex metacharacters in the path so it matches literally.
// Allow an optional trailing slash (`/?$`) so requests like
// `/en/getting-started/installation/` are matched consistently with
// the per-locale homepage routes above.
const escapedPath = pagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')

Check warning on line 192 in packages/layer/modules/markdown-rewrite.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=pleaseai_docs&issues=AZ5vJOwKK2pZvhKfl6h_&open=AZ5vJOwKK2pZvhKfl6h_&pullRequest=32
routes.push(...buildMarkdownRoutePair(`^${escapedPath}/?$`, rawPath))
}

// Inject at the top so we fire before the SPA fallback.
vcConfig.routes = vcConfig.routes ?? []
vcConfig.routes.unshift(...routes)

await writeFile(vcConfigPath, JSON.stringify(vcConfig, null, 2), 'utf8')
log.info(
`Injected ${routes.length} markdown-rewrite route(s) into ${vcConfigPath} (serve markdown to AI agents).`,
)
})
})
},
})
1 change: 1 addition & 0 deletions packages/layer/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default defineNuxtConfig({
resolve('./modules/css'),
resolve('./modules/shadcn'),
resolve('./modules/skills'),
resolve('./modules/markdown-rewrite'),
'@nuxtjs/color-mode',
'@nuxt/content',
'@nuxt/image',
Expand Down