From 44653765b7c35c4752a9aaa071ef5f93f3535466 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 29 May 2026 00:10:52 +0900 Subject: [PATCH 1/3] feat(layer): add Vercel markdown-rewrite module Adds a Nuxt module that injects Vercel build-output rewrite rules so that AI agents (anything sending `Accept: text/markdown` or `User-Agent: curl/*`) get served raw markdown instead of the SPA shell. Behaviour: - No-op on every non-Vercel preset (matches `vercel`, `vercel-edge`, `vercel-static`, etc. via `preset.startsWith('vercel')`). - On Vercel: read `/../config.json`, validate that `llms.txt` was emitted, and unshift route pairs onto `routes` so they fire before the SPA fallback. - Rules: - `/` -> `/llms.txt` - `/` -> `/llms.txt` (per `runtimeConfig.public.i18n.locales`) - `/` -> `/raw.md` for every `/raw/...md` link found in `llms.txt` - Vercel's `has` array is AND-ed, so OR semantics between the `Accept` and `User-Agent` matchers require emitting two rules per `src` -> `dest` pair. - Locale codes are regex-escaped before being joined into the alternation so exotic codes can't break the pattern. Ports upstream docus commits `6fd8686b` and `9ceafe6f` -- see `docs/docus-upstream-changes.md` item #9. Verification: - `bun lint` -> clean - `bun typecheck` -> no new errors in `markdown-rewrite.ts` - `NITRO_PRESET=vercel bun --filter @pleaseai/docs-site build` injects two routes (homepage / Accept + User-Agent) into `.vercel/output/config.json`. - Default (cloudflare) build is unchanged; module bails silently. --- packages/layer/modules/markdown-rewrite.ts | 196 +++++++++++++++++++++ packages/layer/nuxt.config.ts | 1 + 2 files changed, 197 insertions(+) create mode 100644 packages/layer/modules/markdown-rewrite.ts diff --git a/packages/layer/modules/markdown-rewrite.ts b/packages/layer/modules/markdown-rewrite.ts new file mode 100644 index 00000000..ddc0fb7c --- /dev/null +++ b/packages/layer/modules/markdown-rewrite.ts @@ -0,0 +1,196 @@ +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 + has?: VercelHeaderHas[] + continue?: boolean + [key: string]: unknown +} + +interface VercelBuildOutputConfig { + version: number + routes?: VercelRoute[] + [key: string]: unknown +} + +const MARKDOWN_HEADERS: Record = { + '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 () => { + // Only run for Vercel presets (`vercel`, `vercel-edge`, `vercel-static`, ...). + const preset = nitro.options.preset + if (!preset || !preset.startsWith('vercel')) { + return + } + + // Vercel build output config lives at /config.json, i.e. the + // parent of . + // 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 runtimeConfig.public.i18n.locales (populated by + // `@nuxtjs/i18n` when present). If absent, no per-locale routes are added. + const publicRuntime = nuxt.options.runtimeConfig?.public as + | { i18n?: { locales?: I18nLocale[] } } + | undefined + const locales: I18nLocale[] = publicRuntime?.i18n?.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, '\\$&')) + .join('|') + routes.push(...buildMarkdownRoutePair(`^/(${localePattern})/?$`, '/llms.txt')) + } + + // 3. Docs pages → /raw.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() + + for (const match of llmsTxt.matchAll(urlRegex)) { + const url = match[2] + if (!url) continue + + let rawPath: string + try { + rawPath = new URL(url).pathname + } + catch { + continue + } + + // 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. + const escapedPath = pagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + 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).`, + ) + }) + }) + }, +}) diff --git a/packages/layer/nuxt.config.ts b/packages/layer/nuxt.config.ts index 7a682bca..da24396f 100644 --- a/packages/layer/nuxt.config.ts +++ b/packages/layer/nuxt.config.ts @@ -8,6 +8,7 @@ export default defineNuxtConfig({ resolve('./modules/config'), resolve('./modules/css'), resolve('./modules/shadcn'), + resolve('./modules/markdown-rewrite'), '@nuxtjs/color-mode', '@nuxt/content', '@nuxt/image', From 0713fbcee0cbf11c73d83ff15033a916842be396 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 29 May 2026 00:48:19 +0900 Subject: [PATCH 2/3] fix(layer): apply review suggestions Apply review feedback from cubic-dev-ai and gemini-code-assist on the markdown-rewrite module: - Read locale config from `nuxt.options.i18n` instead of `runtimeConfig.public.i18n` (matching the pattern used by the `nitro:config` hook in nuxt.config.ts). The i18n module does not always populate `runtimeConfig.public.i18n`, so the previous source could silently miss locale routes. (cubic) - Decode the URL pathname with `decodeURIComponent` so that paths with URL-encoded characters (e.g. `%20`) match Vercel's router, which compares against decoded request pathnames. (gemini) - Allow an optional trailing slash on docs page route patterns (`/?$` instead of `$`) so requests like `/en/getting-started/installation/` are matched consistently with the per-locale homepage routes. (cubic, gemini) --- bun.lock | 8 +++---- packages/layer/modules/markdown-rewrite.ts | 25 ++++++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/bun.lock b/bun.lock index d0a478a8..233de160 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ }, "packages/layer": { "name": "docs-please", - "version": "0.2.2", + "version": "0.2.6", "dependencies": { "@iconify-json/lucide": "^1.2.81", "@nuxt/content": "^3.9.0", @@ -40,7 +40,9 @@ "@nuxt/kit": "^4.2.2", "@nuxtjs/color-mode": "^4.0.0", "@nuxtjs/mdc": "^0.19.1", + "@nuxtjs/robots": "^5.6.7", "@tabler/icons-vue": "^3.35.0", + "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.1.0", "@vueuse/integrations": "^14.1.0", "class-variance-authority": "^0.7.1", @@ -51,6 +53,7 @@ "exsolve": "^1.0.8", "fuse.js": "^7.1.0", "lucide-vue-next": "^0.555.0", + "nuxt-llms": "^0.1.3", "nuxt-og-image": "^5.1.13", "oxc-parser": "^0.99.0", "parse5": "^7.3.0", @@ -72,10 +75,7 @@ "zod-to-json-schema": "^3.25.0", }, "devDependencies": { - "@nuxtjs/robots": "^5.6.7", - "@tailwindcss/vite": "^4.1.18", "better-sqlite3": "^11.9.1", - "nuxt-llms": "^0.1.3", "typescript": "^5.9.3", "vue-tsc": "^2.0.0", }, diff --git a/packages/layer/modules/markdown-rewrite.ts b/packages/layer/modules/markdown-rewrite.ts index ddc0fb7c..4f2ecd5a 100644 --- a/packages/layer/modules/markdown-rewrite.ts +++ b/packages/layer/modules/markdown-rewrite.ts @@ -123,12 +123,17 @@ export default defineNuxtModule({ routes.push(...buildMarkdownRoutePair('^/$', '/llms.txt')) // 2. Per-locale homepage → /llms.txt - // Locales are read from runtimeConfig.public.i18n.locales (populated by - // `@nuxtjs/i18n` when present). If absent, no per-locale routes are added. - const publicRuntime = nuxt.options.runtimeConfig?.public as - | { i18n?: { locales?: I18nLocale[] } } + // 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 + | { locales?: I18nLocale[] } | undefined - const locales: I18nLocale[] = publicRuntime?.i18n?.locales ?? [] + 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) @@ -155,7 +160,10 @@ export default defineNuxtModule({ let rawPath: string try { - rawPath = new URL(url).pathname + // 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 @@ -178,8 +186,11 @@ export default defineNuxtModule({ 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, '\\$&') - routes.push(...buildMarkdownRoutePair(`^${escapedPath}$`, rawPath)) + routes.push(...buildMarkdownRoutePair(`^${escapedPath}/?$`, rawPath)) } // Inject at the top so we fire before the SPA fallback. From 13bde6247bd2c638c736b14a4490acfdc4e03b5f Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 29 May 2026 00:57:17 +0900 Subject: [PATCH 3/3] Merge branch 'main' into worktree-agent-a1a3a89e87faf9e6d Resolve modules-array conflict in packages/layer/nuxt.config.ts by keeping both new module registrations from #29/#30/#31 and this PR's markdown-rewrite module. --- bun.lock | 1 + packages/layer/app/app.config.ts | 3 + .../layer/app/composables/useDocsShortcuts.ts | 73 ++++++ packages/layer/app/composables/useSeo.ts | 198 ++++++++++++++++ packages/layer/app/pages/[...slug].vue | 14 +- .../layer/app/plugins/shortcuts.client.ts | 9 + packages/layer/modules/config.ts | 3 + packages/layer/modules/skills/index.ts | 215 ++++++++++++++++++ .../.well-known/skills/[...path].get.ts | 90 ++++++++ .../routes/.well-known/skills/index.get.ts | 20 ++ packages/layer/nuxt.config.ts | 1 + packages/layer/nuxt.schema.ts | 14 ++ packages/layer/package.json | 1 + 13 files changed, 639 insertions(+), 3 deletions(-) create mode 100644 packages/layer/app/composables/useDocsShortcuts.ts create mode 100644 packages/layer/app/composables/useSeo.ts create mode 100644 packages/layer/app/plugins/shortcuts.client.ts create mode 100644 packages/layer/modules/skills/index.ts create mode 100644 packages/layer/modules/skills/runtime/server/routes/.well-known/skills/[...path].get.ts create mode 100644 packages/layer/modules/skills/runtime/server/routes/.well-known/skills/index.get.ts diff --git a/bun.lock b/bun.lock index 233de160..9f584424 100644 --- a/bun.lock +++ b/bun.lock @@ -71,6 +71,7 @@ "ufo": "^1.6.1", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", + "yaml": "^2.8.2", "zod": "^4.2.1", "zod-to-json-schema": "^3.25.0", }, diff --git a/packages/layer/app/app.config.ts b/packages/layer/app/app.config.ts index 51af4d66..f74375af 100644 --- a/packages/layer/app/app.config.ts +++ b/packages/layer/app/app.config.ts @@ -17,5 +17,8 @@ export default defineAppConfig({ url: '', branch: 'main', }, + shortcuts: { + toggleColorMode: 'd', + }, }, }) diff --git a/packages/layer/app/composables/useDocsShortcuts.ts b/packages/layer/app/composables/useDocsShortcuts.ts new file mode 100644 index 00000000..6296c2df --- /dev/null +++ b/packages/layer/app/composables/useDocsShortcuts.ts @@ -0,0 +1,73 @@ +import { onKeyStroke } from '@vueuse/core' +import { computed } from 'vue' + +/** + * Returns `true` when the keystroke originated from an editable element + * (``, `