Skip to content

Commit e4b6d97

Browse files
authored
feat(docs): AI-friendly docs — markdown twins, llms.txt, copy page, upgraded search (#739)
- Emit a raw-markdown twin for every docs page at <url>.md at build time - Generate /docs/llms.txt, /docs/llms-full.txt and root /llms.txt indexes - Add Forge-style Copy page split button (copy markdown, view as markdown, open in ChatGPT/Claude) on all docs pages - Tune docs search: fuzzy section-level full-text via Fuse options, AI hint in the search modal footer - Serve .md and llms files with markdown content types via _headers; wildcard-exclude /docs/* in Cloudflare Pages _routes.json to stay under the 100-rule limit
1 parent 555c01d commit e4b6d97

8 files changed

Lines changed: 503 additions & 8 deletions

File tree

docs-v3/app.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
export default defineAppConfig({
22
ui: {
3+
// Docs search palette: highlighted match state + consistent grouped/footer styling.
4+
// `<mark>` tags are injected by @nuxt/ui's fuse highlighter (includeMatches) into
5+
// the heading (itemLabelBase) and body-content (itemLabelSuffix) of each result.
6+
commandPalette: {
7+
slots: {
8+
label: 'text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500',
9+
itemLabelBase: '[&_mark]:bg-blue-100 [&_mark]:dark:bg-blue-500/25 [&_mark]:text-blue-700 [&_mark]:dark:text-blue-300 [&_mark]:rounded [&_mark]:px-0.5 [&_mark]:font-semibold',
10+
itemLabelSuffix: '[&_mark]:bg-blue-100/70 [&_mark]:dark:bg-blue-500/20 [&_mark]:text-blue-700 [&_mark]:dark:text-blue-300 [&_mark]:rounded [&_mark]:px-0.5',
11+
footer: 'px-3 py-2.5 border-t border-gray-200 dark:border-gray-800'
12+
}
13+
},
314
accordion: {
415
slots: {
516
item: 'border-b border-gray-200 dark:border-gray-700/50 last:border-b-0',

docs-v3/app.vue

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,32 @@
77
shortcut="meta_k"
88
:files="files"
99
:navigation="navigation"
10-
:fuse="{ resultLimit: 42 }"
11-
placeholder="Search documentation..."
12-
/>
10+
:fuse="searchFuse"
11+
placeholder="Search the docs, headings and content…"
12+
>
13+
<template #footer>
14+
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 w-full text-xs text-gray-500 dark:text-gray-400">
15+
<span class="flex items-center gap-1.5">
16+
<UIcon name="i-lucide-sparkles" class="size-3.5 text-blue-500 dark:text-blue-400 shrink-0" />
17+
<span>
18+
<span class="font-medium text-gray-700 dark:text-gray-300">AI tip:</span>
19+
append
20+
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-800 font-mono text-[0.7rem] text-gray-700 dark:text-gray-300">.md</code>
21+
to any docs URL for raw markdown
22+
</span>
23+
</span>
24+
<NuxtLink
25+
to="/docs/llms.txt"
26+
external
27+
target="_blank"
28+
class="flex items-center gap-1 font-medium text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
29+
>
30+
<UIcon name="i-lucide-file-text" class="size-3.5 shrink-0" />
31+
/docs/llms.txt
32+
</NuxtLink>
33+
</div>
34+
</template>
35+
</LazyUContentSearch>
1336
</ClientOnly>
1437
</UApp>
1538
</template>
@@ -43,4 +66,17 @@ const { data: navigation } = await useAsyncData('navigation', () => queryCollect
4366
const { data: files } = await useAsyncData('search', () => queryCollectionSearchSections('content'))
4467
4568
const searchTerm = ref('')
69+
70+
// Fuse.js tuning for AI/agent-era docs search: fuzzy, section-aware, generous recall.
71+
// Keys stay at the CommandPalette default (['label', 'suffix'] = heading + body content) —
72+
// defu concatenates arrays, so redefining them here would duplicate the defaults.
73+
const searchFuse = {
74+
resultLimit: 20,
75+
fuseOptions: {
76+
ignoreLocation: true,
77+
threshold: 0.3,
78+
minMatchCharLength: 2,
79+
includeScore: true
80+
}
81+
}
4682
</script>

docs-v3/components/TheHeader.vue

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121

2222
<div class="flex items-center space-x-4">
2323
<div class="hidden lg:block">
24-
<UContentSearchButton
24+
<UContentSearchButton
2525
:collapsed="false"
26-
placeholder="Search documentation..."
26+
:kbds="['meta', 'K']"
27+
label="Search documentation…"
28+
class="w-64 justify-start text-gray-500 dark:text-gray-400"
2729
/>
2830
</div>
2931

@@ -58,9 +60,11 @@
5860

5961
<div class="lg:hidden px-4 pb-4 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 w-full">
6062
<div class="pt-4 w-full">
61-
<UContentSearchButton
62-
:collapsed="false"
63-
placeholder="Search documentation..."
63+
<UContentSearchButton
64+
:collapsed="false"
65+
:kbds="[]"
66+
label="Search documentation…"
67+
class="w-full justify-start text-gray-500 dark:text-gray-400"
6468
/>
6569
</div>
6670
</div>
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<template>
2+
<div
3+
class="inline-flex items-stretch rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-sm overflow-hidden"
4+
>
5+
<button
6+
type="button"
7+
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
8+
:aria-label="copied ? 'Copied' : 'Copy page as Markdown'"
9+
@click="copyPage"
10+
>
11+
<UIcon
12+
:name="copied ? 'i-lucide-check' : 'i-lucide-copy'"
13+
class="w-4 h-4 shrink-0"
14+
:class="copied ? 'text-green-600 dark:text-green-400' : ''"
15+
/>
16+
<span>{{ copied ? 'Copied' : 'Copy page' }}</span>
17+
</button>
18+
19+
<UDropdownMenu
20+
:items="items"
21+
:content="{ align: 'end', side: 'bottom', sideOffset: 6 }"
22+
:ui="{ content: 'w-72' }"
23+
>
24+
<button
25+
type="button"
26+
class="inline-flex items-center px-2 border-l border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
27+
aria-label="More copy options"
28+
>
29+
<UIcon name="i-lucide-chevron-down" class="w-4 h-4" />
30+
</button>
31+
32+
<template #item-label="{ item }">
33+
<div class="flex flex-col text-left">
34+
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ item.label }}</span>
35+
<span v-if="item.description" class="text-xs text-gray-500 dark:text-gray-400">
36+
{{ item.description }}
37+
</span>
38+
</div>
39+
</template>
40+
</UDropdownMenu>
41+
</div>
42+
</template>
43+
44+
<script setup lang="ts">
45+
import { computed } from 'vue'
46+
47+
const route = useRoute()
48+
const config = useRuntimeConfig()
49+
const { copied, copyToClipboard } = useClipboardCopy()
50+
51+
const siteUrl = config.public.siteUrl as string
52+
53+
const mdPath = computed(() => `${route.path.replace(/\/$/, '')}.md`)
54+
const absoluteMdUrl = computed(() => `${siteUrl}${mdPath.value}`)
55+
56+
const llmPrompt = computed(
57+
() => `Read from ${absoluteMdUrl.value} so I can ask questions about it.`
58+
)
59+
60+
const chatGptUrl = computed(
61+
() => `https://chatgpt.com/?hints=search&q=${encodeURIComponent(llmPrompt.value)}`
62+
)
63+
64+
const claudeUrl = computed(
65+
() => `https://claude.ai/new?q=${encodeURIComponent(llmPrompt.value)}`
66+
)
67+
68+
async function fetchMarkdown(): Promise<string> {
69+
try {
70+
const response = await fetch(mdPath.value)
71+
72+
if (response.ok) {
73+
const text = await response.text()
74+
75+
if (text && !text.trimStart().startsWith('<')) {
76+
return text
77+
}
78+
}
79+
} catch {
80+
// Fall through to URL fallback (e.g. .md twin missing in dev)
81+
}
82+
83+
return absoluteMdUrl.value
84+
}
85+
86+
async function copyPage(): Promise<void> {
87+
const markdown = await fetchMarkdown()
88+
await copyToClipboard(markdown)
89+
}
90+
91+
const items = computed(() => [
92+
[
93+
{
94+
label: 'Copy page',
95+
description: 'Copy page as Markdown for LLMs',
96+
icon: 'i-lucide-file-text',
97+
onSelect: copyPage
98+
},
99+
{
100+
label: 'View as Markdown',
101+
description: 'Open the raw Markdown in a new tab',
102+
icon: 'i-lucide-file-code',
103+
to: mdPath.value,
104+
target: '_blank'
105+
}
106+
],
107+
[
108+
{
109+
label: 'Open in ChatGPT',
110+
description: 'Ask ChatGPT about this page',
111+
icon: 'i-lucide-bot',
112+
to: chatGptUrl.value,
113+
target: '_blank'
114+
},
115+
{
116+
label: 'Open in Claude',
117+
description: 'Ask Claude about this page',
118+
icon: 'i-lucide-sparkles',
119+
to: claudeUrl.value,
120+
target: '_blank'
121+
}
122+
]
123+
])
124+
</script>

docs-v3/nuxt.config.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { fileURLToPath } from 'node:url'
2+
13
const docsRoutes = [
24
'/',
35
'/docs',
@@ -135,6 +137,20 @@ export default defineNuxtConfig({
135137
// Cloudflare Pages does not understand, and exact-match rules would additionally be
136138
// prerendered as meta-refresh stub HTML that shadows the redirect with a 200.
137139

140+
// Emit Forge-style raw markdown twins and llms.txt indexes into the static
141+
// output at build time. Runs after Nitro has staged public assets, so the files
142+
// land in the final publicDir (dist/) alongside the prerendered pages.
143+
hooks: {
144+
async 'nitro:build:public-assets'(nitro) {
145+
const { generateLlms } = await import('./scripts/generate-llms.mjs')
146+
await generateLlms({
147+
contentDir: fileURLToPath(new URL('./content/docs', import.meta.url)),
148+
outputDir: nitro.options.output.publicDir,
149+
siteUrl: 'https://laravel-restify.com'
150+
})
151+
}
152+
},
153+
138154
// TypeScript configuration
139155
typescript: {
140156
typeCheck: false
@@ -144,6 +160,22 @@ export default defineNuxtConfig({
144160
ssr: true,
145161
nitro: {
146162
preset: 'cloudflare_pages',
163+
// Collapse the per-file static excludes into a handful of wildcards so the
164+
// generated dist/_routes.json stays well under Cloudflare Pages' hard limit
165+
// of 100 rules. Nitro seeds `exclude` from this config first, converts each
166+
// trailing `/*` into a `/**` glob to skip matching files during its own
167+
// asset walk, then appends the remaining root assets. Without this, every
168+
// prerendered /docs page plus its raw `.md` twin and the llms.txt indexes
169+
// were emitted as individual excludes, pinning the file at exactly 100 rules
170+
// (one content page away from a rejected deploy). `/docs/*` covers all docs
171+
// HTML pages, their `.md` twins, and /docs/llms{,-full}.txt in one rule.
172+
cloudflare: {
173+
pages: {
174+
routes: {
175+
exclude: ['/docs/*', '/docs.md', '/llms.txt']
176+
}
177+
}
178+
},
147179
prerender: {
148180
failOnError: false,
149181
crawlLinks: true,

docs-v3/pages/[...slug].vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
<NuxtLayout>
33
<div v-if="post">
44
<article>
5+
<div v-if="isDocsPage" class="flex justify-end mb-4">
6+
<CopyPageDropdown />
7+
</div>
8+
59
<header class="mb-8">
610
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
711
{{ post.title }}
@@ -42,6 +46,8 @@ const route = useRoute()
4246
const pathSegments = Array.isArray(route.params.slug) ? route.params.slug : [route.params.slug || '']
4347
const contentPath = `/${pathSegments.join('/')}`
4448
49+
const isDocsPage = computed(() => contentPath === '/docs' || contentPath.startsWith('/docs/'))
50+
4551
const { data: post } = await useAsyncData(`content-${contentPath}`, function fetchContent() {
4652
return queryCollection('content').path(contentPath).first()
4753
})

docs-v3/public/_headers

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,21 @@
88
cache-control: public, max-age=31536000, immutable
99
/_nuxt/*
1010
cache-control: public, max-age=31536000, immutable
11+
12+
# Serve the Forge-style raw markdown twins and llms.txt indexes as UTF-8 markdown
13+
# so AI agents and browsers render them as text rather than downloading them.
14+
/docs/*.md
15+
content-type: text/markdown; charset=utf-8
16+
cache-control: public, max-age=3600
17+
/docs.md
18+
content-type: text/markdown; charset=utf-8
19+
cache-control: public, max-age=3600
20+
/llms.txt
21+
content-type: text/plain; charset=utf-8
22+
cache-control: public, max-age=3600
23+
/docs/llms.txt
24+
content-type: text/plain; charset=utf-8
25+
cache-control: public, max-age=3600
26+
/docs/llms-full.txt
27+
content-type: text/plain; charset=utf-8
28+
cache-control: public, max-age=3600

0 commit comments

Comments
 (0)