nuxt-ai-ready 2.0.1 → 2.2.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 +4 -1
- package/dist/chunks/prerender.mjs +10 -3
- package/dist/cli.mjs +67 -11
- package/dist/module.d.mts +3 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +4 -1
- package/dist/runtime/app/plugins/md-alternate.server.js +6 -2
- package/dist/runtime/llms-txt-utils.js +2 -2
- package/dist/runtime/markdown-path.d.ts +2 -0
- package/dist/runtime/markdown-path.js +4 -0
- package/dist/runtime/server/db/context.d.ts +2 -0
- package/dist/runtime/server/db/context.js +2 -0
- package/dist/runtime/server/db/drizzle/client.d.ts +6 -1
- package/dist/runtime/server/db/drizzle/client.js +66 -13
- package/dist/runtime/server/db/drizzle/index.d.ts +2 -2
- package/dist/runtime/server/db/drizzle/index.js +1 -2
- package/dist/runtime/server/db/drizzle/providers/dbPath.js +1 -1
- package/dist/runtime/server/db/drizzle/providers/postgres.d.ts +8 -0
- package/dist/runtime/server/db/drizzle/providers/postgres.js +17 -0
- package/dist/runtime/server/db/drizzle/queries.d.ts +1 -5
- package/dist/runtime/server/db/drizzle/queries.js +79 -38
- package/dist/runtime/server/db/drizzle/raw.d.ts +2 -2
- package/dist/runtime/server/db/drizzle/raw.js +32 -13
- package/dist/runtime/server/db/queries.d.ts +30 -27
- package/dist/runtime/server/db/queries.js +142 -91
- package/dist/runtime/server/db/schema/postgres.d.ts +20 -20
- package/dist/runtime/server/db/schema/postgres.js +6 -6
- package/dist/runtime/server/db/shared.d.ts +16 -0
- package/dist/runtime/server/db/shared.js +12 -0
- package/dist/runtime/server/middleware/markdown.js +10 -5
- package/dist/runtime/server/middleware/markdown.prerender.js +4 -1
- package/dist/runtime/server/plugins/db-lifecycle.js +5 -4
- package/dist/runtime/server/plugins/sitemap-seeder.js +9 -4
- package/dist/runtime/server/routes/__ai-ready/cron.get.js +10 -2
- package/dist/runtime/server/routes/__ai-ready/poll.post.js +30 -14
- package/dist/runtime/server/routes/__ai-ready/reindex.post.d.ts +2 -0
- package/dist/runtime/server/routes/__ai-ready/reindex.post.js +23 -0
- package/dist/runtime/server/routes/api-catalog.js +11 -2
- package/dist/runtime/server/routes/llms.txt.get.js +17 -12
- package/dist/runtime/server/routes/sitemap.md.get.d.ts +2 -0
- package/dist/runtime/server/routes/sitemap.md.get.js +51 -0
- package/dist/runtime/server/tasks/ai-ready-cron.js +3 -0
- package/dist/runtime/server/utils/i18n.d.ts +4 -0
- package/dist/runtime/server/utils/i18n.js +13 -0
- package/dist/runtime/server/utils/indexPage.d.ts +0 -1
- package/dist/runtime/server/utils/indexPage.js +10 -4
- package/dist/runtime/server/utils/link-header.d.ts +2 -0
- package/dist/runtime/server/utils/link-header.js +6 -0
- package/dist/runtime/server/utils/markdown-request.js +3 -1
- package/dist/runtime/server/utils/negotiation-decision.d.ts +1 -1
- package/dist/runtime/server/utils/runCron.d.ts +24 -0
- package/dist/runtime/server/utils/runCron.js +21 -4
- package/dist/runtime/server/utils/sitemap-md.d.ts +13 -0
- package/dist/runtime/server/utils/sitemap-md.js +65 -0
- package/dist/runtime/types.d.ts +21 -7
- package/dist/shared/{nuxt-ai-ready.D8qbnNrv.mjs → nuxt-ai-ready.D5URd0TD.mjs} +107 -39
- package/package.json +31 -26
|
@@ -57,6 +57,18 @@ export function normalizeRoute(route) {
|
|
|
57
57
|
return "/";
|
|
58
58
|
return route.startsWith("/") ? route : `/${route}`;
|
|
59
59
|
}
|
|
60
|
+
const RE_LIKE_META = /[\\%_]/g;
|
|
61
|
+
export function escapeLikeTerm(term) {
|
|
62
|
+
return term.replace(RE_LIKE_META, (ch) => `\\${ch}`);
|
|
63
|
+
}
|
|
64
|
+
export function likeSubstring(term) {
|
|
65
|
+
return `%${escapeLikeTerm(term)}%`;
|
|
66
|
+
}
|
|
67
|
+
export const LIKE_ESCAPE = "\\";
|
|
68
|
+
const MAX_BIND_PARAMS_PER_STATEMENT = 100;
|
|
69
|
+
export function maxRowsPerInsert(paramsPerRow) {
|
|
70
|
+
return Math.max(1, Math.floor(MAX_BIND_PARAMS_PER_STATEMENT / paramsPerRow));
|
|
71
|
+
}
|
|
60
72
|
export function normalizeRouteKey(route) {
|
|
61
73
|
return normalizeRoute(route).replace(RE_LEADING_SLASH, "").replace(RE_SLASH, ":") || "index";
|
|
62
74
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createError, defineEventHandler, setHeader, setResponseStatus } from "#nuxtseo/h3";
|
|
2
|
-
import { useNitroApp } from "#nuxtseo/nitro";
|
|
2
|
+
import { useNitroApp, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
3
|
import { resolveLocaleAlternateUrl } from "../../i18n-url.js";
|
|
4
4
|
import { logger } from "../logger.js";
|
|
5
5
|
import { computeLocaleAlternates, resolveLocaleFromRoute } from "../utils/i18n.js";
|
|
6
6
|
import { INTERNAL_HEADER } from "../utils/negotiation-decision.js";
|
|
7
7
|
import { applyNegotiation, buildNegotiationContext, decideNegotiation, ensureSiteConfig, setMarkdownHeaders } from "../utils/negotiation-response.js";
|
|
8
|
+
import { appendSitemapSection, isSitemapMdRequest, SITEMAP_MD_ROUTE } from "../utils/sitemap-md.js";
|
|
8
9
|
function notFoundMarkdown(ctx, canonicalUrl, build) {
|
|
9
10
|
const { path, config, resolveUrl, routeContext } = ctx;
|
|
10
11
|
const body = [
|
|
@@ -37,6 +38,9 @@ function notFoundMarkdown(ctx, canonicalUrl, build) {
|
|
|
37
38
|
${body}`;
|
|
38
39
|
}
|
|
39
40
|
export default defineEventHandler(async (event) => {
|
|
41
|
+
const runtimeConfig = useRuntimeConfig(event);
|
|
42
|
+
if (isSitemapMdRequest(event.path, runtimeConfig.app.baseURL, runtimeConfig["nuxt-ai-ready"]?.sitemapMd !== false))
|
|
43
|
+
return;
|
|
40
44
|
const decision = decideNegotiation(event, "middleware");
|
|
41
45
|
const negotiationResponse = await applyNegotiation(event, decision);
|
|
42
46
|
if (decision._tag !== "render")
|
|
@@ -45,6 +49,7 @@ export default defineEventHandler(async (event) => {
|
|
|
45
49
|
const ctx = buildNegotiationContext(event, decision.path);
|
|
46
50
|
const { path, config, resolvePath, resolveUrl, routeContext } = ctx;
|
|
47
51
|
const canonicalUrl = resolveUrl(path);
|
|
52
|
+
const finalizeMarkdown = (markdown) => config.sitemapMd === false ? markdown : appendSitemapSection(markdown, resolvePath(SITEMAP_MD_ROUTE));
|
|
48
53
|
const [
|
|
49
54
|
{ tryGetContentMarkdown },
|
|
50
55
|
{ fetchRawWithEvent },
|
|
@@ -65,7 +70,7 @@ export default defineEventHandler(async (event) => {
|
|
|
65
70
|
last_updated: updatedAt || (/* @__PURE__ */ new Date()).toISOString()
|
|
66
71
|
}, markdown);
|
|
67
72
|
setMarkdownHeaders(event, ctx);
|
|
68
|
-
return responseMarkdown;
|
|
73
|
+
return finalizeMarkdown(responseMarkdown);
|
|
69
74
|
}
|
|
70
75
|
const contentPage = await tryGetContentMarkdown(event, path).catch((e) => {
|
|
71
76
|
logger.debug(`[markdown] Content lookup failed for ${path}`, e);
|
|
@@ -80,8 +85,8 @@ export default defineEventHandler(async (event) => {
|
|
|
80
85
|
last_updated: contentPage.updatedAt || (/* @__PURE__ */ new Date()).toISOString()
|
|
81
86
|
});
|
|
82
87
|
setMarkdownHeaders(event, ctx);
|
|
83
|
-
return `${frontmatter}
|
|
84
|
-
${contentPage.markdown}
|
|
88
|
+
return finalizeMarkdown(`${frontmatter}
|
|
89
|
+
${contentPage.markdown}`);
|
|
85
90
|
}
|
|
86
91
|
logger.debug(`[markdown] Fetching HTML for ${path}`);
|
|
87
92
|
const response = await fetchRawWithEvent(event, resolvePath(path), {
|
|
@@ -152,5 +157,5 @@ ${contentPage.markdown}`;
|
|
|
152
157
|
}
|
|
153
158
|
);
|
|
154
159
|
setMarkdownHeaders(event, ctx);
|
|
155
|
-
return result.markdown;
|
|
160
|
+
return finalizeMarkdown(result.markdown);
|
|
156
161
|
});
|
|
@@ -10,6 +10,7 @@ import { buildFrontmatter } from "../utils/frontmatter.js";
|
|
|
10
10
|
import { extractKeywords } from "../utils/keywords.js";
|
|
11
11
|
import { getMarkdownRenderInfo } from "../utils/markdown-request.js";
|
|
12
12
|
import { consumePrerenderedHtml } from "../utils/prerender-html.js";
|
|
13
|
+
import { isSitemapMdRequest } from "../utils/sitemap-md.js";
|
|
13
14
|
function extractHeadingsFromMarkdown(markdown) {
|
|
14
15
|
const headings = [];
|
|
15
16
|
for (const m of markdown.matchAll(/^(#{1,6}) ([^\n]+)$/gm)) {
|
|
@@ -27,11 +28,13 @@ export default defineEventHandler(async (event) => {
|
|
|
27
28
|
if (!import.meta.prerender) {
|
|
28
29
|
return;
|
|
29
30
|
}
|
|
31
|
+
const fullRuntimeConfig = useRuntimeConfig(event);
|
|
32
|
+
if (isSitemapMdRequest(event.path, fullRuntimeConfig.app.baseURL, fullRuntimeConfig["nuxt-ai-ready"]?.sitemapMd !== false))
|
|
33
|
+
return;
|
|
30
34
|
const renderInfo = getMarkdownRenderInfo(event, { _tag: "prerender" });
|
|
31
35
|
if (!renderInfo || "notAcceptable" in renderInfo)
|
|
32
36
|
return;
|
|
33
37
|
const { path } = renderInfo;
|
|
34
|
-
const fullRuntimeConfig = useRuntimeConfig(event);
|
|
35
38
|
const runtimeConfig = fullRuntimeConfig["nuxt-ai-ready"];
|
|
36
39
|
const deployedPath = toDeployedRoute(path, fullRuntimeConfig.app.baseURL);
|
|
37
40
|
const canonicalUrl = withSiteUrl(event, deployedPath);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { DB_CONTEXT_KEY } from "../db/context.js";
|
|
1
|
+
import { DB_CONTEXT_KEY, DB_PROMISE_CONTEXT_KEY, DB_WORK_CONTEXT_KEY } from "../db/context.js";
|
|
2
2
|
export default function dbLifecyclePlugin(nitroApp) {
|
|
3
3
|
nitroApp.hooks.hook("afterResponse", async (event) => {
|
|
4
|
-
if (!event.context?.[DB_CONTEXT_KEY])
|
|
4
|
+
if (!event.context?.[DB_CONTEXT_KEY] && !event.context?.[DB_PROMISE_CONTEXT_KEY] && !event.context?.[DB_WORK_CONTEXT_KEY]) {
|
|
5
5
|
return;
|
|
6
|
-
|
|
7
|
-
await
|
|
6
|
+
}
|
|
7
|
+
const { finishDrizzleResponse } = await import("../db/index.js");
|
|
8
|
+
await finishDrizzleResponse(event);
|
|
8
9
|
});
|
|
9
10
|
nitroApp.hooks.hook("close", async () => {
|
|
10
11
|
const { closeDrizzle } = await import("../db/index.js");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
2
|
+
import { trackDrizzleWork } from "../db/drizzle/client.js";
|
|
2
3
|
import { getPageLastmods, getSitemapLastCrawledAt, markSitemapSeeded, seedRoutes } from "../db/queries.js";
|
|
3
4
|
import { logger } from "../logger.js";
|
|
4
5
|
import { mapSitemapRoutes } from "../utils/sitemap-routes.js";
|
|
@@ -12,6 +13,7 @@ function recordDiagnostic(event, message) {
|
|
|
12
13
|
list.push(message);
|
|
13
14
|
}
|
|
14
15
|
const SEED_INTERVAL_MS = 5 * 60 * 1e3;
|
|
16
|
+
const lastSeedAt = /* @__PURE__ */ new Map();
|
|
15
17
|
const READ_TIMEOUT_MS = 3e3;
|
|
16
18
|
const SLOW_READ_WARN_MS = 1e3;
|
|
17
19
|
const SLOW_SEED_WARN_MS = 1e4;
|
|
@@ -86,9 +88,11 @@ export default function sitemapSeederPlugin(nitroApp) {
|
|
|
86
88
|
logger.warn(`[sitemap-seeder] ${message} for ${sitemapName}`);
|
|
87
89
|
record?.(message);
|
|
88
90
|
}
|
|
89
|
-
|
|
91
|
+
const lastSeed = Math.max(lastCrawled ?? 0, lastSeedAt.get(sitemapName) ?? 0);
|
|
92
|
+
if (Date.now() - lastSeed < SEED_INTERVAL_MS)
|
|
90
93
|
return;
|
|
91
|
-
|
|
94
|
+
lastSeedAt.set(sitemapName, Date.now());
|
|
95
|
+
const routes = [...routeToUrl.entries()].map(([route, url]) => ({ route, url: url.loc }));
|
|
92
96
|
const urlCount = urls.length;
|
|
93
97
|
const seed = async () => {
|
|
94
98
|
const seedStart = Date.now();
|
|
@@ -106,9 +110,10 @@ export default function sitemapSeederPlugin(nitroApp) {
|
|
|
106
110
|
logger.debug(`[sitemap-seeder] Seeded ${seeded} routes from ${sitemapName} in ${seedMs}ms`);
|
|
107
111
|
};
|
|
108
112
|
if (event.waitUntil) {
|
|
109
|
-
event
|
|
113
|
+
const backgroundSeed = trackDrizzleWork(event, seed()).catch(
|
|
110
114
|
(err) => logger.error(`[sitemap-seeder] Background seed failed: ${err.message}`)
|
|
111
|
-
)
|
|
115
|
+
);
|
|
116
|
+
event.waitUntil(backgroundSeed);
|
|
112
117
|
} else {
|
|
113
118
|
await seed();
|
|
114
119
|
}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import { eventHandler } from "#nuxtseo/h3";
|
|
1
|
+
import { createError, eventHandler } from "#nuxtseo/h3";
|
|
2
2
|
import { runCron } from "../../utils/runCron.js";
|
|
3
3
|
export default eventHandler(async (event) => {
|
|
4
4
|
const { requireAuth } = await import("../../utils/auth.js");
|
|
5
5
|
requireAuth(event);
|
|
6
|
-
|
|
6
|
+
const result = await runCron(event);
|
|
7
|
+
if (result.failed) {
|
|
8
|
+
throw createError({
|
|
9
|
+
statusCode: 500,
|
|
10
|
+
message: `Cron run failed at the ${result.failed.stage} stage: ${result.failed.message}`,
|
|
11
|
+
data: result.failed
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
7
15
|
});
|
|
@@ -1,21 +1,37 @@
|
|
|
1
|
-
import { eventHandler, getQuery } from "#nuxtseo/h3";
|
|
1
|
+
import { eventHandler, getQuery, setResponseStatus } from "#nuxtseo/h3";
|
|
2
|
+
import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
|
+
import { releaseCronLock, tryAcquireCronLock } from "../../db/queries.js";
|
|
4
|
+
import { logger } from "../../logger.js";
|
|
2
5
|
import { batchIndexPages } from "../../utils/batchIndex.js";
|
|
3
6
|
export default eventHandler(async (event) => {
|
|
4
7
|
const { requireAuth } = await import("../../utils/auth.js");
|
|
5
8
|
requireAuth(event);
|
|
9
|
+
const lock = await tryAcquireCronLock(event);
|
|
10
|
+
if (lock._tag === "held") {
|
|
11
|
+
setResponseStatus(event, 409);
|
|
12
|
+
return { locked: true };
|
|
13
|
+
}
|
|
6
14
|
const query = getQuery(event);
|
|
7
|
-
const
|
|
15
|
+
const config = useRuntimeConfig(event)["nuxt-ai-ready"];
|
|
16
|
+
const defaultLimit = Math.max(1, Math.trunc(Math.min(config.runtimeSync?.batchSize ?? 10, 50))) || 10;
|
|
17
|
+
const limit = query.limit ? Math.max(1, Math.min(50, Math.trunc(Number(query.limit)) || defaultLimit)) : defaultLimit;
|
|
8
18
|
const timeout = query.timeout ? Math.max(1e3, Math.trunc(Number(query.timeout)) || 3e4) : void 0;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
try {
|
|
20
|
+
const result = await batchIndexPages(event, {
|
|
21
|
+
limit,
|
|
22
|
+
all: query.all === "true" || query.all === "1",
|
|
23
|
+
timeout
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
indexed: result.indexed,
|
|
27
|
+
remaining: result.remaining,
|
|
28
|
+
errors: result.errors.length > 0 ? result.errors : void 0,
|
|
29
|
+
duration: result.duration,
|
|
30
|
+
complete: result.complete
|
|
31
|
+
};
|
|
32
|
+
} finally {
|
|
33
|
+
await releaseCronLock(event, lock.token).catch((err) => {
|
|
34
|
+
logger.warn(`[poll] Failed to release lock: ${err?.message || err}`);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
21
37
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createError, eventHandler, getQuery, setResponseStatus } from "#nuxtseo/h3";
|
|
2
|
+
import { indexPageByRoute } from "../../utils/indexPage.js";
|
|
3
|
+
export default eventHandler(async (event) => {
|
|
4
|
+
const { requireAuth } = await import("../../utils/auth.js");
|
|
5
|
+
requireAuth(event);
|
|
6
|
+
const query = getQuery(event);
|
|
7
|
+
const route = typeof query.route === "string" ? query.route.trim() : "";
|
|
8
|
+
if (!route.startsWith("/")) {
|
|
9
|
+
throw createError({ statusCode: 400, message: 'Invalid route. It must be an absolute path starting with "/", for example "/about".' });
|
|
10
|
+
}
|
|
11
|
+
const force = query.force !== "false" && query.force !== "0";
|
|
12
|
+
const result = await indexPageByRoute(route, event, { force });
|
|
13
|
+
if (!result.success) {
|
|
14
|
+
setResponseStatus(event, 502);
|
|
15
|
+
return { route, indexed: false, error: result.error ?? `Failed to index ${route}` };
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
route,
|
|
19
|
+
indexed: !result.skipped,
|
|
20
|
+
skipped: result.skipped || void 0,
|
|
21
|
+
contentChanged: result.contentChanged
|
|
22
|
+
};
|
|
23
|
+
});
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { assertMethod, defineEventHandler, setHeader } from "#nuxtseo/h3";
|
|
1
|
+
import { assertMethod, createError, defineEventHandler, setHeader, setHeaders, setResponseStatus } from "#nuxtseo/h3";
|
|
2
2
|
import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
3
|
export default defineEventHandler((event) => {
|
|
4
|
+
setHeader(event, "Access-Control-Allow-Origin", "*");
|
|
5
|
+
if (event.method === "OPTIONS") {
|
|
6
|
+
setHeaders(event, {
|
|
7
|
+
"Access-Control-Allow-Headers": "Content-Type, If-None-Match",
|
|
8
|
+
"Access-Control-Allow-Methods": "GET, HEAD"
|
|
9
|
+
});
|
|
10
|
+
setResponseStatus(event, 204);
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
4
13
|
assertMethod(event, ["GET", "HEAD"]);
|
|
5
14
|
const config = useRuntimeConfig(event)["nuxt-ai-ready"].apiCatalog;
|
|
6
15
|
if (!config)
|
|
7
|
-
|
|
16
|
+
throw createError({ statusCode: 404, message: "API catalog is not configured" });
|
|
8
17
|
setHeader(event, "content-type", config.mediaType);
|
|
9
18
|
setHeader(event, "link", `<${config.href}>; rel="api-catalog"`);
|
|
10
19
|
if (event.method === "HEAD")
|
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
import { eventHandler, setHeader } from "#nuxtseo/h3";
|
|
2
2
|
import { defineCachedFunction, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
3
|
import { buildLlmsTxt } from "../../llms-txt-utils.js";
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
const cachedBuilders = /* @__PURE__ */ new Map();
|
|
5
|
+
function getBuildLlmsTxtCached(maxAge) {
|
|
6
|
+
let cached = cachedBuilders.get(maxAge);
|
|
7
|
+
if (!cached) {
|
|
8
|
+
cached = defineCachedFunction(buildLlmsTxt, {
|
|
9
|
+
name: "llms-txt",
|
|
10
|
+
group: "ai-ready",
|
|
11
|
+
maxAge,
|
|
12
|
+
swr: true
|
|
13
|
+
});
|
|
14
|
+
cachedBuilders.set(maxAge, cached);
|
|
12
15
|
}
|
|
13
|
-
|
|
16
|
+
return cached;
|
|
17
|
+
}
|
|
14
18
|
export default eventHandler(async (event) => {
|
|
15
19
|
const runtimeConfig = useRuntimeConfig(event)["nuxt-ai-ready"];
|
|
16
|
-
const
|
|
17
|
-
const
|
|
20
|
+
const cacheSeconds = runtimeConfig.llmsTxtCacheSeconds;
|
|
21
|
+
const cacheEnabled = !import.meta.dev && cacheSeconds > 0;
|
|
22
|
+
const content = cacheEnabled ? await getBuildLlmsTxtCached(cacheSeconds)(event) : await buildLlmsTxt(event);
|
|
18
23
|
setHeader(event, "Content-Type", "text/plain; charset=utf-8");
|
|
19
24
|
if (cacheEnabled) {
|
|
20
|
-
setHeader(event, "Cache-Control", `public, max-age=${
|
|
25
|
+
setHeader(event, "Cache-Control", `public, max-age=${cacheSeconds}, s-maxage=${cacheSeconds}, stale-while-revalidate=3600`);
|
|
21
26
|
}
|
|
22
27
|
return content;
|
|
23
28
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { eventHandler, setHeader } from "#nuxtseo/h3";
|
|
2
|
+
import { defineCachedFunction, useRuntimeConfig } from "#nuxtseo/nitro";
|
|
3
|
+
import { getSiteConfig } from "#site-config/server/composables";
|
|
4
|
+
import { toMarkdownPath } from "../../markdown-path.js";
|
|
5
|
+
import { toDeployedRoute } from "../../route-path.js";
|
|
6
|
+
import { queryPages } from "../db/queries.js";
|
|
7
|
+
import { logger } from "../logger.js";
|
|
8
|
+
import { buildSitemapMd } from "../utils/sitemap-md.js";
|
|
9
|
+
async function buildSitemapMarkdown(event) {
|
|
10
|
+
let pages = [];
|
|
11
|
+
try {
|
|
12
|
+
pages = await queryPages(event);
|
|
13
|
+
} catch (err) {
|
|
14
|
+
logger.warn(
|
|
15
|
+
`[ai-ready] Database unavailable for sitemap.md, serving an empty sitemap: ${err instanceof Error ? err.message : String(err)}`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
const baseURL = useRuntimeConfig(event).app.baseURL;
|
|
19
|
+
return buildSitemapMd(
|
|
20
|
+
pages.map((page) => ({ route: page.route, title: page.title, updatedAt: page.updatedAt })),
|
|
21
|
+
{
|
|
22
|
+
siteName: getSiteConfig(event).name,
|
|
23
|
+
resolveHref: (route) => toDeployedRoute(toMarkdownPath(route), baseURL)
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const cachedBuilders = /* @__PURE__ */ new Map();
|
|
28
|
+
function getBuildSitemapMdCached(maxAge) {
|
|
29
|
+
let cached = cachedBuilders.get(maxAge);
|
|
30
|
+
if (!cached) {
|
|
31
|
+
cached = defineCachedFunction(buildSitemapMarkdown, {
|
|
32
|
+
name: "sitemap-md",
|
|
33
|
+
group: "ai-ready",
|
|
34
|
+
maxAge,
|
|
35
|
+
swr: true
|
|
36
|
+
});
|
|
37
|
+
cachedBuilders.set(maxAge, cached);
|
|
38
|
+
}
|
|
39
|
+
return cached;
|
|
40
|
+
}
|
|
41
|
+
export default eventHandler(async (event) => {
|
|
42
|
+
const fullRuntimeConfig = useRuntimeConfig(event);
|
|
43
|
+
const runtimeConfig = fullRuntimeConfig["nuxt-ai-ready"];
|
|
44
|
+
const cacheSeconds = runtimeConfig.llmsTxtCacheSeconds ?? 600;
|
|
45
|
+
const cacheEnabled = !import.meta.dev && cacheSeconds > 0;
|
|
46
|
+
setHeader(event, "Content-Type", "text/markdown; charset=utf-8");
|
|
47
|
+
if (cacheEnabled) {
|
|
48
|
+
setHeader(event, "Cache-Control", `public, max-age=${cacheSeconds}, s-maxage=${cacheSeconds}, stale-while-revalidate=3600`);
|
|
49
|
+
}
|
|
50
|
+
return cacheEnabled ? await getBuildSitemapMdCached(cacheSeconds)(event) : await buildSitemapMarkdown(event);
|
|
51
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defineTask } from "#nuxtseo/nitro";
|
|
2
|
+
import { logger } from "../logger.js";
|
|
2
3
|
import { runCron } from "../utils/runCron.js";
|
|
3
4
|
export default defineTask({
|
|
4
5
|
meta: {
|
|
@@ -11,6 +12,8 @@ export default defineTask({
|
|
|
11
12
|
const result = await runCron(void 0, {
|
|
12
13
|
batchSize: payload?.limit
|
|
13
14
|
});
|
|
15
|
+
if (result.failed)
|
|
16
|
+
logger.error(`[ai-ready:cron] Run failed at the ${result.failed.stage} stage: ${result.failed.message}`);
|
|
14
17
|
return { result };
|
|
15
18
|
}
|
|
16
19
|
});
|
|
@@ -5,3 +5,7 @@ export type { LocaleAlternate, LocaleAlternateResolution, LocalePages, RouteLoca
|
|
|
5
5
|
export declare function getRuntimeI18n(aiReadyConfig: {
|
|
6
6
|
i18n?: RuntimeI18nConfig | null;
|
|
7
7
|
}): RuntimeI18nConfig | null;
|
|
8
|
+
/** Normalize a host or URL into a comparable hostname (lowercase, no scheme, no path). */
|
|
9
|
+
export declare function normalizeHost(value: string): string;
|
|
10
|
+
/** True when the host equals a domain configured on any i18n locale. */
|
|
11
|
+
export declare function hostMatchesLocaleDomain(host: string | undefined, i18n: RuntimeI18nConfig): boolean;
|
|
@@ -7,3 +7,16 @@ export {
|
|
|
7
7
|
export function getRuntimeI18n(aiReadyConfig) {
|
|
8
8
|
return aiReadyConfig.i18n || null;
|
|
9
9
|
}
|
|
10
|
+
export function normalizeHost(value) {
|
|
11
|
+
return value.trim().toLowerCase().replace(/^[a-z][a-z\d+.-]*:\/\//, "").split("/")[0];
|
|
12
|
+
}
|
|
13
|
+
export function hostMatchesLocaleDomain(host, i18n) {
|
|
14
|
+
if (!host)
|
|
15
|
+
return false;
|
|
16
|
+
const normalized = normalizeHost(host);
|
|
17
|
+
if (!normalized)
|
|
18
|
+
return false;
|
|
19
|
+
return i18n.locales.some(
|
|
20
|
+
(locale) => [...locale.domains ?? [], locale.domain, ...locale.defaultForDomains ?? []].some((domain) => domain && normalizeHost(domain) === normalized)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -5,7 +5,7 @@ import { logger } from "../logger.js";
|
|
|
5
5
|
import { convertHtmlToMarkdown } from "../utils.js";
|
|
6
6
|
import { createUniversalContext } from "./context.js";
|
|
7
7
|
import { extractKeywords } from "./keywords.js";
|
|
8
|
-
|
|
8
|
+
import { INTERNAL_HEADER } from "./negotiation-decision.js";
|
|
9
9
|
export async function indexPage(route, html, options = {}, event) {
|
|
10
10
|
const config = useRuntimeConfig()["nuxt-ai-ready"];
|
|
11
11
|
const ttl = options.ttl ?? config.runtimeSync.ttl;
|
|
@@ -70,10 +70,10 @@ export async function indexPage(route, html, options = {}, event) {
|
|
|
70
70
|
export async function indexPageByRoute(route, event, options = {}) {
|
|
71
71
|
logger.debug(`[indexPageByRoute] Fetching HTML for ${route} (timeout: 10000ms)`);
|
|
72
72
|
const html = await (event ? fetchWithEvent(event, route, {
|
|
73
|
-
headers: { [
|
|
73
|
+
headers: { accept: "text/html", [INTERNAL_HEADER]: "1" },
|
|
74
74
|
timeout: 1e4
|
|
75
75
|
}) : globalThis.$fetch(route, {
|
|
76
|
-
headers: { [
|
|
76
|
+
headers: { accept: "text/html", [INTERNAL_HEADER]: "1" },
|
|
77
77
|
timeout: 1e4
|
|
78
78
|
// 10s timeout per page (must fit within CF worker limit)
|
|
79
79
|
})).catch((err) => {
|
|
@@ -98,5 +98,11 @@ export async function indexPageByRoute(route, event, options = {}) {
|
|
|
98
98
|
}
|
|
99
99
|
return { success: false, error: `Failed to fetch HTML for ${route}` };
|
|
100
100
|
}
|
|
101
|
-
|
|
101
|
+
try {
|
|
102
|
+
return await indexPage(route, html, options, event);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
105
|
+
logger.warn(`[indexPageByRoute] Failed to index ${route}:`, message);
|
|
106
|
+
return { success: false, error: message || `Failed to index ${route}` };
|
|
107
|
+
}
|
|
102
108
|
}
|
|
@@ -8,10 +8,12 @@ import type { RuntimeI18nConfig, RuntimeRouteContext } from './i18n.js';
|
|
|
8
8
|
*/
|
|
9
9
|
export declare function encodePathForHeader(path: string): string;
|
|
10
10
|
type LinkUrlResolver = (path: string) => string;
|
|
11
|
+
export declare const LLMS_TXT_PATH = "/llms.txt";
|
|
11
12
|
interface LinkHeaderConfig {
|
|
12
13
|
apiCatalog?: {
|
|
13
14
|
href: string;
|
|
14
15
|
};
|
|
16
|
+
describedby?: boolean;
|
|
15
17
|
i18n?: RuntimeI18nConfig | null;
|
|
16
18
|
}
|
|
17
19
|
/**
|
|
@@ -4,6 +4,7 @@ import { computeLocaleAlternates } from "./i18n.js";
|
|
|
4
4
|
export function encodePathForHeader(path) {
|
|
5
5
|
return encodeURI(path);
|
|
6
6
|
}
|
|
7
|
+
export const LLMS_TXT_PATH = "/llms.txt";
|
|
7
8
|
function resolveHeaderUrl(path, resolveUrl) {
|
|
8
9
|
if (!resolveUrl)
|
|
9
10
|
return path;
|
|
@@ -21,6 +22,11 @@ export function buildLinkHeader(path, variant, config, resolveUrl, routeContext
|
|
|
21
22
|
} else {
|
|
22
23
|
const href = resolveHeaderUrl(path, resolveUrl);
|
|
23
24
|
parts.push(`<${encodePathForHeader(href)}>; rel="alternate"; type="text/html"`);
|
|
25
|
+
parts.push(`<${encodePathForHeader(href)}>; rel="canonical"`);
|
|
26
|
+
}
|
|
27
|
+
if (config.describedby !== false) {
|
|
28
|
+
const href = resolveHeaderUrl(LLMS_TXT_PATH, resolveUrl);
|
|
29
|
+
parts.push(`<${encodePathForHeader(href)}>; rel="describedby"`);
|
|
24
30
|
}
|
|
25
31
|
if (config.i18n) {
|
|
26
32
|
const alternates = computeLocaleAlternates(path, config.i18n, routeContext);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { negotiateContent } from "@mdream/js/negotiate";
|
|
2
2
|
import { getBotInfo } from "@nuxtjs/robots/util";
|
|
3
3
|
import { getHeaders } from "#nuxtseo/h3";
|
|
4
|
+
import { isReservedPath } from "../../markdown-path.js";
|
|
5
|
+
const RE_MD_EXT = /\.md$/;
|
|
4
6
|
export function toMarkdownRequest(event, isPrerender = !!import.meta.prerender) {
|
|
5
7
|
return { path: event.path, headers: getHeaders(event), isPrerender };
|
|
6
8
|
}
|
|
@@ -25,7 +27,7 @@ export function getRequestRenderInfo(request, mode = { _tag: "runtime", contentN
|
|
|
25
27
|
const queryIndex = request.path.indexOf("?");
|
|
26
28
|
const originalPath = queryIndex === -1 ? request.path : request.path.slice(0, queryIndex);
|
|
27
29
|
const isPrerender = mode._tag === "prerender";
|
|
28
|
-
if (
|
|
30
|
+
if (isReservedPath(originalPath.replace(RE_MD_EXT, "")))
|
|
29
31
|
return null;
|
|
30
32
|
const accept = request.headers.accept || "";
|
|
31
33
|
if (!originalPath.endsWith(".md") && accept && /\b(?:application\/json|text\/event-stream)\b/i.test(accept) && !/text\/(?:html|markdown|plain)\b|\*\/\*/i.test(accept)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ContentNegotiationPolicy } from '../../types.js';
|
|
2
2
|
import type { ContentNegotiationResolution, NegotiationRouteRule } from './content-negotiation.js';
|
|
3
3
|
import type { MarkdownRequest } from './markdown-request.js';
|
|
4
|
-
/** Marks
|
|
4
|
+
/** Marks an internal HTML fetch that must bypass content negotiation. */
|
|
5
5
|
export declare const INTERNAL_HEADER = "x-ai-ready-internal";
|
|
6
6
|
/**
|
|
7
7
|
* Where the decision runs. The early stage sits in front of the Nitro static
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import type { H3Event } from '#nuxtseo/h3';
|
|
2
2
|
import type { StaleCheckResult } from './checkStale.js';
|
|
3
|
+
/**
|
|
4
|
+
* Why a run could not finish.
|
|
5
|
+
*
|
|
6
|
+
* `lock` means the run never started, so nothing was changed. `run` means it
|
|
7
|
+
* started and stopped part way, so some work may already be committed.
|
|
8
|
+
*/
|
|
9
|
+
export interface CronFailure {
|
|
10
|
+
stage: 'lock' | 'run';
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
3
13
|
export interface CronResult {
|
|
14
|
+
/**
|
|
15
|
+
* Set when the run failed.
|
|
16
|
+
*
|
|
17
|
+
* A scheduled task that throws reaches Cloudflare as `scriptThrewException`,
|
|
18
|
+
* which carries no stage, no message, and nothing a host can report. The
|
|
19
|
+
* failure travels back as a value instead, so the caller decides what to do
|
|
20
|
+
* with it.
|
|
21
|
+
*/
|
|
22
|
+
failed?: CronFailure;
|
|
4
23
|
runId?: number | null;
|
|
5
24
|
stale?: StaleCheckResult;
|
|
6
25
|
sitemap?: {
|
|
@@ -19,6 +38,11 @@ export interface CronResult {
|
|
|
19
38
|
}
|
|
20
39
|
/**
|
|
21
40
|
* Run cron job logic - shared between scheduled task and HTTP endpoint
|
|
41
|
+
*
|
|
42
|
+
* This is the error boundary for the whole run. Nothing throws past it: the
|
|
43
|
+
* scheduled task has no request context and no handler above it, so a throw
|
|
44
|
+
* here becomes an opaque platform exception rather than something the host can
|
|
45
|
+
* report. Every failure comes back as `CronResult.failed`.
|
|
22
46
|
*/
|
|
23
47
|
export declare function runCron(event: H3Event | undefined, options?: {
|
|
24
48
|
batchSize?: number;
|
|
@@ -6,17 +6,34 @@ import { checkAndHandleStale, STALE_CHECK_INTERVAL_MS } from "./checkStale.js";
|
|
|
6
6
|
import { resolveCronPlan, resolveSitemapIntervalMinutes } from "./cron-plan.js";
|
|
7
7
|
import { crawlSitemapByRoute, getSitemapsFromConfig } from "./sitemap.js";
|
|
8
8
|
import { mapSitemapRoutes } from "./sitemap-routes.js";
|
|
9
|
+
function describeFailure(error) {
|
|
10
|
+
if (error instanceof Error)
|
|
11
|
+
return error.message;
|
|
12
|
+
return String(error);
|
|
13
|
+
}
|
|
9
14
|
export async function runCron(event, options) {
|
|
10
15
|
if (import.meta.dev)
|
|
11
16
|
return {};
|
|
17
|
+
return executeCron(event, options).catch((error) => {
|
|
18
|
+
const message = describeFailure(error);
|
|
19
|
+
logger.error(`[cron] Run failed: ${message}`);
|
|
20
|
+
return { failed: { stage: "run", message } };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async function executeCron(event, options) {
|
|
12
24
|
const config = useRuntimeConfig()["nuxt-ai-ready"];
|
|
13
25
|
const debug = config.debug;
|
|
14
26
|
const startTime = Date.now();
|
|
15
27
|
const results = {};
|
|
16
28
|
const allErrors = [];
|
|
17
29
|
const sitemapIntervalMinutes = resolveSitemapIntervalMinutes(config.runtimeSync.ttl);
|
|
18
|
-
const
|
|
19
|
-
|
|
30
|
+
const lock = await tryAcquireCronLock(event).catch((error) => {
|
|
31
|
+
logger.error(`[cron] Could not acquire the lock: ${describeFailure(error)}`);
|
|
32
|
+
return null;
|
|
33
|
+
});
|
|
34
|
+
if (lock === null)
|
|
35
|
+
return { failed: { stage: "lock", message: "Could not acquire the cron lock" } };
|
|
36
|
+
if (lock._tag === "held") {
|
|
20
37
|
if (debug) {
|
|
21
38
|
logger.info(`[cron] Skipping - another cron run is in progress`);
|
|
22
39
|
}
|
|
@@ -122,7 +139,7 @@ export async function runCron(event, options) {
|
|
|
122
139
|
}
|
|
123
140
|
return results;
|
|
124
141
|
} finally {
|
|
125
|
-
await releaseCronLock(event).catch((err) => {
|
|
142
|
+
await releaseCronLock(event, lock.token).catch((err) => {
|
|
126
143
|
logger.warn(`[cron] Failed to release lock: ${err?.message || err}`);
|
|
127
144
|
});
|
|
128
145
|
}
|
|
@@ -152,7 +169,7 @@ async function pingSitemap(event, config, sitemapIntervalMinutes, debug) {
|
|
|
152
169
|
route: nextSitemap.route,
|
|
153
170
|
state: nextSitemap.crawlState
|
|
154
171
|
});
|
|
155
|
-
const routes = [...mapSitemapRoutes(result.urls).
|
|
172
|
+
const routes = [...mapSitemapRoutes(result.urls).entries()].map(([route, url]) => ({ route, url: url.loc }));
|
|
156
173
|
if (routes.length > 0)
|
|
157
174
|
await seedRoutes(event, routes);
|
|
158
175
|
if (result._tag === "failed") {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const SITEMAP_MD_ROUTE = "/sitemap.md";
|
|
2
|
+
export interface SitemapMdEntry {
|
|
3
|
+
route: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
updatedAt?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SitemapMdOptions {
|
|
8
|
+
siteName?: string;
|
|
9
|
+
resolveHref?: (route: string) => string;
|
|
10
|
+
}
|
|
11
|
+
export declare function buildSitemapMd(entries: SitemapMdEntry[], options?: SitemapMdOptions): string;
|
|
12
|
+
export declare function appendSitemapSection(markdown: string, sitemapHref?: string): string;
|
|
13
|
+
export declare function isSitemapMdRequest(path: string, baseURL: string, enabled: boolean): boolean;
|