nuxt-ai-ready 1.3.9 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/devtools/lib/ai-ready/rpc.ts +6 -0
- package/dist/devtools/lib/ai-ready/state.ts +21 -0
- package/dist/devtools/lib/ai-ready/types.ts +50 -0
- package/dist/devtools/nuxt.config.ts +3 -0
- package/dist/devtools/pages/ai-ready/debug.vue +27 -0
- package/dist/devtools/pages/ai-ready/docs.vue +3 -0
- package/dist/devtools/pages/ai-ready/index.vue +177 -0
- package/dist/devtools/pages/ai-ready/llms-txt.vue +172 -0
- package/dist/devtools/pages/ai-ready.vue +50 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +6 -4
- package/dist/runtime/server/db/drizzle/providers/libsql.js +6 -1
- package/dist/runtime/server/middleware/markdown.js +12 -11
- package/dist/runtime/server/utils/link-header.d.ts +3 -1
- package/dist/runtime/server/utils/link-header.js +11 -2
- package/dist/runtime/types.d.ts +6 -0
- package/package.json +27 -27
- package/dist/devtools/200.html +0 -1
- package/dist/devtools/404.html +0 -1
- package/dist/devtools/_nuxt/B75sBxVw.js +0 -1
- package/dist/devtools/_nuxt/C5qFqPjv.js +0 -2
- package/dist/devtools/_nuxt/CUvdyac7.js +0 -1
- package/dist/devtools/_nuxt/D9Lze2B3.js +0 -1
- package/dist/devtools/_nuxt/D9pLQdnz.js +0 -38
- package/dist/devtools/_nuxt/DevtoolsPanel.BTjFkHvn.css +0 -1
- package/dist/devtools/_nuxt/DiEjVMsr.js +0 -1
- package/dist/devtools/_nuxt/Es9IinpI.js +0 -1
- package/dist/devtools/_nuxt/builds/latest.json +0 -1
- package/dist/devtools/_nuxt/builds/meta/f26345f8-0602-46ab-916a-0529f3a14be9.json +0 -1
- package/dist/devtools/_nuxt/entry.C01PFCnM.css +0 -1
- package/dist/devtools/_nuxt/fira-code.Bc8wnsZt.woff2 +0 -0
- package/dist/devtools/_nuxt/hubot-sans.DLGyhQVu.woff2 +0 -0
- package/dist/devtools/_nuxt/index.Cz-FJ6Du.css +0 -1
- package/dist/devtools/debug/index.html +0 -1
- package/dist/devtools/docs/index.html +0 -1
- package/dist/devtools/index.html +0 -1
- package/dist/devtools/llms-txt/index.html +0 -1
- package/dist/devtools/pages/index.html +0 -1
package/README.md
CHANGED
|
@@ -41,6 +41,8 @@ npx nuxi@latest module add nuxt-ai-ready
|
|
|
41
41
|
> npx skilld add nuxt-ai-ready
|
|
42
42
|
> ```
|
|
43
43
|
|
|
44
|
+
💡 Made your site AI-ready? Preview how a page converts to LLM-readable markdown with the free [HTML→Markdown tool](https://nuxtseo.com/tools/html-to-markdown), or track how AI engines index, rank and cite your site with [Nuxt SEO Pro](https://nuxtseo.com/pro).
|
|
45
|
+
|
|
44
46
|
## Documentation
|
|
45
47
|
|
|
46
48
|
[📖 Read the full documentation](https://nuxtseo.com/ai-ready) for more information.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { useDevtoolsConnection } from 'nuxtseo-layer-devtools/composables/rpc'
|
|
2
|
+
|
|
3
|
+
// The layer owns host fetch + route tracking and refreshes on connect; ai-ready's
|
|
4
|
+
// state.ts watches refreshTime to reload the global debug data, so no module-level
|
|
5
|
+
// host access is needed here.
|
|
6
|
+
useDevtoolsConnection()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DevtoolsGlobalData } from './types'
|
|
2
|
+
import { appFetch } from 'nuxtseo-layer-devtools/composables/rpc'
|
|
3
|
+
import { productionUrl, refreshTime } from 'nuxtseo-layer-devtools/composables/state'
|
|
4
|
+
import { ref, watch } from 'vue'
|
|
5
|
+
|
|
6
|
+
export const data = ref<DevtoolsGlobalData | null>(null)
|
|
7
|
+
export const loading = ref(true)
|
|
8
|
+
|
|
9
|
+
export async function refreshSources() {
|
|
10
|
+
if (!appFetch.value || typeof appFetch.value !== 'function')
|
|
11
|
+
return
|
|
12
|
+
data.value = await appFetch.value('/__ai-ready__/debug.json', { responseType: 'json' }).catch(() => null)
|
|
13
|
+
loading.value = false
|
|
14
|
+
if (data.value?.siteConfigUrl)
|
|
15
|
+
productionUrl.value = data.value.siteConfigUrl
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Re-fetch the global debug data when the host connection or manual refresh changes
|
|
19
|
+
watch([appFetch, refreshTime], () => {
|
|
20
|
+
refreshSources()
|
|
21
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface DevtoolsConfig {
|
|
2
|
+
database: { type: string }
|
|
3
|
+
runtimeSync: { enabled: boolean, ttl: number, batchSize: number, pruneTtl: number }
|
|
4
|
+
indexNow: boolean
|
|
5
|
+
sitemapPrerendered: boolean
|
|
6
|
+
markdownCacheHeaders: { maxAge: number, swr: boolean }
|
|
7
|
+
llmsTxtCacheSeconds: number
|
|
8
|
+
contentSignal: false | { aiTrain: boolean, search: boolean, aiInput: boolean }
|
|
9
|
+
mcp: { enabled: boolean, tools: boolean, resources: boolean }
|
|
10
|
+
cron: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface LlmsTxtLink {
|
|
14
|
+
title: string
|
|
15
|
+
description?: string
|
|
16
|
+
href: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LlmsTxtSection {
|
|
20
|
+
title: string
|
|
21
|
+
description?: string | string[]
|
|
22
|
+
links?: LlmsTxtLink[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface LlmsTxtConfig {
|
|
26
|
+
sections?: LlmsTxtSection[]
|
|
27
|
+
notes?: string | string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PageSummary {
|
|
31
|
+
route: string
|
|
32
|
+
title: string
|
|
33
|
+
description: string
|
|
34
|
+
updatedAt: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DevtoolsGlobalData {
|
|
38
|
+
version: string
|
|
39
|
+
siteConfigUrl: string
|
|
40
|
+
isDev: boolean
|
|
41
|
+
config: DevtoolsConfig
|
|
42
|
+
llmsTxt: LlmsTxtConfig
|
|
43
|
+
stats?: {
|
|
44
|
+
total: number
|
|
45
|
+
indexed: number
|
|
46
|
+
pending: number
|
|
47
|
+
errors: number
|
|
48
|
+
}
|
|
49
|
+
pages?: PageSummary[]
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { data } from '../../lib/ai-ready/state'
|
|
4
|
+
|
|
5
|
+
const config = computed(() => data.value?.config)
|
|
6
|
+
const llmsTxt = computed(() => data.value?.llmsTxt)
|
|
7
|
+
|
|
8
|
+
const configJson = computed(() => config.value ? JSON.stringify(config.value, null, 2) : '')
|
|
9
|
+
const llmsTxtJson = computed(() => llmsTxt.value ? JSON.stringify(llmsTxt.value, null, 2) : '')
|
|
10
|
+
const fullJson = computed(() => data.value ? JSON.stringify(data.value, null, 2) : '')
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div class="space-y-4">
|
|
15
|
+
<DevtoolsSection text="Module Config" icon="carbon:settings" :open="true">
|
|
16
|
+
<DevtoolsSnippet v-if="configJson" :code="configJson" lang="json" />
|
|
17
|
+
</DevtoolsSection>
|
|
18
|
+
|
|
19
|
+
<DevtoolsSection text="llms.txt Config" icon="carbon:document" :open="true">
|
|
20
|
+
<DevtoolsSnippet v-if="llmsTxtJson" :code="llmsTxtJson" lang="json" />
|
|
21
|
+
</DevtoolsSection>
|
|
22
|
+
|
|
23
|
+
<DevtoolsSection text="Full Response" icon="carbon:code" :open="false">
|
|
24
|
+
<DevtoolsSnippet v-if="fullJson" :code="fullJson" lang="json" />
|
|
25
|
+
</DevtoolsSection>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { appFetch } from 'nuxtseo-layer-devtools/composables/rpc'
|
|
3
|
+
import { hasProductionUrl, isProductionMode, previewSource } from 'nuxtseo-layer-devtools/composables/state'
|
|
4
|
+
import { computed, ref } from 'vue'
|
|
5
|
+
import { useAsyncData } from '#imports'
|
|
6
|
+
import { data as globalData } from '../../lib/ai-ready/state'
|
|
7
|
+
|
|
8
|
+
const isDev = computed(() => globalData?.value?.isDev ?? true)
|
|
9
|
+
const stats = computed(() => globalData?.value?.stats)
|
|
10
|
+
const pages = computed(() => globalData?.value?.pages || [])
|
|
11
|
+
|
|
12
|
+
const search = ref('')
|
|
13
|
+
const selectedRoute = ref<string | null>(null)
|
|
14
|
+
|
|
15
|
+
const filteredPages = computed(() => {
|
|
16
|
+
if (!search.value)
|
|
17
|
+
return pages.value
|
|
18
|
+
const q = search.value.toLowerCase()
|
|
19
|
+
return pages.value.filter(p =>
|
|
20
|
+
p.route.toLowerCase().includes(q)
|
|
21
|
+
|| p.title?.toLowerCase().includes(q)
|
|
22
|
+
|| p.description?.toLowerCase().includes(q),
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Fetch markdown for selected page
|
|
27
|
+
const { data: markdownContent, status: mdStatus } = useAsyncData('page-markdown', async () => {
|
|
28
|
+
if (!appFetch.value || !selectedRoute.value)
|
|
29
|
+
return null
|
|
30
|
+
try {
|
|
31
|
+
const mdRoute = selectedRoute.value === '/' ? '/index.md' : `${selectedRoute.value}.md`
|
|
32
|
+
return await appFetch.value(mdRoute, { responseType: 'text' }) as string
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}, {
|
|
38
|
+
watch: [selectedRoute, appFetch],
|
|
39
|
+
})
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<div class="space-y-4">
|
|
44
|
+
<!-- Dev mode empty state -->
|
|
45
|
+
<template v-if="isDev && !isProductionMode">
|
|
46
|
+
<DevtoolsEmptyState
|
|
47
|
+
icon="carbon:list"
|
|
48
|
+
title="No pages in development"
|
|
49
|
+
description="Pages are indexed during prerendering or via runtime sync. The database is empty in dev mode."
|
|
50
|
+
>
|
|
51
|
+
<div class="mt-4 space-y-2">
|
|
52
|
+
<button
|
|
53
|
+
v-if="hasProductionUrl"
|
|
54
|
+
type="button"
|
|
55
|
+
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-[var(--seo-green)] text-white hover:opacity-90 transition-opacity cursor-pointer"
|
|
56
|
+
@click="previewSource = 'production'"
|
|
57
|
+
>
|
|
58
|
+
<UIcon name="carbon:cloud" class="w-3.5 h-3.5" />
|
|
59
|
+
Switch to Production
|
|
60
|
+
</button>
|
|
61
|
+
<p class="text-xs text-[var(--color-text-muted)]">
|
|
62
|
+
Or run <code class="px-1 py-0.5 rounded bg-[var(--color-surface-elevated)]">nuxi generate</code> to populate page data.
|
|
63
|
+
</p>
|
|
64
|
+
</div>
|
|
65
|
+
</DevtoolsEmptyState>
|
|
66
|
+
</template>
|
|
67
|
+
|
|
68
|
+
<!-- Page browser (production mode or has pages) -->
|
|
69
|
+
<template v-else>
|
|
70
|
+
<!-- Stats -->
|
|
71
|
+
<div v-if="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
72
|
+
<DevtoolsMetric
|
|
73
|
+
label="Total Pages"
|
|
74
|
+
:value="stats.total"
|
|
75
|
+
icon="carbon:document-multiple"
|
|
76
|
+
/>
|
|
77
|
+
<DevtoolsMetric
|
|
78
|
+
label="Indexed"
|
|
79
|
+
:value="stats.indexed"
|
|
80
|
+
icon="carbon:checkmark-filled"
|
|
81
|
+
variant="success"
|
|
82
|
+
/>
|
|
83
|
+
<DevtoolsMetric
|
|
84
|
+
label="Pending"
|
|
85
|
+
:value="stats.pending"
|
|
86
|
+
icon="carbon:time"
|
|
87
|
+
:variant="stats.pending > 0 ? 'warning' : 'default'"
|
|
88
|
+
/>
|
|
89
|
+
<DevtoolsMetric
|
|
90
|
+
label="Errors"
|
|
91
|
+
:value="stats.errors"
|
|
92
|
+
icon="carbon:warning-alt"
|
|
93
|
+
:variant="stats.errors > 0 ? 'danger' : 'default'"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<!-- Search -->
|
|
98
|
+
<div class="flex items-center gap-2">
|
|
99
|
+
<div class="relative flex-1">
|
|
100
|
+
<UIcon name="carbon:search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-text-muted)]" />
|
|
101
|
+
<input
|
|
102
|
+
v-model="search"
|
|
103
|
+
type="text"
|
|
104
|
+
placeholder="Search pages..."
|
|
105
|
+
class="w-full pl-9 pr-3 py-2 text-sm rounded-lg bg-[var(--color-surface-sunken)] border border-[var(--color-border)] text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] outline-none focus:border-[var(--seo-green)] transition-colors"
|
|
106
|
+
>
|
|
107
|
+
</div>
|
|
108
|
+
<span class="text-xs text-[var(--color-text-muted)] shrink-0">
|
|
109
|
+
{{ filteredPages.length }} page{{ filteredPages.length === 1 ? '' : 's' }}
|
|
110
|
+
</span>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<!-- Page list -->
|
|
114
|
+
<div v-if="filteredPages.length" class="space-y-1">
|
|
115
|
+
<button
|
|
116
|
+
v-for="page in filteredPages"
|
|
117
|
+
:key="page.route"
|
|
118
|
+
type="button"
|
|
119
|
+
class="w-full text-left p-3 rounded-lg border transition-all cursor-pointer"
|
|
120
|
+
:class="selectedRoute === page.route
|
|
121
|
+
? 'bg-[var(--color-surface-elevated)] border-[var(--seo-green)]'
|
|
122
|
+
: 'bg-[var(--color-surface)] border-[var(--color-border)] hover:bg-[var(--color-surface-elevated)]'"
|
|
123
|
+
@click="selectedRoute = selectedRoute === page.route ? null : page.route"
|
|
124
|
+
>
|
|
125
|
+
<div class="flex items-start justify-between gap-2">
|
|
126
|
+
<div class="min-w-0">
|
|
127
|
+
<div class="text-xs font-mono text-[var(--seo-green)] truncate">
|
|
128
|
+
{{ page.route }}
|
|
129
|
+
</div>
|
|
130
|
+
<div v-if="page.title" class="text-sm font-medium text-[var(--color-text)] truncate mt-0.5">
|
|
131
|
+
{{ page.title }}
|
|
132
|
+
</div>
|
|
133
|
+
<div v-if="page.description" class="text-xs text-[var(--color-text-muted)] line-clamp-2 mt-0.5">
|
|
134
|
+
{{ page.description }}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
<div v-if="page.updatedAt" class="text-[10px] text-[var(--color-text-subtle)] shrink-0">
|
|
138
|
+
{{ new Date(page.updatedAt).toLocaleDateString() }}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<DevtoolsEmptyState
|
|
145
|
+
v-else-if="search"
|
|
146
|
+
icon="carbon:search"
|
|
147
|
+
title="No matching pages"
|
|
148
|
+
:description="`No pages match "${search}"`"
|
|
149
|
+
/>
|
|
150
|
+
|
|
151
|
+
<DevtoolsEmptyState
|
|
152
|
+
v-else
|
|
153
|
+
icon="carbon:list"
|
|
154
|
+
title="No pages indexed"
|
|
155
|
+
description="The production site has no indexed pages yet. Ensure prerendering has run."
|
|
156
|
+
/>
|
|
157
|
+
|
|
158
|
+
<!-- Markdown preview panel -->
|
|
159
|
+
<DevtoolsPanel
|
|
160
|
+
v-if="selectedRoute"
|
|
161
|
+
:title="`${selectedRoute}.md`"
|
|
162
|
+
icon="carbon:document"
|
|
163
|
+
>
|
|
164
|
+
<div v-if="mdStatus === 'pending'" class="py-4">
|
|
165
|
+
<DevtoolsLoading />
|
|
166
|
+
</div>
|
|
167
|
+
<div v-else-if="markdownContent" class="relative">
|
|
168
|
+
<DevtoolsCopyButton :text="markdownContent" class="absolute top-2 right-2 z-10" />
|
|
169
|
+
<pre class="text-xs font-mono whitespace-pre-wrap p-4 rounded-lg bg-[var(--color-surface-sunken)] text-[var(--color-text)] overflow-auto max-h-[500px]">{{ markdownContent }}</pre>
|
|
170
|
+
</div>
|
|
171
|
+
<p v-else class="text-xs text-[var(--color-text-muted)] p-4">
|
|
172
|
+
Could not load markdown for this route.
|
|
173
|
+
</p>
|
|
174
|
+
</DevtoolsPanel>
|
|
175
|
+
</template>
|
|
176
|
+
</div>
|
|
177
|
+
</template>
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { appFetch } from 'nuxtseo-layer-devtools/composables/rpc'
|
|
3
|
+
import { isProductionMode, refreshTime } from 'nuxtseo-layer-devtools/composables/state'
|
|
4
|
+
import { computed, ref } from 'vue'
|
|
5
|
+
import { useAsyncData } from '#imports'
|
|
6
|
+
import { data as globalData } from '../../lib/ai-ready/state'
|
|
7
|
+
|
|
8
|
+
const isDev = computed(() => globalData?.value?.isDev ?? true)
|
|
9
|
+
const llmsTxtConfig = computed(() => globalData?.value?.llmsTxt)
|
|
10
|
+
|
|
11
|
+
const activeTab = ref<'llms-txt' | 'llms-full'>('llms-txt')
|
|
12
|
+
|
|
13
|
+
// Fetch actual llms.txt content
|
|
14
|
+
const { data: llmsTxtContent } = useAsyncData('llms-txt-content', async () => {
|
|
15
|
+
if (!appFetch.value)
|
|
16
|
+
return null
|
|
17
|
+
try {
|
|
18
|
+
return await appFetch.value('/llms.txt', { responseType: 'text' }) as string
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}, {
|
|
24
|
+
watch: [appFetch, refreshTime],
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const { data: llmsFullContent, status: fullStatus } = useAsyncData('llms-full-content', async () => {
|
|
28
|
+
if (!appFetch.value || activeTab.value !== 'llms-full')
|
|
29
|
+
return null
|
|
30
|
+
try {
|
|
31
|
+
return await appFetch.value('/llms-full.txt', { responseType: 'text' }) as string
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}, {
|
|
37
|
+
watch: [appFetch, refreshTime, activeTab],
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const displayContent = computed(() => {
|
|
41
|
+
if (activeTab.value === 'llms-full')
|
|
42
|
+
return llmsFullContent.value
|
|
43
|
+
return llmsTxtContent.value
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Build a template preview from config (for dev mode)
|
|
47
|
+
const templatePreview = computed(() => {
|
|
48
|
+
const config = llmsTxtConfig.value
|
|
49
|
+
if (!config)
|
|
50
|
+
return ''
|
|
51
|
+
|
|
52
|
+
const lines: string[] = ['# {Site Name}', '', '> {Site Description}', '']
|
|
53
|
+
|
|
54
|
+
for (const section of config.sections || []) {
|
|
55
|
+
lines.push(`## ${section.title}`)
|
|
56
|
+
if (section.description) {
|
|
57
|
+
const descs = Array.isArray(section.description) ? section.description : [section.description]
|
|
58
|
+
for (const d of descs)
|
|
59
|
+
lines.push('', d)
|
|
60
|
+
}
|
|
61
|
+
for (const link of section.links || []) {
|
|
62
|
+
const desc = link.description ? `: ${link.description}` : ''
|
|
63
|
+
lines.push(`- [${link.title}](${link.href})${desc}`)
|
|
64
|
+
}
|
|
65
|
+
lines.push('')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (config.notes) {
|
|
69
|
+
const notes = Array.isArray(config.notes) ? config.notes : [config.notes]
|
|
70
|
+
for (const note of notes)
|
|
71
|
+
lines.push(note)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return lines.join('\n')
|
|
75
|
+
})
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<div class="space-y-4">
|
|
80
|
+
<!-- Tab switcher -->
|
|
81
|
+
<div class="flex items-center gap-2">
|
|
82
|
+
<button
|
|
83
|
+
v-for="tab in [{ key: 'llms-txt', label: 'llms.txt' }, { key: 'llms-full', label: 'llms-full.txt' }]"
|
|
84
|
+
:key="tab.key"
|
|
85
|
+
type="button"
|
|
86
|
+
class="px-3 py-1.5 text-xs font-medium rounded-md transition-all cursor-pointer"
|
|
87
|
+
:class="activeTab === tab.key
|
|
88
|
+
? 'bg-[var(--seo-green)] text-white'
|
|
89
|
+
: 'bg-[var(--color-surface-elevated)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'"
|
|
90
|
+
@click="activeTab = tab.key as any"
|
|
91
|
+
>
|
|
92
|
+
{{ tab.label }}
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<!-- Dev mode: template preview hint -->
|
|
97
|
+
<DevtoolsAlert
|
|
98
|
+
v-if="isDev && !isProductionMode"
|
|
99
|
+
variant="info"
|
|
100
|
+
>
|
|
101
|
+
<p class="font-medium mb-1">
|
|
102
|
+
Template Preview
|
|
103
|
+
</p>
|
|
104
|
+
<p class="text-sm opacity-80">
|
|
105
|
+
This shows the llms.txt structure from your config. Actual content with page data is generated during prerendering.
|
|
106
|
+
Switch to production mode to see live content.
|
|
107
|
+
</p>
|
|
108
|
+
</DevtoolsAlert>
|
|
109
|
+
|
|
110
|
+
<!-- Content display -->
|
|
111
|
+
<DevtoolsPanel :title="activeTab === 'llms-txt' ? 'llms.txt' : 'llms-full.txt'">
|
|
112
|
+
<!-- Loading state for llms-full.txt -->
|
|
113
|
+
<div v-if="activeTab === 'llms-full' && fullStatus === 'pending'" class="py-8 text-center">
|
|
114
|
+
<DevtoolsLoading />
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<!-- Show content or template -->
|
|
118
|
+
<template v-else>
|
|
119
|
+
<div v-if="displayContent" class="relative">
|
|
120
|
+
<DevtoolsCopyButton :text="displayContent" class="absolute top-2 right-2 z-10" />
|
|
121
|
+
<pre class="text-xs font-mono whitespace-pre-wrap p-4 rounded-lg bg-[var(--color-surface-sunken)] text-[var(--color-text)] overflow-auto max-h-[600px]">{{ displayContent }}</pre>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<!-- Fallback: show template preview in dev -->
|
|
125
|
+
<div v-else-if="isDev && templatePreview" class="relative">
|
|
126
|
+
<pre class="text-xs font-mono whitespace-pre-wrap p-4 rounded-lg bg-[var(--color-surface-sunken)] text-[var(--color-text-muted)] overflow-auto max-h-[600px]">{{ templatePreview }}</pre>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<DevtoolsEmptyState
|
|
130
|
+
v-else
|
|
131
|
+
icon="carbon:document"
|
|
132
|
+
title="No content available"
|
|
133
|
+
description="llms.txt content is generated during prerendering. Run `nuxi generate` or switch to production mode."
|
|
134
|
+
/>
|
|
135
|
+
</template>
|
|
136
|
+
</DevtoolsPanel>
|
|
137
|
+
|
|
138
|
+
<!-- llms.txt Structure (always visible) -->
|
|
139
|
+
<DevtoolsSection text="Configured Sections" icon="carbon:list-boxes">
|
|
140
|
+
<div v-if="llmsTxtConfig?.sections?.length" class="space-y-3">
|
|
141
|
+
<div
|
|
142
|
+
v-for="(section, i) in llmsTxtConfig.sections"
|
|
143
|
+
:key="i"
|
|
144
|
+
class="p-3 rounded-lg bg-[var(--color-surface-elevated)] border border-[var(--color-border)]"
|
|
145
|
+
>
|
|
146
|
+
<h4 class="text-sm font-semibold text-[var(--color-text)] mb-1">
|
|
147
|
+
{{ section.title }}
|
|
148
|
+
</h4>
|
|
149
|
+
<div v-if="section.links?.length" class="space-y-1 mt-2">
|
|
150
|
+
<div
|
|
151
|
+
v-for="(link, j) in section.links"
|
|
152
|
+
:key="j"
|
|
153
|
+
class="flex items-start gap-2 text-xs"
|
|
154
|
+
>
|
|
155
|
+
<UIcon name="carbon:link" class="w-3 h-3 mt-0.5 text-[var(--color-text-muted)]" />
|
|
156
|
+
<div>
|
|
157
|
+
<span class="font-medium text-[var(--color-text)]">{{ link.title }}</span>
|
|
158
|
+
<span v-if="link.description" class="text-[var(--color-text-muted)]">, {{ link.description }}</span>
|
|
159
|
+
<div class="font-mono text-[var(--color-text-subtle)]">
|
|
160
|
+
{{ link.href }}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
<p v-else class="text-xs text-[var(--color-text-muted)]">
|
|
168
|
+
No custom sections configured.
|
|
169
|
+
</p>
|
|
170
|
+
</DevtoolsSection>
|
|
171
|
+
</div>
|
|
172
|
+
</template>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { isProductionMode } from 'nuxtseo-layer-devtools/composables/state'
|
|
3
|
+
import { computed, watch } from 'vue'
|
|
4
|
+
import { navigateTo, useRoute } from '#imports'
|
|
5
|
+
import { data, loading, refreshSources } from '../lib/ai-ready/state'
|
|
6
|
+
import '../lib/ai-ready/rpc'
|
|
7
|
+
|
|
8
|
+
const route = useRoute()
|
|
9
|
+
const currentTab = computed(() => {
|
|
10
|
+
const path = route.path
|
|
11
|
+
if (path === '/ai-ready/llms-txt')
|
|
12
|
+
return 'llms-txt'
|
|
13
|
+
if (path === '/ai-ready/debug')
|
|
14
|
+
return 'debug'
|
|
15
|
+
if (path === '/ai-ready/docs')
|
|
16
|
+
return 'docs'
|
|
17
|
+
return 'pages'
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const navItems = [
|
|
21
|
+
{ value: 'pages', to: '/ai-ready', icon: 'carbon:list', label: 'Pages', devOnly: false },
|
|
22
|
+
{ value: 'llms-txt', to: '/ai-ready/llms-txt', icon: 'carbon:document', label: 'llms.txt', devOnly: false },
|
|
23
|
+
{ value: 'debug', to: '/ai-ready/debug', icon: 'carbon:debug', label: 'Debug', devOnly: true },
|
|
24
|
+
{ value: 'docs', to: '/ai-ready/docs', icon: 'carbon:book', label: 'Docs', devOnly: false },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
const runtimeVersion = computed(() => data.value?.version || 'unknown')
|
|
28
|
+
|
|
29
|
+
// Debug data is dev-only; leave the debug tab when the header switches to Production
|
|
30
|
+
watch(isProductionMode, (isProd) => {
|
|
31
|
+
if (isProd && currentTab.value === 'debug')
|
|
32
|
+
return navigateTo('/ai-ready')
|
|
33
|
+
})
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<DevtoolsLayout
|
|
38
|
+
module-name="nuxt-ai-ready"
|
|
39
|
+
title="AI Ready"
|
|
40
|
+
icon="carbon:bot"
|
|
41
|
+
:version="runtimeVersion"
|
|
42
|
+
:nav-items="navItems"
|
|
43
|
+
github-url="https://github.com/harlan-zw/nuxt-ai-ready"
|
|
44
|
+
:loading="loading"
|
|
45
|
+
:active-tab="currentTab"
|
|
46
|
+
@refresh="refreshSources"
|
|
47
|
+
>
|
|
48
|
+
<NuxtPage />
|
|
49
|
+
</DevtoolsLayout>
|
|
50
|
+
</template>
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -675,11 +675,13 @@ const module$1 = defineNuxtModule({
|
|
|
675
675
|
nuxt.options.robots = robotsOpts;
|
|
676
676
|
const groups = robotsOpts.groups || [];
|
|
677
677
|
robotsOpts.groups = groups;
|
|
678
|
-
|
|
678
|
+
const group = {
|
|
679
679
|
userAgent: "*",
|
|
680
|
-
contentUsage: [`train-ai=${config.contentSignal.aiTrain ? "y" : "n"}`],
|
|
681
680
|
contentSignal: [`ai-train=${config.contentSignal.aiTrain ? "yes" : "no"}`, `search=${config.contentSignal.search ? "yes" : "no"}`, `ai-input=${config.contentSignal.aiInput ? "yes" : "no"}`]
|
|
682
|
-
}
|
|
681
|
+
};
|
|
682
|
+
if (config.contentSignal.contentUsage !== false)
|
|
683
|
+
group.contentUsage = [`train-ai=${config.contentSignal.aiTrain ? "y" : "n"}`];
|
|
684
|
+
groups.push(group);
|
|
683
685
|
}
|
|
684
686
|
registerTypeTemplates();
|
|
685
687
|
const defaultLlmsTxtSections = [];
|
|
@@ -957,7 +959,7 @@ export async function lookupContentPage(event, path) {
|
|
|
957
959
|
});
|
|
958
960
|
addServerHandler({ route: "/llms.txt", handler: resolve("./runtime/server/routes/llms.txt.get") });
|
|
959
961
|
addServerHandler({ route: "/llms-full.txt", handler: resolve("./runtime/server/routes/llms-full.txt.get") });
|
|
960
|
-
addServerHandler({ route: "/__ai-
|
|
962
|
+
addServerHandler({ route: "/__ai-ready__/debug.json", handler: resolve("./runtime/server/routes/__ai-ready/devtools.get") });
|
|
961
963
|
if (config.debug) {
|
|
962
964
|
addServerHandler({ route: "/__ai-ready-debug", handler: resolve("./runtime/server/routes/__ai-ready-debug.get") });
|
|
963
965
|
}
|
|
@@ -4,9 +4,14 @@ import { useRuntimeConfig } from "nitropack/runtime";
|
|
|
4
4
|
import * as schema from "#ai-ready-virtual/db-schema.mjs";
|
|
5
5
|
import { logger } from "../../../logger.js";
|
|
6
6
|
import { registerDriver } from "../raw.js";
|
|
7
|
+
import { resolveWritableDbPath } from "./dbPath.js";
|
|
7
8
|
export async function createClient(event) {
|
|
8
9
|
const config = useRuntimeConfig(event)["nuxt-ai-ready"];
|
|
9
|
-
|
|
10
|
+
let dbUrl = config.database.url || `file:${config.database.filename || ".data/ai-ready/pages.db"}`;
|
|
11
|
+
if (dbUrl.startsWith("file:") && !dbUrl.startsWith("file://")) {
|
|
12
|
+
const resolved = await resolveWritableDbPath(dbUrl.slice("file:".length));
|
|
13
|
+
dbUrl = `file:${resolved}`;
|
|
14
|
+
}
|
|
10
15
|
logger.debug(`[drizzle] Connecting to LibSQL: ${dbUrl}`);
|
|
11
16
|
const client = createLibSQLClient({ url: dbUrl, authToken: config.database.authToken });
|
|
12
17
|
const db = drizzle(client, { schema });
|
|
@@ -9,14 +9,14 @@ import { buildFrontmatter } from "../utils/frontmatter.js";
|
|
|
9
9
|
import { computeLocaleAlternates, resolveLocaleFromRoute } from "../utils/i18n.js";
|
|
10
10
|
import { buildLinkHeader } from "../utils/link-header.js";
|
|
11
11
|
const INTERNAL_HEADER = "x-ai-ready-internal";
|
|
12
|
-
function setNegotiationHeaders(event, path, config) {
|
|
12
|
+
function setNegotiationHeaders(event, path, config, resolveUrl) {
|
|
13
13
|
setHeader(event, "vary", "Accept, Sec-Fetch-Dest");
|
|
14
|
-
setHeader(event, "link", buildLinkHeader(path, "html", config));
|
|
14
|
+
setHeader(event, "link", buildLinkHeader(path, "html", config, resolveUrl));
|
|
15
15
|
}
|
|
16
|
-
function setMarkdownHeaders(event, path, config) {
|
|
16
|
+
function setMarkdownHeaders(event, path, config, resolveUrl) {
|
|
17
17
|
setHeader(event, "content-type", "text/markdown; charset=utf-8");
|
|
18
18
|
setHeader(event, "vary", "Accept, Sec-Fetch-Dest");
|
|
19
|
-
setHeader(event, "link", buildLinkHeader(path, "markdown", config));
|
|
19
|
+
setHeader(event, "link", buildLinkHeader(path, "markdown", config, resolveUrl));
|
|
20
20
|
if (config.markdownCacheHeaders) {
|
|
21
21
|
const { maxAge, swr } = config.markdownCacheHeaders;
|
|
22
22
|
const cacheControl = swr ? `public, max-age=${maxAge}, stale-while-revalidate=${maxAge}` : `public, max-age=${maxAge}`;
|
|
@@ -65,9 +65,10 @@ export default defineEventHandler(async (event) => {
|
|
|
65
65
|
}
|
|
66
66
|
const { path, isExplicit, negotiation } = renderInfo;
|
|
67
67
|
const config = useRuntimeConfig(event)["nuxt-ai-ready"];
|
|
68
|
-
const
|
|
68
|
+
const resolveUrl = (path2) => withSiteUrl(event, path2);
|
|
69
|
+
const canonicalUrl = resolveUrl(path);
|
|
69
70
|
if (negotiation === "html") {
|
|
70
|
-
setNegotiationHeaders(event, path, config);
|
|
71
|
+
setNegotiationHeaders(event, path, config, resolveUrl);
|
|
71
72
|
return;
|
|
72
73
|
}
|
|
73
74
|
if (!isExplicit) {
|
|
@@ -85,7 +86,7 @@ export default defineEventHandler(async (event) => {
|
|
|
85
86
|
canonical_url: canonicalUrl,
|
|
86
87
|
last_updated: contentPage.updatedAt || (/* @__PURE__ */ new Date()).toISOString()
|
|
87
88
|
});
|
|
88
|
-
setMarkdownHeaders(event, path, config);
|
|
89
|
+
setMarkdownHeaders(event, path, config, resolveUrl);
|
|
89
90
|
return `${frontmatter}
|
|
90
91
|
${contentPage.markdown}`;
|
|
91
92
|
}
|
|
@@ -98,7 +99,7 @@ ${contentPage.markdown}`;
|
|
|
98
99
|
return null;
|
|
99
100
|
});
|
|
100
101
|
if (!response) {
|
|
101
|
-
setMarkdownHeaders(event, path, config);
|
|
102
|
+
setMarkdownHeaders(event, path, config, resolveUrl);
|
|
102
103
|
return notFoundMarkdown(canonicalUrl, path, config);
|
|
103
104
|
}
|
|
104
105
|
if (response.status >= 300 && response.status < 400) {
|
|
@@ -113,12 +114,12 @@ ${contentPage.markdown}`;
|
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
if (!response.ok) {
|
|
116
|
-
setMarkdownHeaders(event, path, config);
|
|
117
|
+
setMarkdownHeaders(event, path, config, resolveUrl);
|
|
117
118
|
return notFoundMarkdown(canonicalUrl, path, config);
|
|
118
119
|
}
|
|
119
120
|
const contentType = response.headers.get("content-type") || "";
|
|
120
121
|
if (!contentType.includes("text/html")) {
|
|
121
|
-
setMarkdownHeaders(event, path, config);
|
|
122
|
+
setMarkdownHeaders(event, path, config, resolveUrl);
|
|
122
123
|
return notFoundMarkdown(canonicalUrl, path, config);
|
|
123
124
|
}
|
|
124
125
|
const html = await response.text();
|
|
@@ -144,6 +145,6 @@ ${contentPage.markdown}`;
|
|
|
144
145
|
additionalFrontmatter
|
|
145
146
|
}
|
|
146
147
|
);
|
|
147
|
-
setMarkdownHeaders(event, path, config);
|
|
148
|
+
setMarkdownHeaders(event, path, config, resolveUrl);
|
|
148
149
|
return result.markdown;
|
|
149
150
|
});
|
|
@@ -7,7 +7,9 @@ import type { ModulePublicRuntimeConfig } from '../../../module.js';
|
|
|
7
7
|
* `encodeURI` preserves `/` separators and other reserved URL characters.
|
|
8
8
|
*/
|
|
9
9
|
export declare function encodePathForHeader(path: string): string;
|
|
10
|
+
type LinkUrlResolver = (path: string) => string;
|
|
10
11
|
/**
|
|
11
12
|
* Build a comma-joined Link header value with the standard alternates plus i18n hreflang variants.
|
|
12
13
|
*/
|
|
13
|
-
export declare function buildLinkHeader(path: string, variant: 'html' | 'markdown', config: ModulePublicRuntimeConfig): string;
|
|
14
|
+
export declare function buildLinkHeader(path: string, variant: 'html' | 'markdown', config: ModulePublicRuntimeConfig, resolveUrl?: LinkUrlResolver): string;
|
|
15
|
+
export {};
|