From 44653765b7c35c4752a9aaa071ef5f93f3535466 Mon Sep 17 00:00:00 2001
From: Minsu Lee <amond@amond.net>
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 `<output.publicDir>/../config.json`, validate that
  `llms.txt` was emitted, and unshift route pairs onto `routes` so
  they fire before the SPA fallback.
- Rules:
  - `/`         -> `/llms.txt`
  - `/<locale>` -> `/llms.txt`   (per `runtimeConfig.public.i18n.locales`)
  - `/<page>`   -> `/raw<page>.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<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 () => {
+        // 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 <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 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<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 {
+            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 <amond@amond.net>
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 <amond@amond.net>
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
+ * (`<input>`, `<textarea>`, or any element with `[contenteditable]`),
+ * in which case the shortcut should be ignored so the user can type freely.
+ *
+ * Traverses up the DOM tree from the event target so that non-`HTMLElement`
+ * descendants (e.g. `SVGElement`, `MathMLElement`) inside an editable
+ * container are still treated as editable.
+ */
+function isEditableTarget(target: EventTarget | null): boolean {
+  let el: Node | null = target instanceof Node ? target : null
+  while (el && !(el instanceof HTMLElement)) {
+    el = el.parentNode
+  }
+
+  if (!el)
+    return false
+
+  const tag = el.tagName
+  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
+    return true
+
+  if (el.isContentEditable)
+    return true
+
+  return false
+}
+
+/**
+ * Registers a keyboard shortcut (default `d`) that toggles between
+ * light and dark color modes. Mirrors docus' `useDocusShortcuts`
+ * composable (upstream commit `61c36d03`) but is reimplemented on top of
+ * `@vueuse/core`'s `onKeyStroke` instead of `@nuxt/ui`'s `defineShortcuts`.
+ *
+ * The handler bails out when:
+ * - the color mode is forced (e.g. `colorMode.forced === true`),
+ * - the keystroke originated from an editable element,
+ * - any modifier key (meta / ctrl / alt / shift) is pressed,
+ * - the configured shortcut is empty.
+ */
+export function useDocsShortcuts(): void {
+  const appConfig = useAppConfig()
+  const colorMode = useColorMode()
+
+  const toggleColorModeShortcut = computed<string>(
+    () => appConfig.docs?.shortcuts?.toggleColorMode ?? 'd',
+  )
+
+  onKeyStroke(
+    (event: KeyboardEvent) => {
+      const key = toggleColorModeShortcut.value
+      if (!key)
+        return false
+      return event.key?.toLowerCase() === key.toLowerCase()
+    },
+    (event: KeyboardEvent) => {
+      if (colorMode.forced)
+        return
+
+      if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey)
+        return
+
+      if (isEditableTarget(event.target))
+        return
+
+      const resolved = colorMode.value === 'dark' ? 'light' : 'dark'
+      colorMode.preference = resolved
+    },
+  )
+}
diff --git a/packages/layer/app/composables/useSeo.ts b/packages/layer/app/composables/useSeo.ts
new file mode 100644
index 00000000..318a066c
--- /dev/null
+++ b/packages/layer/app/composables/useSeo.ts
@@ -0,0 +1,198 @@
+import type { MaybeRefOrGetter } from 'vue'
+import type { BreadcrumbItem } from '../utils/navigation'
+import { joinURL, withoutTrailingSlash } from 'ufo'
+import { inferSiteURL } from '../../utils/site-url'
+
+export interface UseSeoOptions {
+  /**
+   * Page title.
+   */
+  title: MaybeRefOrGetter<string | undefined>
+  /**
+   * Page description.
+   */
+  description: MaybeRefOrGetter<string | undefined>
+  /**
+   * Page type for `og:type` (default: `'article'`).
+   */
+  type?: MaybeRefOrGetter<'website' | 'article'>
+  /**
+   * Custom OG image URL (absolute).
+   */
+  ogImage?: MaybeRefOrGetter<string | undefined>
+  /**
+   * Published date (ISO string) for Article schema.
+   */
+  publishedAt?: MaybeRefOrGetter<string | undefined>
+  /**
+   * Modified date (ISO string) for Article schema.
+   */
+  modifiedAt?: MaybeRefOrGetter<string | undefined>
+  /**
+   * Breadcrumb trail for BreadcrumbList schema.
+   */
+  breadcrumbs?: MaybeRefOrGetter<BreadcrumbItem[] | undefined>
+}
+
+type JsonLdScript = {
+  type: 'application/ld+json'
+  textContent: string
+} & { [key: `data-${string}`]: string }
+
+type LinkTag = {
+  rel: string
+  href?: string
+  hreflang?: string
+} & { [key: `data-${string}`]: string }
+
+/**
+ * Comprehensive SEO setup for documentation pages:
+ *
+ * - Title/description meta + OpenGraph + Twitter card tags
+ * - Canonical `<link>` resolved from app config / inferred site URL
+ * - JSON-LD structured data (Article or WebSite, optional BreadcrumbList)
+ *
+ * Ported from upstream docus commit `d283f9aa`
+ * (`feat(layer): add more seo optimization`). Hreflang emission is deferred
+ * until `@nuxtjs/i18n` is wired into the layer.
+ */
+export function useSeo(options: UseSeoOptions): void {
+  const route = useRoute()
+  const appConfig = useAppConfig()
+
+  const title = computed(() => toValue(options.title))
+  const description = computed(() => toValue(options.description))
+  const type = computed<'website' | 'article'>(() => toValue(options.type) ?? 'article')
+  const ogImage = computed(() => toValue(options.ogImage))
+  const publishedAt = computed(() => toValue(options.publishedAt))
+  const modifiedAt = computed(() => toValue(options.modifiedAt))
+  const breadcrumbs = computed(() => toValue(options.breadcrumbs))
+
+  // Resolve a usable site URL: prefer the explicit app config (populated by
+  // `modules/config.ts` at build time), fall back to env inference for
+  // server-side rendering.
+  const baseUrl = computed<string | undefined>(() => {
+    const configured = appConfig.docs?.url
+    const resolved = (typeof configured === 'string' && configured.length > 0)
+      ? configured
+      : inferSiteURL()
+    return resolved ? withoutTrailingSlash(resolved) : undefined
+  })
+
+  const canonicalUrl = computed<string | undefined>(() => {
+    if (!baseUrl.value) return undefined
+    return joinURL(baseUrl.value, route.path)
+  })
+
+  const siteName = computed<string | undefined>(() => {
+    const name = appConfig.docs?.title
+    return typeof name === 'string' && name.length > 0 ? name : undefined
+  })
+
+  // Standard meta tags (title/description/OG/Twitter).
+  useSeoMeta({
+    title,
+    description,
+    ogTitle: title,
+    ogDescription: description,
+    ogType: type,
+    ogUrl: canonicalUrl,
+    ogImage,
+    twitterCard: 'summary_large_image',
+    twitterTitle: title,
+    twitterDescription: description,
+    twitterImage: ogImage,
+  })
+
+  // Canonical + (future) hreflang links.
+  useHead({
+    link: computed<LinkTag[]>(() => {
+      const links: LinkTag[] = []
+
+      if (canonicalUrl.value) {
+        links.push({ rel: 'canonical', href: canonicalUrl.value })
+      }
+
+      // TODO: emit hreflang tags once @nuxtjs/i18n is wired in
+      // (upstream uses runtimeConfig.public.i18n).
+
+      return links
+    }),
+  })
+
+  // JSON-LD structured data.
+  useHead({
+    script: computed<JsonLdScript[]>(() => {
+      const scripts: JsonLdScript[] = []
+
+      if (!baseUrl.value) return scripts
+
+      const pageUrl = joinURL(baseUrl.value, route.path)
+
+      // Article/WebSite schemas require a title; breadcrumbs do not.
+      if (title.value) {
+        if (type.value === 'article') {
+          const articleSchema: Record<string, unknown> = {
+            '@context': 'https://schema.org',
+            '@type': 'Article',
+            'headline': title.value,
+            'description': description.value,
+            'url': pageUrl,
+            'mainEntityOfPage': {
+              '@type': 'WebPage',
+              '@id': pageUrl,
+            },
+          }
+          if (publishedAt.value) {
+            articleSchema.datePublished = publishedAt.value
+          }
+          if (modifiedAt.value) {
+            articleSchema.dateModified = modifiedAt.value
+          }
+          if (siteName.value) {
+            articleSchema.publisher = {
+              '@type': 'Organization',
+              'name': siteName.value,
+            }
+          }
+          scripts.push({
+            type: 'application/ld+json',
+            textContent: JSON.stringify(articleSchema),
+          })
+        }
+        else if (type.value === 'website') {
+          const websiteSchema: Record<string, unknown> = {
+            '@context': 'https://schema.org',
+            '@type': 'WebSite',
+            'name': siteName.value ?? title.value,
+            'description': description.value,
+            'url': baseUrl.value,
+          }
+          scripts.push({
+            type: 'application/ld+json',
+            textContent: JSON.stringify(websiteSchema),
+          })
+        }
+      }
+
+      if (breadcrumbs.value && breadcrumbs.value.length > 0) {
+        const breadcrumbSchema = {
+          '@context': 'https://schema.org',
+          '@type': 'BreadcrumbList',
+          'itemListElement': breadcrumbs.value.map((item, index) => ({
+            '@type': 'ListItem',
+            'position': index + 1,
+            'name': item.title,
+            'item': joinURL(baseUrl.value!, item.path),
+          })),
+        }
+        scripts.push({
+          type: 'application/ld+json',
+          textContent: JSON.stringify(breadcrumbSchema),
+        })
+      }
+
+      return scripts
+    }),
+  })
+}
diff --git a/packages/layer/app/pages/[...slug].vue b/packages/layer/app/pages/[...slug].vue
index d9b44838..d5b6e1a7 100644
--- a/packages/layer/app/pages/[...slug].vue
+++ b/packages/layer/app/pages/[...slug].vue
@@ -11,6 +11,10 @@ const { data: page } = await useAsyncData(`docs-${route.path}`, () =>
 const { data: surround } = await useAsyncData(`surround-${route.path}`, () =>
   queryCollectionItemSurroundings('docs', route.path))
 
+const { data: navigation } = await useNavigation()
+
+const breadcrumbs = computed(() => findPageBreadcrumbs(navigation.value, route.path))
+
 // Extract TOC from page body
 const _toc = computed(() => {
   if (!page.value?.body)
@@ -59,9 +63,13 @@ const _toc = computed(() => {
   return headings
 })
 
-useSeoMeta({
-  title: page.value?.title,
-  description: page.value?.description,
+useSeo({
+  title: () => page.value?.title,
+  description: () => page.value?.description,
+  type: 'article',
+  publishedAt: () => (page.value as { publishedAt?: string } | null | undefined)?.publishedAt,
+  modifiedAt: () => (page.value as { modifiedAt?: string } | null | undefined)?.modifiedAt,
+  breadcrumbs,
 })
 
 // Register raw markdown variant so static builds expose /raw/*.md for AI agents.
diff --git a/packages/layer/app/plugins/shortcuts.client.ts b/packages/layer/app/plugins/shortcuts.client.ts
new file mode 100644
index 00000000..ed7be778
--- /dev/null
+++ b/packages/layer/app/plugins/shortcuts.client.ts
@@ -0,0 +1,9 @@
+/**
+ * Installs the documentation layer's keyboard shortcuts on app boot.
+ *
+ * Client-only because the shortcut targets `window` keystrokes, which
+ * have no meaning on the server.
+ */
+export default defineNuxtPlugin(() => {
+  useDocsShortcuts()
+})
diff --git a/packages/layer/modules/config.ts b/packages/layer/modules/config.ts
index b63431aa..996b12ee 100644
--- a/packages/layer/modules/config.ts
+++ b/packages/layer/modules/config.ts
@@ -24,6 +24,9 @@ export default defineNuxtModule({
         url: gitInfo?.url,
         branch: getGitBranch(),
       },
+      shortcuts: {
+        toggleColorMode: 'd',
+      },
     })
 
     // SEO defaults
diff --git a/packages/layer/modules/skills/index.ts b/packages/layer/modules/skills/index.ts
new file mode 100644
index 00000000..ca37886f
--- /dev/null
+++ b/packages/layer/modules/skills/index.ts
@@ -0,0 +1,215 @@
+import { existsSync } from 'node:fs'
+import { readdir, readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { addServerHandler, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
+import { parse as parseYaml } from 'yaml'
+
+/**
+ * Skills module — exposes Anthropic Agent Skills via `.well-known/skills`.
+ *
+ * Ported from upstream docus:
+ * - `2f7861bd` feat(skills): add agent skills discovery via `.well-known`
+ * - `764329f5` feat(skills): make directory configurable via module options
+ *
+ * Skills live under `<rootDir>/<dir>/<name>/SKILL.md`, each with YAML
+ * frontmatter `{ name, description }`. Both fields are required; `name`
+ * must satisfy the Anthropic Agent Skills spec
+ * (`/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/`, max 64 chars) and `description`
+ * is capped at 200 chars.
+ */
+
+export interface ModuleOptions {
+  /**
+   * Directory (relative to project rootDir) that contains skill folders.
+   * Each subfolder is treated as a skill and must contain a `SKILL.md` file.
+   * @default 'skills'
+   */
+  dir?: string
+}
+
+interface SkillEntry {
+  name: string
+  description: string
+  files: string[]
+}
+
+const SKILL_NAME_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
+const MAX_NAME_LENGTH = 64
+const MAX_DESCRIPTION_LENGTH = 200
+
+export default defineNuxtModule<ModuleOptions>({
+  meta: {
+    name: 'skills',
+    configKey: 'skills',
+  },
+  defaults: {
+    dir: 'skills',
+  },
+  async setup(options, nuxt) {
+    const log = useLogger('docs-skills')
+    const dir = options.dir ?? 'skills'
+    const skillsDir = join(nuxt.options.rootDir, dir)
+
+    if (!existsSync(skillsDir)) {
+      return
+    }
+
+    const catalog = await scanSkills(skillsDir, log)
+    if (!catalog.length) {
+      return
+    }
+
+    log.info(
+      `Found ${catalog.length} agent skill${catalog.length > 1 ? 's' : ''}: ${catalog.map(s => s.name).join(', ')}`,
+    )
+
+    // Expose the catalog at runtime so the index route doesn't need to re-scan
+    // the source tree (which may be unavailable in serverless deployments).
+    nuxt.options.runtimeConfig.skills = { catalog }
+
+    const { resolve } = createResolver(import.meta.url)
+
+    nuxt.hook('nitro:config', (nitroConfig) => {
+      // Ship the skill files as Nitro server assets so they are readable
+      // from runtime handlers via `useStorage('assets:skills')`.
+      nitroConfig.serverAssets ||= []
+      nitroConfig.serverAssets.push({ baseName: 'skills', dir: skillsDir })
+
+      // Register every skill file path so static builds prerender them.
+      nitroConfig.prerender ||= {}
+      nitroConfig.prerender.routes ||= []
+      nitroConfig.prerender.routes.push('/.well-known/skills/index.json')
+      for (const skill of catalog) {
+        for (const file of skill.files) {
+          nitroConfig.prerender.routes.push(`/.well-known/skills/${skill.name}/${file}`)
+        }
+      }
+    })
+
+    addServerHandler({
+      route: '/.well-known/skills/index.json',
+      handler: resolve('./runtime/server/routes/.well-known/skills/index.get'),
+    })
+
+    addServerHandler({
+      route: '/.well-known/skills/**',
+      handler: resolve('./runtime/server/routes/.well-known/skills/[...path].get'),
+    })
+  },
+})
+
+function parseFrontmatter(content: string): { name?: string, description?: string } | null {
+  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
+  if (!match?.[1]) {
+    return null
+  }
+  try {
+    const parsed: unknown = parseYaml(match[1])
+    if (!parsed || typeof parsed !== 'object') {
+      return null
+    }
+    const obj = parsed as Record<string, unknown>
+    return {
+      name: typeof obj.name === 'string' ? obj.name : undefined,
+      description: typeof obj.description === 'string' ? obj.description : undefined,
+    }
+  }
+  catch {
+    return null
+  }
+}
+
+function validateSkillName(name: string, dirName: string, log: ReturnType<typeof useLogger>): boolean {
+  if (name.length > MAX_NAME_LENGTH) {
+    log.warn(`Skill "${name}" exceeds ${MAX_NAME_LENGTH} character limit`)
+    return false
+  }
+  if (!SKILL_NAME_REGEX.test(name) || name.includes('--')) {
+    log.warn(`Skill name "${name}" does not match the Agent Skills naming spec`)
+    return false
+  }
+  if (name !== dirName) {
+    log.warn(`Skill name "${name}" does not match directory name "${dirName}"`)
+    return false
+  }
+  return true
+}
+
+async function listFilesRecursively(dir: string, base: string = ''): Promise<string[]> {
+  const files: string[] = []
+  const entries = await readdir(dir, { withFileTypes: true })
+  for (const entry of entries) {
+    const relPath = base ? `${base}/${entry.name}` : entry.name
+    if (entry.isDirectory()) {
+      files.push(...await listFilesRecursively(join(dir, entry.name), relPath))
+    }
+    else {
+      files.push(relPath)
+    }
+  }
+  return files
+}
+
+async function scanSkills(skillsDir: string, log: ReturnType<typeof useLogger>): Promise<SkillEntry[]> {
+  const catalog: SkillEntry[] = []
+  const entries = await readdir(skillsDir, { withFileTypes: true })
+
+  for (const entry of entries) {
+    if (!entry.isDirectory()) {
+      continue
+    }
+
+    const skillDir = join(skillsDir, entry.name)
+    const skillMdPath = join(skillDir, 'SKILL.md')
+
+    if (!existsSync(skillMdPath)) {
+      continue
+    }
+
+    const content = await readFile(skillMdPath, 'utf-8')
+    const frontmatter = parseFrontmatter(content)
+
+    if (!frontmatter?.name) {
+      log.warn(`Skipping skill "${entry.name}": missing name in SKILL.md frontmatter`)
+      continue
+    }
+
+    if (!frontmatter.description) {
+      log.warn(`Skipping skill "${entry.name}": missing description in SKILL.md frontmatter`)
+      continue
+    }
+
+    if (frontmatter.description.length > MAX_DESCRIPTION_LENGTH) {
+      log.warn(
+        `Skipping skill "${entry.name}": description exceeds ${MAX_DESCRIPTION_LENGTH} character limit`,
+      )
+      continue
+    }
+
+    const name = frontmatter.name
+    if (!validateSkillName(name, entry.name, log)) {
+      continue
+    }
+
+    const allFiles = await listFilesRecursively(skillDir)
+    // Drop dot-prefixed paths (e.g. .DS_Store, .git directories).
+    const files = allFiles.filter(f => !f.split('/').some(s => s.startsWith('.')))
+    const sortedFiles = ['SKILL.md', ...files.filter(f => f !== 'SKILL.md')]
+
+    catalog.push({
+      name,
+      description: frontmatter.description,
+      files: sortedFiles,
+    })
+  }
+
+  return catalog
+}
+
+declare module 'nuxt/schema' {
+  interface RuntimeConfig {
+    skills: {
+      catalog: SkillEntry[]
+    }
+  }
+}
diff --git a/packages/layer/modules/skills/runtime/server/routes/.well-known/skills/[...path].get.ts b/packages/layer/modules/skills/runtime/server/routes/.well-known/skills/[...path].get.ts
new file mode 100644
index 00000000..2edaaa4b
--- /dev/null
+++ b/packages/layer/modules/skills/runtime/server/routes/.well-known/skills/[...path].get.ts
@@ -0,0 +1,90 @@
+const CONTENT_TYPES: Record<string, string> = {
+  '.md': 'text/markdown; charset=utf-8',
+  '.markdown': 'text/markdown; charset=utf-8',
+  '.json': 'application/json; charset=utf-8',
+  '.yaml': 'application/yaml; charset=utf-8',
+  '.yml': 'application/yaml; charset=utf-8',
+  '.txt': 'text/plain; charset=utf-8',
+  '.py': 'text/plain; charset=utf-8',
+  '.sh': 'text/plain; charset=utf-8',
+  '.bash': 'text/plain; charset=utf-8',
+  '.js': 'text/javascript; charset=utf-8',
+  '.mjs': 'text/javascript; charset=utf-8',
+  '.cjs': 'text/javascript; charset=utf-8',
+  '.ts': 'text/plain; charset=utf-8',
+  '.tsx': 'text/plain; charset=utf-8',
+  '.jsx': 'text/plain; charset=utf-8',
+  '.html': 'text/html; charset=utf-8',
+  '.css': 'text/css; charset=utf-8',
+  '.toml': 'text/plain; charset=utf-8',
+  '.xml': 'application/xml; charset=utf-8',
+}
+
+function getContentType(path: string): string {
+  // Only consider the basename so a path like `dir.x/file` doesn't pick up
+  // an extension from a parent directory.
+  const slash = path.lastIndexOf('/')
+  const basename = slash === -1 ? path : path.slice(slash + 1)
+  const dot = basename.lastIndexOf('.')
+  if (dot === -1) return 'application/octet-stream'
+  const ext = basename.slice(dot).toLowerCase()
+  return CONTENT_TYPES[ext] || 'application/octet-stream'
+}
+
+export default defineEventHandler(async (event) => {
+  const url = getRequestURL(event)
+  const prefix = '/.well-known/skills/'
+  const idx = url.pathname.indexOf(prefix)
+
+  if (idx === -1) {
+    throw createError({ statusCode: 404, statusMessage: 'Not Found' })
+  }
+
+  let filePath: string
+  try {
+    filePath = decodeURIComponent(url.pathname.slice(idx + prefix.length))
+  }
+  catch {
+    throw createError({ statusCode: 400, statusMessage: 'Bad Request' })
+  }
+
+  // The index.json route is served by a dedicated handler — this one should
+  // never see it, but guard anyway.
+  if (!filePath || filePath === 'index.json') {
+    throw createError({ statusCode: 404, statusMessage: 'Not Found' })
+  }
+
+  // Block path traversal and dotfile access.
+  const segments = filePath.split('/')
+  if (segments.some(seg => seg === '' || seg === '..' || seg === '.' || seg.startsWith('.'))) {
+    throw createError({ statusCode: 400, statusMessage: 'Bad Request' })
+  }
+
+  const storage = useStorage('assets:skills')
+
+  // Use getItemRaw so JSON/YAML files are returned as their raw bytes/string
+  // instead of being deserialized by destr.
+  const raw = await storage.getItemRaw<unknown>(filePath)
+
+  if (raw === null || raw === undefined) {
+    throw createError({ statusCode: 404, statusMessage: 'Not Found' })
+  }
+
+  setResponseHeader(event, 'content-type', getContentType(filePath))
+  setResponseHeader(event, 'cache-control', 'public, max-age=3600')
+
+  // Return raw bytes for non-string payloads so binary files (images,
+  // archives, etc.) are served untouched. UTF-8 decoding would corrupt
+  // them and produces unnecessary overhead for text payloads.
+  if (typeof raw === 'string') {
+    return raw
+  }
+  if (raw instanceof Uint8Array) {
+    return raw
+  }
+  if (typeof Buffer !== 'undefined' && Buffer.isBuffer(raw)) {
+    return raw
+  }
+
+  throw createError({ statusCode: 404, statusMessage: 'Not Found' })
+})
diff --git a/packages/layer/modules/skills/runtime/server/routes/.well-known/skills/index.get.ts b/packages/layer/modules/skills/runtime/server/routes/.well-known/skills/index.get.ts
new file mode 100644
index 00000000..fb0a62e4
--- /dev/null
+++ b/packages/layer/modules/skills/runtime/server/routes/.well-known/skills/index.get.ts
@@ -0,0 +1,20 @@
+/**
+ * Returns the agent skills catalog from `runtimeConfig.skills.catalog`,
+ * which the module builds at startup. This avoids re-scanning storage and
+ * re-parsing YAML frontmatter on every request.
+ */
+export default defineEventHandler((event) => {
+  const config = useRuntimeConfig(event)
+  const catalog = (config.skills?.catalog ?? [])
+    .map(skill => ({
+      name: skill.name,
+      description: skill.description,
+      path: `/.well-known/skills/${skill.name}/SKILL.md`,
+    }))
+    .sort((a, b) => a.name.localeCompare(b.name))
+
+  setResponseHeader(event, 'content-type', 'application/json; charset=utf-8')
+  setResponseHeader(event, 'cache-control', 'public, max-age=3600')
+
+  return { skills: { catalog } }
+})
diff --git a/packages/layer/nuxt.config.ts b/packages/layer/nuxt.config.ts
index da24396f..85d54e4c 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/skills'),
     resolve('./modules/markdown-rewrite'),
     '@nuxtjs/color-mode',
     '@nuxt/content',
diff --git a/packages/layer/nuxt.schema.ts b/packages/layer/nuxt.schema.ts
index c8b19dec..5bccd10e 100644
--- a/packages/layer/nuxt.schema.ts
+++ b/packages/layer/nuxt.schema.ts
@@ -107,6 +107,20 @@ export default defineNuxtSchema({
           },
         },
       },
+      shortcuts: {
+        $schema: {
+          title: 'Keyboard Shortcuts',
+          description: 'Keyboard shortcuts configuration',
+        },
+        toggleColorMode: {
+          $default: 'd',
+          $schema: {
+            title: 'Toggle Color Mode',
+            description: 'Shortcut to toggle light and dark mode. Leave empty to disable.',
+            type: 'string',
+          },
+        },
+      },
     },
   },
 })
diff --git a/packages/layer/package.json b/packages/layer/package.json
index 4e022b43..34ead94c 100644
--- a/packages/layer/package.json
+++ b/packages/layer/package.json
@@ -73,6 +73,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"
   },
