nuxt-ai-ready 3.1.0 → 3.3.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/dist/chunks/agent-skills.mjs +2 -1
- package/dist/chunks/prerender.mjs +196 -29
- package/dist/module.d.mts +1 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +2 -1
- package/dist/runtime/llms-txt-directive.d.ts +15 -0
- package/dist/runtime/llms-txt-directive.js +23 -0
- package/dist/runtime/llms-txt-files.d.ts +30 -0
- package/dist/runtime/llms-txt-files.js +141 -0
- package/dist/runtime/llms-txt-utils.d.ts +3 -2
- package/dist/runtime/llms-txt-utils.js +85 -221
- package/dist/runtime/markdown-links.d.ts +10 -0
- package/dist/runtime/markdown-links.js +52 -0
- package/dist/runtime/server/db/queries.d.ts +3 -0
- package/dist/runtime/server/db/queries.js +28 -2
- package/dist/runtime/server/middleware/markdown.js +9 -2
- package/dist/runtime/server/middleware/markdown.prerender.js +2 -1
- package/dist/runtime/server/plugins/llms-txt-directive-html.d.ts +3 -0
- package/dist/runtime/server/plugins/llms-txt-directive-html.js +8 -0
- package/dist/runtime/server/routes/llms-full.txt.get.js +2 -1
- package/dist/runtime/server/routes/llms.txt.get.js +19 -11
- package/dist/runtime/server/routes/sitemap.md.get.js +6 -3
- package/dist/runtime/server/utils/markdown-request.js +2 -5
- package/dist/runtime/server/utils/sitemap.d.ts +1 -0
- package/dist/runtime/server/utils/sitemap.js +22 -10
- package/dist/runtime/server/utils.js +2 -1
- package/dist/runtime/types.d.ts +30 -2
- package/dist/shared/{nuxt-ai-ready.Dmvy7CIJ.mjs → nuxt-ai-ready.BwnSMqRn.mjs} +94 -28
- package/package.json +1 -1
- package/skills/nuxt-ai-ready/SKILL.md +37 -2
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
import { decodePath } from "ufo";
|
|
2
|
-
import { getRequestHost } from "#nuxtseo/h3";
|
|
2
|
+
import { createError, getRequestHost, getRequestURL } from "#nuxtseo/h3";
|
|
3
3
|
import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
4
4
|
import { getSiteConfig } from "#site-config/server/composables/getSiteConfig";
|
|
5
5
|
import { withSiteTrailingSlash, withSiteUrl } from "#site-config/server/composables/utils";
|
|
6
|
-
import {
|
|
6
|
+
import { createLlmsTxtFiles } from "./llms-txt-files.js";
|
|
7
7
|
import { hasMarkdownTwin, toMarkdownPath } from "./markdown-path.js";
|
|
8
8
|
import { normalizePersistedRoute, toDeployedRoute, toLogicalRoute } from "./route-path.js";
|
|
9
|
-
import { queryPages } from "./server/db/queries.js";
|
|
9
|
+
import { queryPageMetadata, queryPages } from "./server/db/queries.js";
|
|
10
10
|
import { logger } from "./server/logger.js";
|
|
11
|
-
import {
|
|
12
|
-
export { normalizeLlmsTxtConfig };
|
|
11
|
+
import { fetchSitemapUrlGroups, getSitemapsFromConfig } from "./server/utils/sitemap.js";
|
|
12
|
+
export { normalizeLlmsTxtConfig } from "./llms-txt-format.js";
|
|
13
13
|
async function findMarkdownLinkAvailability() {
|
|
14
|
-
if (!import.meta.prerender)
|
|
15
|
-
return {
|
|
16
|
-
runtimeMarkdownAvailable: true,
|
|
17
|
-
paths: /* @__PURE__ */ new Set()
|
|
18
|
-
};
|
|
19
|
-
}
|
|
14
|
+
if (!import.meta.prerender)
|
|
15
|
+
return { runtimeMarkdownAvailable: true, paths: /* @__PURE__ */ new Set() };
|
|
20
16
|
const { readMarkdownLinkAvailabilityFromFilesystem } = await import("#ai-ready-virtual/read-page-data.mjs");
|
|
21
17
|
const availability = await readMarkdownLinkAvailabilityFromFilesystem();
|
|
22
18
|
return {
|
|
@@ -24,229 +20,97 @@ async function findMarkdownLinkAvailability() {
|
|
|
24
20
|
paths: new Set(availability.paths.map(decodePath))
|
|
25
21
|
};
|
|
26
22
|
}
|
|
27
|
-
function
|
|
28
|
-
const segments = url.split("/").filter(Boolean);
|
|
29
|
-
if (segments.length === 0)
|
|
30
|
-
return "/";
|
|
31
|
-
if (depth === 1 || segments.length === 1)
|
|
32
|
-
return `/${segments[0]}`;
|
|
33
|
-
return `/${segments[0]}/${segments[1]}`;
|
|
34
|
-
}
|
|
35
|
-
function getPathSegments(pathname) {
|
|
36
|
-
return pathname.split("/").filter((s) => Boolean(s));
|
|
37
|
-
}
|
|
38
|
-
function analyzePageGroups(pages) {
|
|
39
|
-
const twoSegmentCount = /* @__PURE__ */ new Map();
|
|
40
|
-
const segmentHasNested = /* @__PURE__ */ new Map();
|
|
41
|
-
for (const page of pages) {
|
|
42
|
-
const prefix = getGroupPrefix(page.pathname, 2);
|
|
43
|
-
twoSegmentCount.set(prefix, (twoSegmentCount.get(prefix) || 0) + 1);
|
|
44
|
-
const segments = getPathSegments(page.pathname);
|
|
45
|
-
const firstSegment = segments[0] || "";
|
|
46
|
-
if (!segmentHasNested.has(firstSegment))
|
|
47
|
-
segmentHasNested.set(firstSegment, false);
|
|
48
|
-
if (segments.length > 1)
|
|
49
|
-
segmentHasNested.set(firstSegment, true);
|
|
50
|
-
}
|
|
51
|
-
return { twoSegmentCount, segmentHasNested };
|
|
52
|
-
}
|
|
53
|
-
function getPageGroupKey(pathname, { twoSegmentCount, segmentHasNested }) {
|
|
54
|
-
const segments = getPathSegments(pathname);
|
|
55
|
-
const firstSegment = segments[0] || "";
|
|
56
|
-
const twoSegPrefix = getGroupPrefix(pathname, 2);
|
|
57
|
-
const twoSegCount = twoSegmentCount.get(twoSegPrefix) || 0;
|
|
58
|
-
let groupKey = twoSegCount > 1 ? twoSegPrefix : `/${firstSegment}`;
|
|
59
|
-
if (segments.length <= 1 && !segmentHasNested.get(firstSegment))
|
|
60
|
-
groupKey = "";
|
|
61
|
-
return groupKey;
|
|
62
|
-
}
|
|
63
|
-
function sortPagesByPath(pages) {
|
|
64
|
-
const analysis = analyzePageGroups(pages);
|
|
65
|
-
return pages.sort((a, b) => {
|
|
66
|
-
const segmentsA = getPathSegments(a.pathname);
|
|
67
|
-
const segmentsB = getPathSegments(b.pathname);
|
|
68
|
-
const groupKeyA = getPageGroupKey(a.pathname, analysis);
|
|
69
|
-
const groupKeyB = getPageGroupKey(b.pathname, analysis);
|
|
70
|
-
if (groupKeyA === "" && groupKeyB !== "")
|
|
71
|
-
return -1;
|
|
72
|
-
if (groupKeyA !== "" && groupKeyB === "")
|
|
73
|
-
return 1;
|
|
74
|
-
if (groupKeyA !== groupKeyB)
|
|
75
|
-
return groupKeyA.localeCompare(groupKeyB);
|
|
76
|
-
if (segmentsA.length === 0)
|
|
77
|
-
return -1;
|
|
78
|
-
if (segmentsB.length === 0)
|
|
79
|
-
return 1;
|
|
80
|
-
const minLen = Math.min(segmentsA.length, segmentsB.length);
|
|
81
|
-
for (let i = 0; i < minLen; i++) {
|
|
82
|
-
const cmp = segmentsA[i].localeCompare(segmentsB[i]);
|
|
83
|
-
if (cmp !== 0)
|
|
84
|
-
return cmp;
|
|
85
|
-
}
|
|
86
|
-
return segmentsA.length - segmentsB.length;
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
function formatPagesWithGroups(pages) {
|
|
90
|
-
if (pages.length === 0)
|
|
91
|
-
return [];
|
|
92
|
-
const analysis = analyzePageGroups(pages);
|
|
93
|
-
const lines = [];
|
|
94
|
-
let currentGroup = "";
|
|
95
|
-
let segmentGroupIndex = 0;
|
|
96
|
-
let urlsInCurrentGroup = 0;
|
|
97
|
-
for (const page of pages) {
|
|
98
|
-
const groupKey = getPageGroupKey(page.pathname, analysis);
|
|
99
|
-
if (groupKey !== currentGroup) {
|
|
100
|
-
if (urlsInCurrentGroup > 0) {
|
|
101
|
-
const shouldAddBlankLine = segmentGroupIndex === 0 || segmentGroupIndex >= 1 && segmentGroupIndex <= 2 && urlsInCurrentGroup > 1;
|
|
102
|
-
if (shouldAddBlankLine)
|
|
103
|
-
lines.push("");
|
|
104
|
-
}
|
|
105
|
-
currentGroup = groupKey;
|
|
106
|
-
segmentGroupIndex++;
|
|
107
|
-
urlsInCurrentGroup = 0;
|
|
108
|
-
}
|
|
109
|
-
urlsInCurrentGroup++;
|
|
110
|
-
lines.push(formatLlmsTxtPageLink(page));
|
|
111
|
-
}
|
|
112
|
-
return lines;
|
|
113
|
-
}
|
|
114
|
-
export async function buildLlmsTxt(event) {
|
|
23
|
+
export async function buildLlmsTxtFiles(event) {
|
|
115
24
|
const runtimeConfig = useRuntimeConfig(event);
|
|
116
25
|
const aiReadyConfig = runtimeConfig["nuxt-ai-ready"];
|
|
117
|
-
const sitemapConfig = runtimeConfig.sitemap;
|
|
118
26
|
const siteConfig = getSiteConfig(event);
|
|
119
|
-
const
|
|
120
|
-
const i18n = aiReadyConfig.i18n;
|
|
121
|
-
const i18nRuntime = i18n ? { _tag: "enabled", config: i18n, ...await import("./llms-txt-i18n.js") } : { _tag: "disabled" };
|
|
122
|
-
const i18nContext = { host: getRequestHost(event, { xForwardedHost: true }) };
|
|
27
|
+
const config = aiReadyConfig.llmsTxt;
|
|
123
28
|
const baseURL = runtimeConfig.app.baseURL;
|
|
124
|
-
const resolvePath = (path) => withSiteTrailingSlash(event, toDeployedRoute(path, baseURL));
|
|
125
29
|
const resolveUrl = (path) => withSiteUrl(event, toDeployedRoute(path, baseURL), { withBase: true });
|
|
30
|
+
const resolvePageUrl = (path) => resolveUrl(withSiteTrailingSlash(event, path));
|
|
126
31
|
const canonicalSiteUrl = siteConfig.url ? resolveUrl("/") : void 0;
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (canonicalSiteUrl)
|
|
134
|
-
parts.push(`
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
})) ?? [];
|
|
142
|
-
if (sections[0]?.links) {
|
|
143
|
-
const sitemapRoutes = sitemapConfig?.sitemaps ? Object.values(sitemapConfig.sitemaps).map((s) => s.sitemapName) : ["sitemap.xml"];
|
|
144
|
-
for (const name of sitemapRoutes) {
|
|
145
|
-
sections[0].links.push({
|
|
146
|
-
title: name,
|
|
147
|
-
href: resolveUrl(`/${name}`),
|
|
148
|
-
description: "XML sitemap for search engines and crawlers."
|
|
149
|
-
});
|
|
32
|
+
const i18n = aiReadyConfig.i18n;
|
|
33
|
+
const i18nRuntime = i18n ? { _tag: "enabled", config: i18n, ...await import("./llms-txt-i18n.js") } : { _tag: "disabled" };
|
|
34
|
+
const i18nContext = { host: getRequestHost(event, { xForwardedHost: true }) };
|
|
35
|
+
const parts = [`# ${(siteConfig.name || canonicalSiteUrl || "Site").replace(/\s+/g, " ").trim()}`];
|
|
36
|
+
if (siteConfig.description)
|
|
37
|
+
parts.push("", `> ${siteConfig.description.replace(/\s+/g, " ").trim()}`);
|
|
38
|
+
if (canonicalSiteUrl)
|
|
39
|
+
parts.push("", `Canonical Origin: ${canonicalSiteUrl}`);
|
|
40
|
+
const sections = config.sections?.map((section) => ({ ...section, links: section.links ? [...section.links] : void 0 })) ?? [];
|
|
41
|
+
const resources = sections.find((section) => section.title === "LLM Resources" && !section.sitemap);
|
|
42
|
+
if (resources) {
|
|
43
|
+
resources.links ||= [];
|
|
44
|
+
for (const sitemap of getSitemapsFromConfig(event).filter((sitemap2) => config.sitemaps === void 0 || config.sitemaps.includes(sitemap2.name))) {
|
|
45
|
+
resources.links.push({ title: sitemap.route.split("/").pop() || `${sitemap.name}.xml`, href: resolveUrl(toLogicalRoute(sitemap.route, baseURL)), description: "XML sitemap for search engines and crawlers." });
|
|
150
46
|
}
|
|
151
|
-
|
|
152
|
-
title: "
|
|
153
|
-
|
|
154
|
-
description: "Crawler rules and permissions."
|
|
155
|
-
});
|
|
47
|
+
if (!getSitemapsFromConfig(event).length && (config.sitemaps === void 0 || config.sitemaps.includes("sitemap")))
|
|
48
|
+
resources.links.push({ title: "sitemap.xml", href: resolveUrl("/sitemap.xml"), description: "XML sitemap for search engines and crawlers." });
|
|
49
|
+
resources.links.push({ title: "robots.txt", href: resolveUrl("/robots.txt"), description: "Crawler rules and permissions." });
|
|
156
50
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
51
|
+
if (config.pages === false && !sections.some((section) => section.sitemap))
|
|
52
|
+
return Object.fromEntries(createLlmsTxtFiles({ header: parts.join("\n"), config, sections, pages: [], sitemapPages: /* @__PURE__ */ new Map(), resolveUrl }));
|
|
53
|
+
const groups = await fetchSitemapUrlGroups(event, config.sitemaps);
|
|
54
|
+
const allowedOrigin = canonicalSiteUrl ? new URL(canonicalSiteUrl).origin : void 0;
|
|
55
|
+
const requestOrigin = getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).origin;
|
|
56
|
+
const sitemapPaths = /* @__PURE__ */ new Map();
|
|
57
|
+
for (const [name, urls] of groups) {
|
|
58
|
+
const paths = /* @__PURE__ */ new Set();
|
|
59
|
+
for (const entry of urls) {
|
|
60
|
+
const url = URL.canParse(entry.loc, canonicalSiteUrl || "http://localhost") ? new URL(entry.loc, canonicalSiteUrl || "http://localhost") : void 0;
|
|
61
|
+
if (!url || url.search || url.hash || allowedOrigin && url.origin !== allowedOrigin && url.origin !== requestOrigin)
|
|
62
|
+
continue;
|
|
63
|
+
const path = toLogicalRoute(url.pathname, baseURL);
|
|
64
|
+
paths.add(path);
|
|
65
|
+
}
|
|
66
|
+
sitemapPaths.set(name, paths);
|
|
161
67
|
}
|
|
162
|
-
const
|
|
163
|
-
let
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
]);
|
|
171
|
-
} catch (err) {
|
|
172
|
-
logger.warn(
|
|
173
|
-
`[ai-ready] Database unavailable for llms.txt, falling back to sitemap-only pages: ${err instanceof Error ? err.message : String(err)}`
|
|
174
|
-
);
|
|
68
|
+
const admitted = new Set([...sitemapPaths.values()].flatMap((paths) => [...paths]));
|
|
69
|
+
let stored = [];
|
|
70
|
+
if (aiReadyConfig.database?._tag !== "Disabled") {
|
|
71
|
+
const read = config.pages === "all" ? Promise.all([queryPages(event), queryPages(event, { where: { hasError: true } })]).then((results) => results.flat()) : queryPageMetadata(event, [...admitted].flatMap((path) => [path, toDeployedRoute(path, baseURL)]));
|
|
72
|
+
stored = await read.catch((error) => {
|
|
73
|
+
logger.warn(`[ai-ready] Database unavailable for llms.txt. Using sitemap metadata: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
return [];
|
|
75
|
+
});
|
|
175
76
|
}
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
77
|
+
const errorPaths = new Set(stored.filter((page) => page.isError).map((page) => normalizePersistedRoute(page.route, admitted, baseURL)));
|
|
78
|
+
const metadata = new Map(stored.map((page) => [normalizePersistedRoute(page.route, admitted, baseURL), page]));
|
|
79
|
+
const availability = config.markdownLinks ? await findMarkdownLinkAvailability() : void 0;
|
|
80
|
+
const href = (path) => {
|
|
81
|
+
const markdownPath = toMarkdownPath(path);
|
|
82
|
+
if (availability && (availability.paths.has(decodePath(toDeployedRoute(markdownPath, baseURL))) || availability.runtimeMarkdownAvailable && hasMarkdownTwin(path)))
|
|
83
|
+
return resolveUrl(markdownPath);
|
|
84
|
+
return resolvePageUrl(path);
|
|
85
|
+
};
|
|
86
|
+
const pageMap = /* @__PURE__ */ new Map();
|
|
87
|
+
const candidates = config.pages === "all" ? /* @__PURE__ */ new Set([...admitted, ...metadata.keys()]) : admitted;
|
|
88
|
+
for (const pathname of [...candidates].sort()) {
|
|
89
|
+
if (errorPaths.has(pathname))
|
|
185
90
|
continue;
|
|
186
|
-
|
|
187
|
-
|
|
91
|
+
const page = metadata.get(pathname);
|
|
92
|
+
const locale = page?.locale || (i18nRuntime._tag === "enabled" ? i18nRuntime.resolveLocaleFromRoute(pathname, i18nRuntime.config, i18nContext).locale : void 0);
|
|
93
|
+
pageMap.set(pathname, { pathname, href: href(pathname), title: page?.title, description: page?.description, locale });
|
|
188
94
|
}
|
|
189
|
-
|
|
190
|
-
const other = [];
|
|
191
|
-
for (const pathname of sitemapPaths) {
|
|
192
|
-
if (!seenPaths.has(pathname) && !errorSet.has(pathname)) {
|
|
193
|
-
const locale = i18nRuntime._tag === "enabled" ? i18nRuntime.resolveLocaleFromRoute(pathname, i18nRuntime.config, i18nContext).locale : void 0;
|
|
194
|
-
other.push({ pathname, locale });
|
|
195
|
-
seenPaths.add(pathname);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
let resolvePageHref = resolvePath;
|
|
199
|
-
if (llmsTxtConfig.markdownLinks) {
|
|
200
|
-
const markdownLinkAvailability = await findMarkdownLinkAvailability();
|
|
201
|
-
resolvePageHref = (pathname) => {
|
|
202
|
-
const deployedPathname = resolvePath(pathname);
|
|
203
|
-
const markdownPath = resolvePath(toMarkdownPath(pathname));
|
|
204
|
-
if (markdownLinkAvailability.paths.has(decodePath(markdownPath)) || markdownLinkAvailability.runtimeMarkdownAvailable && hasMarkdownTwin(pathname)) {
|
|
205
|
-
return markdownPath;
|
|
206
|
-
}
|
|
207
|
-
return deployedPathname;
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
for (const page of [...prerendered, ...other])
|
|
211
|
-
page.href = resolvePageHref(page.pathname);
|
|
95
|
+
let rootContent;
|
|
212
96
|
if (i18nRuntime._tag === "enabled") {
|
|
213
|
-
const
|
|
214
|
-
for (const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
97
|
+
const counts = /* @__PURE__ */ new Map();
|
|
98
|
+
for (const page of pageMap.values())
|
|
99
|
+
counts.set(page.locale, (counts.get(page.locale) || 0) + 1);
|
|
100
|
+
rootContent = i18nRuntime.formatAvailableLanguagesSection(i18nRuntime.config, counts, href, i18nContext).join("\n");
|
|
101
|
+
for (const [path, page] of pageMap) {
|
|
102
|
+
if (page.locale !== i18nRuntime.config.defaultLocale)
|
|
103
|
+
pageMap.delete(path);
|
|
218
104
|
}
|
|
219
|
-
parts.push(...i18nRuntime.formatAvailableLanguagesSection(i18nRuntime.config, pageCounts, resolvePageHref, i18nContext));
|
|
220
|
-
parts.push("");
|
|
221
105
|
}
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (sortedPrerendered.length > 0 && sortedOther.length > 0) {
|
|
233
|
-
parts.push(`## Prerendered Pages${devModeHint}
|
|
234
|
-
`);
|
|
235
|
-
parts.push(...formatPagesWithGroups(sortedPrerendered));
|
|
236
|
-
parts.push("");
|
|
237
|
-
parts.push("## Other Pages\n");
|
|
238
|
-
parts.push(...formatPagesWithGroups(sortedOther));
|
|
239
|
-
parts.push("");
|
|
240
|
-
} else if (sortedPrerendered.length > 0) {
|
|
241
|
-
parts.push(`## Pages${devModeHint}
|
|
242
|
-
`);
|
|
243
|
-
parts.push(...formatPagesWithGroups(sortedPrerendered));
|
|
244
|
-
parts.push("");
|
|
245
|
-
} else if (sortedOther.length > 0) {
|
|
246
|
-
parts.push(`## Pages${devModeHint}
|
|
247
|
-
`);
|
|
248
|
-
parts.push(...formatPagesWithGroups(sortedOther));
|
|
249
|
-
parts.push("");
|
|
250
|
-
}
|
|
251
|
-
return parts.join("\n");
|
|
106
|
+
const sitemapPages = new Map([...sitemapPaths].map(([name, paths]) => [name, [...paths].flatMap((path) => pageMap.has(path) ? [pageMap.get(path)] : [])]));
|
|
107
|
+
return Object.fromEntries(createLlmsTxtFiles({ header: parts.join("\n"), config, sections, rootContent, pages: config.pages === false ? [] : [...pageMap.values()], sitemapPages, resolveUrl }));
|
|
108
|
+
}
|
|
109
|
+
export async function buildLlmsTxt(event) {
|
|
110
|
+
const files = await buildLlmsTxtFiles(event);
|
|
111
|
+
const path = toLogicalRoute(event.path.split("?")[0], useRuntimeConfig(event).app.baseURL);
|
|
112
|
+
const content = files[path];
|
|
113
|
+
if (content === void 0)
|
|
114
|
+
throw createError({ statusCode: 404, statusMessage: "llms.txt file not found" });
|
|
115
|
+
return content;
|
|
252
116
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve root-relative link and image destinations against a site origin.
|
|
3
|
+
*
|
|
4
|
+
* A Markdown page read on its own, by an agent or from `llms-full.txt`, has
|
|
5
|
+
* no page URL to resolve `/docs/api` against, so such a link loses its
|
|
6
|
+
* target. Fenced code and inline code spans keep their text.
|
|
7
|
+
*/
|
|
8
|
+
export declare function absoluteMarkdownLinks(markdown: string, siteUrl: string): string;
|
|
9
|
+
/** A deployed path on the site's origin, or the path itself when no site URL is known. */
|
|
10
|
+
export declare function absoluteSiteUrl(path: string, siteUrl: string | undefined): string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const RE_FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
2
|
+
const RE_INLINE_ROOT_LINK = /\]\((<?)\/(?!\/)/g;
|
|
3
|
+
const RE_REFERENCE_ROOT_LINK = /^( {0,3}\[[^\]]+\]:[\t ]*<?)\/(?!\/)/;
|
|
4
|
+
function closesFence(line, fence) {
|
|
5
|
+
const trimmed = line.trim();
|
|
6
|
+
return trimmed.length >= fence.length && trimmed[0] === fence[0] && [...trimmed].every((char) => char === fence[0]);
|
|
7
|
+
}
|
|
8
|
+
function backtickRun(line, start) {
|
|
9
|
+
let end = start;
|
|
10
|
+
while (line[end] === "`")
|
|
11
|
+
end++;
|
|
12
|
+
return end - start;
|
|
13
|
+
}
|
|
14
|
+
function rewriteOutsideCodeSpans(line, rewrite) {
|
|
15
|
+
let output = "";
|
|
16
|
+
let last = 0;
|
|
17
|
+
let index = line.indexOf("`");
|
|
18
|
+
while (index !== -1) {
|
|
19
|
+
const length = backtickRun(line, index);
|
|
20
|
+
let close = line.indexOf("`", index + length);
|
|
21
|
+
while (close !== -1 && backtickRun(line, close) !== length)
|
|
22
|
+
close = line.indexOf("`", close + backtickRun(line, close));
|
|
23
|
+
if (close === -1) {
|
|
24
|
+
index = line.indexOf("`", index + length);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
output += rewrite(line.slice(last, index)) + line.slice(index, close + length);
|
|
28
|
+
last = close + length;
|
|
29
|
+
index = line.indexOf("`", last);
|
|
30
|
+
}
|
|
31
|
+
return output + rewrite(line.slice(last));
|
|
32
|
+
}
|
|
33
|
+
export function absoluteMarkdownLinks(markdown, siteUrl) {
|
|
34
|
+
const origin = new URL(siteUrl).origin;
|
|
35
|
+
let fence;
|
|
36
|
+
return markdown.split(/(?<=\n)/).map((line) => {
|
|
37
|
+
if (fence) {
|
|
38
|
+
if (closesFence(line, fence))
|
|
39
|
+
fence = void 0;
|
|
40
|
+
return line;
|
|
41
|
+
}
|
|
42
|
+
const opening = line.match(RE_FENCE);
|
|
43
|
+
if (opening) {
|
|
44
|
+
fence = opening[1];
|
|
45
|
+
return line;
|
|
46
|
+
}
|
|
47
|
+
return rewriteOutsideCodeSpans(line, (text) => text.replace(RE_REFERENCE_ROOT_LINK, `$1${origin}/`).replace(RE_INLINE_ROOT_LINK, `]($1${origin}/`));
|
|
48
|
+
}).join("");
|
|
49
|
+
}
|
|
50
|
+
export function absoluteSiteUrl(path, siteUrl) {
|
|
51
|
+
return siteUrl ? new URL(path, new URL(siteUrl).origin).href : path;
|
|
52
|
+
}
|
|
@@ -34,6 +34,9 @@ export interface PageEntry {
|
|
|
34
34
|
export interface PageData extends PageEntry {
|
|
35
35
|
markdown: string;
|
|
36
36
|
}
|
|
37
|
+
export type PageMetadata = Pick<PageEntry, 'route' | 'title' | 'description' | 'isError' | 'locale'>;
|
|
38
|
+
/** Read only admitted page metadata. Each query fits D1's 100 parameter limit. */
|
|
39
|
+
export declare function queryPageMetadata(event: AiReadyDatabaseEvent | undefined, routes: readonly string[]): Promise<PageMetadata[]>;
|
|
37
40
|
export interface SearchResult {
|
|
38
41
|
route: string;
|
|
39
42
|
title: string;
|
|
@@ -80,6 +80,7 @@ async function getPrerenderDb() {
|
|
|
80
80
|
const isErrorQuery = sql.includes("is_error = 1") || params.includes(1) && sql.includes("is_error");
|
|
81
81
|
const excludeErrors = sql.includes("is_error = 0");
|
|
82
82
|
const includeMarkdown = wantsMarkdown(sql);
|
|
83
|
+
const admittedRoutes = sql.includes("WHERE route IN (") ? new Set(params) : void 0;
|
|
83
84
|
if (isErrorQuery) {
|
|
84
85
|
return [...errorRoutes].map((route) => ({
|
|
85
86
|
route,
|
|
@@ -94,8 +95,8 @@ async function getPrerenderDb() {
|
|
|
94
95
|
locale: ""
|
|
95
96
|
}));
|
|
96
97
|
}
|
|
97
|
-
const filtered =
|
|
98
|
-
|
|
98
|
+
const filtered = pages.filter((p) => (!excludeErrors || !errorRoutes.has(p.route)) && (!admittedRoutes || admittedRoutes.has(p.route)));
|
|
99
|
+
const rows = filtered.map((p) => ({
|
|
99
100
|
route: p.route,
|
|
100
101
|
title: p.title,
|
|
101
102
|
description: p.description,
|
|
@@ -107,6 +108,13 @@ async function getPrerenderDb() {
|
|
|
107
108
|
indexed: 1,
|
|
108
109
|
locale: p.locale || ""
|
|
109
110
|
}));
|
|
111
|
+
if (admittedRoutes && !excludeErrors) {
|
|
112
|
+
for (const route of errorRoutes) {
|
|
113
|
+
if (admittedRoutes.has(route))
|
|
114
|
+
rows.push({ route, title: "", description: "", headings: [], keywords: "[]", updated_at: "", is_error: 1, indexed: 0, locale: "" });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return rows;
|
|
110
118
|
},
|
|
111
119
|
first: async (sql, params = []) => {
|
|
112
120
|
if (sql.includes("WHERE route = ?")) {
|
|
@@ -136,6 +144,24 @@ async function getPrerenderDb() {
|
|
|
136
144
|
}
|
|
137
145
|
};
|
|
138
146
|
}
|
|
147
|
+
export async function queryPageMetadata(event, routes) {
|
|
148
|
+
if (!routes.length)
|
|
149
|
+
return [];
|
|
150
|
+
const db = await getDb(event);
|
|
151
|
+
if (!db)
|
|
152
|
+
return [];
|
|
153
|
+
const unique = [...new Set(routes)];
|
|
154
|
+
const pages = [];
|
|
155
|
+
for (let i = 0; i < unique.length; i += 100) {
|
|
156
|
+
const batch = unique.slice(i, i + 100);
|
|
157
|
+
const rows = await db.all(
|
|
158
|
+
`SELECT route, title, description, is_error, locale FROM ai_ready_pages WHERE route IN (${batch.map(() => "?").join(", ")})`,
|
|
159
|
+
batch
|
|
160
|
+
);
|
|
161
|
+
pages.push(...rows.map((row) => ({ route: row.route, title: row.title, description: row.description, isError: row.is_error === 1, locale: row.locale || "" })));
|
|
162
|
+
}
|
|
163
|
+
return pages;
|
|
164
|
+
}
|
|
139
165
|
function buildWhereClause(where) {
|
|
140
166
|
const conditions = [];
|
|
141
167
|
const params = [];
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createError, defineEventHandler, getRequestURL, setHeader, setResponseStatus } from "#nuxtseo/h3";
|
|
2
2
|
import { useNitroApp, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
3
|
import { resolveLocaleAlternateUrl } from "../../i18n-url.js";
|
|
4
|
+
import { insertLlmsTxtDirective } from "../../llms-txt-directive.js";
|
|
5
|
+
import { absoluteMarkdownLinks } from "../../markdown-links.js";
|
|
4
6
|
import { logger } from "../logger.js";
|
|
5
7
|
import { computeLocaleAlternates, resolveLocaleFromRoute } from "../utils/i18n.js";
|
|
6
8
|
import { resolveMarkdownRedirect } from "../utils/markdown-redirect.js";
|
|
@@ -18,7 +20,7 @@ function notFoundMarkdown(ctx, canonicalUrl, build) {
|
|
|
18
20
|
``,
|
|
19
21
|
`- [Sitemap](${resolveUrl("/sitemap.xml")})`,
|
|
20
22
|
`- [llms.txt](${resolveUrl("/llms.txt")})`,
|
|
21
|
-
`- [llms-full.txt](${resolveUrl("/llms-full.txt")})
|
|
23
|
+
...config.llmsFullTxt === false ? [] : [`- [llms-full.txt](${resolveUrl("/llms-full.txt")})`],
|
|
22
24
|
``
|
|
23
25
|
].join("\n");
|
|
24
26
|
const i18n = config.i18n;
|
|
@@ -49,7 +51,12 @@ export default defineEventHandler(async (event) => {
|
|
|
49
51
|
const ctx = buildNegotiationContext(event, decision.path);
|
|
50
52
|
const { path, config, resolvePath, resolveUrl, routeContext } = ctx;
|
|
51
53
|
const canonicalUrl = resolveUrl(path);
|
|
52
|
-
const finalizeMarkdown = (markdown) =>
|
|
54
|
+
const finalizeMarkdown = (markdown) => {
|
|
55
|
+
const withSitemap = config.sitemapMd === false ? markdown : appendSitemapSection(markdown, resolvePath(SITEMAP_MD_ROUTE));
|
|
56
|
+
const llmsTxt = config.llmsTxt;
|
|
57
|
+
const withDirective = llmsTxt?.directive?.markdown === false ? withSitemap : insertLlmsTxtDirective(withSitemap, resolveUrl("/llms.txt"));
|
|
58
|
+
return absoluteMarkdownLinks(withDirective, canonicalUrl);
|
|
59
|
+
};
|
|
53
60
|
const [
|
|
54
61
|
{ tryGetContentMarkdown },
|
|
55
62
|
{ fetchRawWithEvent },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createError, defineEventHandler } from "#nuxtseo/h3";
|
|
2
2
|
import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
3
|
import { withSiteUrl } from "#site-config/server/composables/utils";
|
|
4
|
+
import { absoluteMarkdownLinks } from "../../markdown-links.js";
|
|
4
5
|
import { toDeployedRoute } from "../../route-path.js";
|
|
5
6
|
import { logger } from "../logger.js";
|
|
6
7
|
import { convertHtmlToMarkdown, extractLastUpdated } from "../utils.js";
|
|
@@ -52,7 +53,7 @@ export default defineEventHandler(async (event) => {
|
|
|
52
53
|
last_updated: contentPage.updatedAt
|
|
53
54
|
});
|
|
54
55
|
const markdown = `${frontmatter}
|
|
55
|
-
${contentPage.markdown}`;
|
|
56
|
+
${absoluteMarkdownLinks(contentPage.markdown, canonicalUrl)}`;
|
|
56
57
|
const headings = extractHeadingsFromMarkdown(contentPage.markdown);
|
|
57
58
|
const keywords2 = extractKeywords(contentPage.markdown, "");
|
|
58
59
|
return JSON.stringify({
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { defineNitroPlugin, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
2
|
+
import { llmsTxtDirectiveHtml } from "../../llms-txt-directive.js";
|
|
3
|
+
import { toDeployedRoute } from "../../route-path.js";
|
|
4
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
5
|
+
nitroApp.hooks.hook("render:html", (html, { event }) => {
|
|
6
|
+
html.bodyPrepend.unshift(llmsTxtDirectiveHtml(toDeployedRoute("/llms.txt", useRuntimeConfig(event).app.baseURL)));
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { eventHandler, sendIterable, setHeader, setResponseHeader } from "#nuxtseo/h3";
|
|
1
|
+
import { assertMethod, eventHandler, sendIterable, setHeader, setResponseHeader } from "#nuxtseo/h3";
|
|
2
2
|
import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
3
|
import { getSiteConfig } from "#site-config/server/composables";
|
|
4
4
|
import { withSiteUrl } from "#site-config/server/composables/utils";
|
|
@@ -7,6 +7,7 @@ import { toDeployedRoute } from "../../route-path.js";
|
|
|
7
7
|
import { countPages, streamPages } from "../db/queries.js";
|
|
8
8
|
import { buildLlmsFullTxtHeader, formatPageForLlmsFullTxt } from "../utils/llms-full.js";
|
|
9
9
|
export default eventHandler(async (event) => {
|
|
10
|
+
assertMethod(event, "GET", true);
|
|
10
11
|
if (import.meta.prerender) {
|
|
11
12
|
setHeader(event, "Content-Type", "text/plain; charset=utf-8");
|
|
12
13
|
return "# llms-full.txt\n\nThis file is generated during prerender.";
|
|
@@ -1,29 +1,37 @@
|
|
|
1
|
-
import { eventHandler, setHeader } from "#nuxtseo/h3";
|
|
1
|
+
import { assertMethod, createError, eventHandler, getRequestURL, setHeader } from "#nuxtseo/h3";
|
|
2
2
|
import { defineCachedFunction, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
3
|
import { publicCacheControl } from "../../cache-control.js";
|
|
4
|
-
import {
|
|
4
|
+
import { buildLlmsTxtFiles } from "../../llms-txt-utils.js";
|
|
5
|
+
import { toLogicalRoute } from "../../route-path.js";
|
|
5
6
|
const cachedBuilders = /* @__PURE__ */ new Map();
|
|
6
7
|
function getBuildLlmsTxtCached(maxAge) {
|
|
7
8
|
let cached = cachedBuilders.get(maxAge);
|
|
8
9
|
if (!cached) {
|
|
9
|
-
cached = defineCachedFunction(
|
|
10
|
-
name: "llms-txt",
|
|
10
|
+
cached = defineCachedFunction(buildLlmsTxtFiles, {
|
|
11
|
+
name: "llms-txt-files",
|
|
11
12
|
group: "ai-ready",
|
|
12
13
|
maxAge,
|
|
13
|
-
swr: true
|
|
14
|
+
swr: true,
|
|
15
|
+
// Share one discovery snapshot between root and child files, separated by origin.
|
|
16
|
+
getKey: (event) => getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).origin
|
|
14
17
|
});
|
|
15
18
|
cachedBuilders.set(maxAge, cached);
|
|
16
19
|
}
|
|
17
20
|
return cached;
|
|
18
21
|
}
|
|
19
22
|
export default eventHandler(async (event) => {
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
23
|
+
assertMethod(event, "GET", true);
|
|
24
|
+
const runtimeConfig = useRuntimeConfig(event);
|
|
25
|
+
const config = runtimeConfig["nuxt-ai-ready"];
|
|
26
|
+
const cacheSeconds = config.llmsTxtCacheSeconds;
|
|
27
|
+
const cacheEnabled = !import.meta.dev && !import.meta.prerender && cacheSeconds > 0;
|
|
28
|
+
const files = cacheEnabled ? await getBuildLlmsTxtCached(cacheSeconds)(event) : await buildLlmsTxtFiles(event);
|
|
29
|
+
const path = toLogicalRoute(event.path.split("?")[0], runtimeConfig.app.baseURL);
|
|
30
|
+
const content = files[path];
|
|
31
|
+
if (content === void 0)
|
|
32
|
+
throw createError({ statusCode: 404, statusMessage: "llms.txt file not found" });
|
|
24
33
|
setHeader(event, "Content-Type", "text/plain; charset=utf-8");
|
|
25
|
-
if (cacheEnabled)
|
|
34
|
+
if (cacheEnabled)
|
|
26
35
|
setHeader(event, "Cache-Control", publicCacheControl(cacheSeconds, 3600));
|
|
27
|
-
}
|
|
28
36
|
return content;
|
|
29
37
|
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { eventHandler, setHeader } from "#nuxtseo/h3";
|
|
1
|
+
import { assertMethod, eventHandler, setHeader } from "#nuxtseo/h3";
|
|
2
2
|
import { defineCachedFunction, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
3
|
import { getSiteConfig } from "#site-config/server/composables";
|
|
4
4
|
import { publicCacheControl } from "../../cache-control.js";
|
|
5
|
+
import { absoluteSiteUrl } from "../../markdown-links.js";
|
|
5
6
|
import { toMarkdownPath } from "../../markdown-path.js";
|
|
6
7
|
import { toDeployedRoute } from "../../route-path.js";
|
|
7
8
|
import { queryPages } from "../db/queries.js";
|
|
@@ -17,11 +18,12 @@ async function buildSitemapMarkdown(event) {
|
|
|
17
18
|
);
|
|
18
19
|
}
|
|
19
20
|
const baseURL = useRuntimeConfig(event).app.baseURL;
|
|
21
|
+
const siteConfig = getSiteConfig(event);
|
|
20
22
|
return buildSitemapMd(
|
|
21
23
|
pages.map((page) => ({ route: page.route, title: page.title, updatedAt: page.updatedAt })),
|
|
22
24
|
{
|
|
23
|
-
siteName:
|
|
24
|
-
resolveHref: (route) => toDeployedRoute(toMarkdownPath(route), baseURL)
|
|
25
|
+
siteName: siteConfig.name,
|
|
26
|
+
resolveHref: (route) => absoluteSiteUrl(toDeployedRoute(toMarkdownPath(route), baseURL), siteConfig.url)
|
|
25
27
|
}
|
|
26
28
|
);
|
|
27
29
|
}
|
|
@@ -40,6 +42,7 @@ function getBuildSitemapMdCached(maxAge) {
|
|
|
40
42
|
return cached;
|
|
41
43
|
}
|
|
42
44
|
export default eventHandler(async (event) => {
|
|
45
|
+
assertMethod(event, "GET", true);
|
|
43
46
|
const fullRuntimeConfig = useRuntimeConfig(event);
|
|
44
47
|
const runtimeConfig = fullRuntimeConfig["nuxt-ai-ready"];
|
|
45
48
|
const cacheSeconds = runtimeConfig.llmsTxtCacheSeconds ?? 600;
|