nuxt-ai-ready 2.3.4 → 2.4.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/prerender.mjs +3 -7
- package/dist/module.json +1 -1
- package/dist/runtime/server/db/drizzle/queries.js +3 -4
- package/dist/runtime/server/db/queries.d.ts +2 -0
- package/dist/runtime/server/db/queries.js +10 -6
- package/dist/runtime/server/db/shared.d.ts +4 -1
- package/dist/runtime/server/db/shared.js +14 -1
- package/dist/runtime/server/middleware/markdown.js +39 -26
- package/dist/runtime/server/middleware/markdown.prerender.js +5 -5
- package/dist/runtime/server/utils/indexPage.js +9 -2
- package/dist/runtime/server/utils/markdown-redirect.d.ts +27 -0
- package/dist/runtime/server/utils/markdown-redirect.js +18 -0
- package/dist/runtime/server/utils/page-updated-at.d.ts +30 -0
- package/dist/runtime/server/utils/page-updated-at.js +21 -0
- package/dist/runtime/types.d.ts +2 -2
- package/package.json +23 -23
|
@@ -10,6 +10,7 @@ import { normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-p
|
|
|
10
10
|
import { toDeployedRoute, toLogicalRoute } from '../../dist/runtime/route-path.js';
|
|
11
11
|
import { initSchema, computeContentHash, insertPage, queryAllPages, exportDbDump } from '../../dist/runtime/server/db/shared.js';
|
|
12
12
|
import { buildLlmsFullTxtHeader, formatPageForLlmsFullTxt } from '../../dist/runtime/server/utils/llms-full.js';
|
|
13
|
+
import { resolvePageUpdatedAt } from '../../dist/runtime/server/utils/page-updated-at.js';
|
|
13
14
|
import { SITEMAP_MD_ROUTE, appendSitemapSection } from '../../dist/runtime/server/utils/sitemap-md.js';
|
|
14
15
|
import 'node:crypto';
|
|
15
16
|
import 'node:module';
|
|
@@ -150,12 +151,7 @@ function resolveRouteLocale(route, i18n) {
|
|
|
150
151
|
async function processMarkdownRoute(state, nuxt, route, parsed, lastmod, options) {
|
|
151
152
|
route = normalizePagePath(route);
|
|
152
153
|
const { title, description, headings, keywords, updatedAt: metaUpdatedAt } = parsed;
|
|
153
|
-
|
|
154
|
-
if (metaUpdatedAt) {
|
|
155
|
-
const parsedDate = new Date(metaUpdatedAt);
|
|
156
|
-
if (!Number.isNaN(parsedDate.getTime()))
|
|
157
|
-
updatedAt = parsedDate.toISOString();
|
|
158
|
-
}
|
|
154
|
+
const updatedAt = resolvePageUpdatedAt(lastmod, metaUpdatedAt);
|
|
159
155
|
const hookContext = { ...parsed, route };
|
|
160
156
|
await nuxt.hooks.callHook("ai-ready:page:markdown", hookContext);
|
|
161
157
|
const { markdown } = hookContext;
|
|
@@ -353,7 +349,7 @@ function setupPrerenderHandler(options, dbPath, siteInfo, llmsTxtConfig, extras
|
|
|
353
349
|
markdown: "",
|
|
354
350
|
headings: "",
|
|
355
351
|
keywords: [],
|
|
356
|
-
updatedAt:
|
|
352
|
+
updatedAt: "",
|
|
357
353
|
isError: true,
|
|
358
354
|
locale: resolveRouteLocale(route, state.i18n)
|
|
359
355
|
});
|
package/dist/module.json
CHANGED
|
@@ -675,7 +675,6 @@ export async function seedRoutes(event, routes, options = {}) {
|
|
|
675
675
|
if (routes.length === 0)
|
|
676
676
|
return 0;
|
|
677
677
|
const refreshWindowMs = Math.max(0, Math.trunc(Number.isFinite(options.refreshWindowMs) ? options.refreshWindowMs : SEED_REFRESH_WINDOW_MS));
|
|
678
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
679
678
|
const nowMs = Date.now();
|
|
680
679
|
const byRoute = /* @__PURE__ */ new Map();
|
|
681
680
|
for (const entry of routes) {
|
|
@@ -688,14 +687,14 @@ export async function seedRoutes(event, routes, options = {}) {
|
|
|
688
687
|
locale: deriveLocale(event, route, explicitLocale, entryUrl)
|
|
689
688
|
});
|
|
690
689
|
}
|
|
691
|
-
const rowsPerInsert = maxRowsPerInsert(
|
|
690
|
+
const rowsPerInsert = maxRowsPerInsert(4);
|
|
692
691
|
const db = await useRawDb(event);
|
|
693
692
|
const entries = [...byRoute.values()];
|
|
694
693
|
const stmts = [];
|
|
695
694
|
for (let i = 0; i < entries.length; i += rowsPerInsert) {
|
|
696
695
|
const batch = entries.slice(i, i + rowsPerInsert);
|
|
697
|
-
const valuesSql = batch.map(() => `(?, ?, '', '', '', '[]', '[]',
|
|
698
|
-
const params = batch.flatMap((r) => [r.route, r.routeKey,
|
|
696
|
+
const valuesSql = batch.map(() => `(?, ?, '', '', '', '[]', '[]', '', 0, 0, 0, 'runtime', ?, ?)`).join(", ");
|
|
697
|
+
const params = batch.flatMap((r) => [r.route, r.routeKey, nowMs, r.locale]);
|
|
699
698
|
stmts.push({
|
|
700
699
|
sql: `
|
|
701
700
|
INSERT INTO ai_ready_pages (route, route_key, title, description, markdown, headings, keywords, updated_at, indexed_at, is_error, indexed, source, last_seen_at, locale)
|
|
@@ -124,6 +124,8 @@ export declare function isPageFresh(event: H3Event | undefined, route: string, t
|
|
|
124
124
|
export interface PageIndexState {
|
|
125
125
|
indexedAt: number;
|
|
126
126
|
contentHash: string | null;
|
|
127
|
+
updatedAt: string;
|
|
128
|
+
source: 'prerender' | 'runtime';
|
|
127
129
|
}
|
|
128
130
|
/**
|
|
129
131
|
* Fetch a page's index bookkeeping in one lightweight query.
|
|
@@ -324,10 +324,15 @@ export async function getPageIndexState(event, route) {
|
|
|
324
324
|
if (!db)
|
|
325
325
|
return void 0;
|
|
326
326
|
const row = await db.first(
|
|
327
|
-
"SELECT indexed_at, content_hash FROM ai_ready_pages WHERE route = ?",
|
|
327
|
+
"SELECT indexed_at, content_hash, updated_at, source FROM ai_ready_pages WHERE route = ?",
|
|
328
328
|
[route]
|
|
329
329
|
);
|
|
330
|
-
return row ? {
|
|
330
|
+
return row ? {
|
|
331
|
+
indexedAt: toNumber(row.indexed_at),
|
|
332
|
+
contentHash: row.content_hash,
|
|
333
|
+
updatedAt: row.updated_at || "",
|
|
334
|
+
source: row.source === "runtime" ? "runtime" : "prerender"
|
|
335
|
+
} : void 0;
|
|
331
336
|
}
|
|
332
337
|
export async function getPageHash(event, route) {
|
|
333
338
|
const db = await getDb(event);
|
|
@@ -347,7 +352,6 @@ export async function seedRoutes(event, routes, options = {}) {
|
|
|
347
352
|
if (!db || routes.length === 0)
|
|
348
353
|
return 0;
|
|
349
354
|
const refreshWindowMs = Math.max(0, Math.trunc(Number.isFinite(options.refreshWindowMs) ? options.refreshWindowMs : SEED_REFRESH_WINDOW_MS));
|
|
350
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
351
355
|
const nowMs = Date.now();
|
|
352
356
|
const byRoute = /* @__PURE__ */ new Map();
|
|
353
357
|
for (const entry of routes) {
|
|
@@ -360,11 +364,11 @@ export async function seedRoutes(event, routes, options = {}) {
|
|
|
360
364
|
locale: deriveLocale(event, route, explicitLocale, entryUrl)
|
|
361
365
|
});
|
|
362
366
|
}
|
|
363
|
-
const rowsPerInsert = maxRowsPerInsert(
|
|
367
|
+
const rowsPerInsert = maxRowsPerInsert(4);
|
|
364
368
|
const stmts = [];
|
|
365
369
|
for (const batch of chunk([...byRoute.values()], rowsPerInsert)) {
|
|
366
|
-
const valuesSql = batch.map(() => `(?, ?, '', '', '', '[]', '[]',
|
|
367
|
-
const params = batch.flatMap((r) => [r.route, r.routeKey,
|
|
370
|
+
const valuesSql = batch.map(() => `(?, ?, '', '', '', '[]', '[]', '', 0, 0, 0, 'runtime', ?, ?)`).join(", ");
|
|
371
|
+
const params = batch.flatMap((r) => [r.route, r.routeKey, nowMs, r.locale]);
|
|
368
372
|
stmts.push({
|
|
369
373
|
sql: `
|
|
370
374
|
INSERT INTO ai_ready_pages (route, route_key, title, description, markdown, headings, keywords, updated_at, indexed_at, is_error, indexed, source, last_seen_at, locale)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compute content hash for change detection (first 16 chars of SHA-256)
|
|
2
|
+
* Compute content hash for change detection (first 16 chars of SHA-256).
|
|
3
|
+
* Hash the page body only. Frontmatter holds per-conversion fields
|
|
4
|
+
* (`canonical_url`, `last_updated`) that differ between the build and runtime
|
|
5
|
+
* paths, so hashing it marks unchanged pages as changed.
|
|
3
6
|
*/
|
|
4
7
|
export declare function computeContentHash(markdown: string): Promise<string>;
|
|
5
8
|
export interface DatabaseAdapter {
|
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { subtle } from "uncrypto";
|
|
2
|
+
import { isMap, isScalar, parseDocument } from "yaml";
|
|
2
3
|
import { buildSchemaSql, DROP_TABLES_SQL, resolveFtsTokenizer, SCHEMA_VERSION } from "./schema-sql.js";
|
|
4
|
+
const LEADING_BLOCK_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
5
|
+
const PAGE_FRONTMATTER_KEYS = /* @__PURE__ */ new Set(["title", "description", "meta", "canonical_url", "last_updated", "locale", "alternates"]);
|
|
6
|
+
function stripPageFrontmatter(markdown) {
|
|
7
|
+
const match = markdown.match(LEADING_BLOCK_RE);
|
|
8
|
+
if (!match)
|
|
9
|
+
return markdown;
|
|
10
|
+
const document = parseDocument(match[1]);
|
|
11
|
+
if (document.errors.length || !isMap(document.contents))
|
|
12
|
+
return markdown;
|
|
13
|
+
const isPageFrontmatter = document.contents.items.some((item) => isScalar(item.key) && PAGE_FRONTMATTER_KEYS.has(String(item.key.value)));
|
|
14
|
+
return isPageFrontmatter ? markdown.slice(match[0].length) : markdown;
|
|
15
|
+
}
|
|
3
16
|
export async function computeContentHash(markdown) {
|
|
4
17
|
const encoder = new TextEncoder();
|
|
5
|
-
const data = encoder.encode(markdown);
|
|
18
|
+
const data = encoder.encode(stripPageFrontmatter(markdown).trim());
|
|
6
19
|
const hashBuffer = await subtle.digest("SHA-256", data);
|
|
7
20
|
const hashArray = [...new Uint8Array(hashBuffer)];
|
|
8
21
|
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { createError, defineEventHandler, setHeader, setResponseStatus } from "#nuxtseo/h3";
|
|
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
4
|
import { logger } from "../logger.js";
|
|
5
5
|
import { computeLocaleAlternates, resolveLocaleFromRoute } from "../utils/i18n.js";
|
|
6
|
+
import { resolveMarkdownRedirect } from "../utils/markdown-redirect.js";
|
|
6
7
|
import { INTERNAL_HEADER } from "../utils/negotiation-decision.js";
|
|
7
8
|
import { applyNegotiation, buildNegotiationContext, decideNegotiation, ensureSiteConfig, setMarkdownHeaders } from "../utils/negotiation-response.js";
|
|
8
9
|
import { appendSitemapSection, isSitemapMdRequest, SITEMAP_MD_ROUTE } from "../utils/sitemap-md.js";
|
|
@@ -30,7 +31,6 @@ function notFoundMarkdown(ctx, canonicalUrl, build) {
|
|
|
30
31
|
title: "Page not found",
|
|
31
32
|
description: `No content is available at ${path}.`,
|
|
32
33
|
canonical_url: canonicalUrl,
|
|
33
|
-
last_updated: (/* @__PURE__ */ new Date()).toISOString(),
|
|
34
34
|
locale,
|
|
35
35
|
alternates
|
|
36
36
|
});
|
|
@@ -67,7 +67,7 @@ export default defineEventHandler(async (event) => {
|
|
|
67
67
|
title: title ?? path,
|
|
68
68
|
description,
|
|
69
69
|
canonical_url: canonicalUrl,
|
|
70
|
-
last_updated: updatedAt
|
|
70
|
+
last_updated: updatedAt
|
|
71
71
|
}, markdown);
|
|
72
72
|
setMarkdownHeaders(event, ctx);
|
|
73
73
|
return finalizeMarkdown(responseMarkdown);
|
|
@@ -82,37 +82,49 @@ export default defineEventHandler(async (event) => {
|
|
|
82
82
|
title: contentPage.title,
|
|
83
83
|
description: contentPage.description,
|
|
84
84
|
canonical_url: canonicalUrl,
|
|
85
|
-
last_updated: contentPage.updatedAt
|
|
85
|
+
last_updated: contentPage.updatedAt
|
|
86
86
|
});
|
|
87
87
|
setMarkdownHeaders(event, ctx);
|
|
88
88
|
return finalizeMarkdown(`${frontmatter}
|
|
89
89
|
${contentPage.markdown}`);
|
|
90
90
|
}
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
91
|
+
const origin = getRequestURL(event).origin;
|
|
92
|
+
const siteUrl = resolveUrl("/");
|
|
93
|
+
const redirectOptions = {
|
|
94
|
+
pageUrl: `${origin}${resolvePath(path)}`,
|
|
95
|
+
origins: URL.canParse(siteUrl) ? [new URL(siteUrl).origin] : []
|
|
96
|
+
};
|
|
97
|
+
let htmlPath = resolvePath(path);
|
|
98
|
+
let response = null;
|
|
99
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
100
|
+
logger.debug(`[markdown] Fetching HTML for ${htmlPath}`);
|
|
101
|
+
response = await fetchRawWithEvent(event, htmlPath, {
|
|
102
|
+
headers: { [INTERNAL_HEADER]: "1" },
|
|
103
|
+
redirect: "manual"
|
|
104
|
+
}).catch((e) => {
|
|
105
|
+
logger.error(`Failed to fetch HTML for ${htmlPath}`, e);
|
|
106
|
+
return null;
|
|
107
|
+
});
|
|
108
|
+
const location = response && response.status >= 300 && response.status < 400 ? response.headers.get("location") : null;
|
|
109
|
+
if (!response || !location)
|
|
110
|
+
break;
|
|
111
|
+
const redirect = resolveMarkdownRedirect(location, redirectOptions);
|
|
112
|
+
if (redirect._tag === "follow" && attempt === 0) {
|
|
113
|
+
htmlPath = redirect.path;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
setHeader(event, "location", redirect._tag === "redirect" ? redirect.location : location);
|
|
117
|
+
return createError({
|
|
118
|
+
statusCode: response.status,
|
|
119
|
+
statusMessage: response.statusText
|
|
120
|
+
});
|
|
121
|
+
}
|
|
99
122
|
if (!response) {
|
|
100
123
|
throw createError({
|
|
101
124
|
statusCode: 502,
|
|
102
125
|
statusMessage: "Bad Gateway"
|
|
103
126
|
});
|
|
104
127
|
}
|
|
105
|
-
if (response.status >= 300 && response.status < 400) {
|
|
106
|
-
const location = response.headers.get("location");
|
|
107
|
-
if (location) {
|
|
108
|
-
const redirectTarget = location.endsWith("/") ? `${location.slice(0, -1)}.md` : `${location}.md`;
|
|
109
|
-
setHeader(event, "location", redirectTarget);
|
|
110
|
-
return createError({
|
|
111
|
-
statusCode: response.status,
|
|
112
|
-
statusMessage: response.statusText
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
128
|
if (!response.ok && response.status !== 404)
|
|
117
129
|
return response;
|
|
118
130
|
if (response.status === 404) {
|
|
@@ -139,11 +151,12 @@ ${contentPage.markdown}`);
|
|
|
139
151
|
logger.debug(`[markdown] DB lookup failed for ${path}:`, err);
|
|
140
152
|
return void 0;
|
|
141
153
|
});
|
|
142
|
-
const lastUpdated = dbPage?.updatedAt || extractLastUpdated(html)
|
|
154
|
+
const lastUpdated = dbPage?.updatedAt || extractLastUpdated(html);
|
|
143
155
|
const additionalFrontmatter = {
|
|
144
|
-
canonical_url: canonicalUrl
|
|
145
|
-
last_updated: lastUpdated
|
|
156
|
+
canonical_url: canonicalUrl
|
|
146
157
|
};
|
|
158
|
+
if (lastUpdated)
|
|
159
|
+
additionalFrontmatter.last_updated = lastUpdated;
|
|
147
160
|
if (config.i18n) {
|
|
148
161
|
additionalFrontmatter.locale = resolveLocaleFromRoute(path, config.i18n, routeContext).locale;
|
|
149
162
|
}
|
|
@@ -45,12 +45,11 @@ export default defineEventHandler(async (event) => {
|
|
|
45
45
|
});
|
|
46
46
|
if (contentPage) {
|
|
47
47
|
logger.debug(`[markdown.prerender] Using content source for ${path} (${contentPage.markdown.length} bytes)`);
|
|
48
|
-
const lastUpdated2 = contentPage.updatedAt || (/* @__PURE__ */ new Date()).toISOString();
|
|
49
48
|
const frontmatter = buildFrontmatter({
|
|
50
49
|
title: contentPage.title,
|
|
51
50
|
description: contentPage.description,
|
|
52
51
|
canonical_url: canonicalUrl,
|
|
53
|
-
last_updated:
|
|
52
|
+
last_updated: contentPage.updatedAt
|
|
54
53
|
});
|
|
55
54
|
const markdown = `${frontmatter}
|
|
56
55
|
${contentPage.markdown}`;
|
|
@@ -62,7 +61,8 @@ ${contentPage.markdown}`;
|
|
|
62
61
|
description: contentPage.description || "",
|
|
63
62
|
headings,
|
|
64
63
|
keywords: keywords2,
|
|
65
|
-
|
|
64
|
+
// Only a real content date feeds the page index and sitemap lastmod.
|
|
65
|
+
...contentPage.updatedAt && { updatedAt: contentPage.updatedAt }
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
let html = consumePrerenderedHtml(path);
|
|
@@ -97,7 +97,7 @@ ${contentPage.markdown}`;
|
|
|
97
97
|
message: `Page rendered as error: ${path}`
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
|
-
const lastUpdated = extractLastUpdated(html)
|
|
100
|
+
const lastUpdated = extractLastUpdated(html);
|
|
101
101
|
const result = await convertHtmlToMarkdown(
|
|
102
102
|
html,
|
|
103
103
|
canonicalUrl,
|
|
@@ -106,7 +106,7 @@ ${contentPage.markdown}`;
|
|
|
106
106
|
extractUpdatedAt: true,
|
|
107
107
|
additionalFrontmatter: {
|
|
108
108
|
canonical_url: canonicalUrl,
|
|
109
|
-
last_updated: lastUpdated
|
|
109
|
+
...lastUpdated && { last_updated: lastUpdated }
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
);
|
|
@@ -6,6 +6,7 @@ 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
|
+
import { resolveIndexedUpdatedAt, resolvePageUpdatedAt } from "./page-updated-at.js";
|
|
9
10
|
export async function indexPage(route, html, options = {}, event) {
|
|
10
11
|
const config = useRuntimeConfig()["nuxt-ai-ready"];
|
|
11
12
|
const ttl = options.ttl ?? config.runtimeSync.ttl;
|
|
@@ -19,11 +20,17 @@ export async function indexPage(route, html, options = {}, event) {
|
|
|
19
20
|
const { siteUrl } = createUniversalContext(event);
|
|
20
21
|
const fullUrl = siteUrl ? `${siteUrl}${route}` : route;
|
|
21
22
|
const result = await convertHtmlToMarkdown(html, fullUrl, config.mdreamOptions, { extractUpdatedAt: true });
|
|
22
|
-
const updatedAt = result.updatedAt || (/* @__PURE__ */ new Date()).toISOString();
|
|
23
23
|
const headings = JSON.stringify(result.headings);
|
|
24
24
|
const keywords = extractKeywords(result.textContent, result.metaKeywords);
|
|
25
25
|
const contentHash = await computeContentHash(result.markdown);
|
|
26
26
|
const contentChanged = state?.contentHash !== contentHash;
|
|
27
|
+
const updatedAt = isError ? "" : resolveIndexedUpdatedAt({
|
|
28
|
+
declared: resolvePageUpdatedAt(void 0, result.updatedAt),
|
|
29
|
+
previous: state,
|
|
30
|
+
contentHash,
|
|
31
|
+
now: /* @__PURE__ */ new Date(),
|
|
32
|
+
detectDrift: config.runtimeSync.enabled
|
|
33
|
+
});
|
|
27
34
|
await upsertPage(event, {
|
|
28
35
|
route,
|
|
29
36
|
title: result.title,
|
|
@@ -92,7 +99,7 @@ export async function indexPageByRoute(route, event, options = {}) {
|
|
|
92
99
|
markdown: "",
|
|
93
100
|
headings: "[]",
|
|
94
101
|
keywords: [],
|
|
95
|
-
updatedAt:
|
|
102
|
+
updatedAt: "",
|
|
96
103
|
isError: true
|
|
97
104
|
});
|
|
98
105
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a `.md` request does with an upstream redirect of its HTML page.
|
|
3
|
+
*
|
|
4
|
+
* `redirect` sends the client to `location`. `follow` means the target is the
|
|
5
|
+
* same markdown document as the request, as with a trailing-slash redirect, so
|
|
6
|
+
* the handler converts the HTML at `path` instead of redirecting to itself.
|
|
7
|
+
*/
|
|
8
|
+
export type MarkdownRedirect = {
|
|
9
|
+
_tag: 'redirect';
|
|
10
|
+
location: string;
|
|
11
|
+
} | {
|
|
12
|
+
_tag: 'follow';
|
|
13
|
+
path: string;
|
|
14
|
+
};
|
|
15
|
+
export interface MarkdownRedirectOptions {
|
|
16
|
+
/** Absolute URL of the HTML page that answered with the redirect. */
|
|
17
|
+
pageUrl: string;
|
|
18
|
+
/** Other origins that serve this site, such as the configured site URL. */
|
|
19
|
+
origins?: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Map an upstream `Location` onto the markdown sibling of its target.
|
|
23
|
+
*
|
|
24
|
+
* A target on another origin, or one without a markdown sibling, keeps its
|
|
25
|
+
* original location. Query and fragment carry over to the sibling.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveMarkdownRedirect(location: string, { pageUrl, origins }: MarkdownRedirectOptions): MarkdownRedirect;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { markdownAlternatePath, toMarkdownPath } from "../../markdown-path.js";
|
|
2
|
+
const RE_ABSOLUTE_URL = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
|
|
3
|
+
export function resolveMarkdownRedirect(location, { pageUrl, origins = [] }) {
|
|
4
|
+
const page = new URL(pageUrl);
|
|
5
|
+
const target = URL.canParse(location, page) ? new URL(location, page) : null;
|
|
6
|
+
if (!target || target.origin !== page.origin && !origins.includes(target.origin))
|
|
7
|
+
return { _tag: "redirect", location };
|
|
8
|
+
const markdownPath = markdownAlternatePath(target.pathname);
|
|
9
|
+
if (!markdownPath)
|
|
10
|
+
return { _tag: "redirect", location };
|
|
11
|
+
if (markdownPath === toMarkdownPath(page.pathname))
|
|
12
|
+
return { _tag: "follow", path: `${target.pathname}${target.search}` };
|
|
13
|
+
const markdownLocation = `${markdownPath}${target.search}${target.hash}`;
|
|
14
|
+
return {
|
|
15
|
+
_tag: "redirect",
|
|
16
|
+
location: RE_ABSOLUTE_URL.test(location) ? `${target.origin}${markdownLocation}` : markdownLocation
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve when a page's content last changed, from sources that know it.
|
|
3
|
+
*
|
|
4
|
+
* Returns an empty string when no source gives a valid date. The index time is
|
|
5
|
+
* never a fallback: it moves on every deploy or re-index, so a sitemap lastmod
|
|
6
|
+
* built from it tells crawlers every page changed, and they learn to ignore it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolvePageUpdatedAt(sitemapLastmod: string | Date | undefined, metaUpdatedAt: string | undefined): string;
|
|
9
|
+
export interface StoredPageDate {
|
|
10
|
+
contentHash: string | null;
|
|
11
|
+
updatedAt: string;
|
|
12
|
+
source: 'prerender' | 'runtime';
|
|
13
|
+
}
|
|
14
|
+
export interface IndexedUpdatedAtInput {
|
|
15
|
+
/** Date the page declares itself, already resolved. Empty when none. */
|
|
16
|
+
declared: string;
|
|
17
|
+
/** The row before this index, if any. */
|
|
18
|
+
previous: StoredPageDate | undefined;
|
|
19
|
+
contentHash: string;
|
|
20
|
+
now: Date;
|
|
21
|
+
/** Only true when runtime indexing runs, so two indexes can be compared. */
|
|
22
|
+
detectDrift: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Date a runtime-indexed page. A declared date always wins. Otherwise a page
|
|
26
|
+
* gets the detection time only when its body hash differs from an earlier
|
|
27
|
+
* runtime index of the same page. A prerender hash comes from another pipeline,
|
|
28
|
+
* so a difference there proves nothing and never produces a date.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveIndexedUpdatedAt(input: IndexedUpdatedAtInput): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function toIsoDate(value) {
|
|
2
|
+
if (!value)
|
|
3
|
+
return "";
|
|
4
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
5
|
+
return Number.isNaN(date.getTime()) ? "" : date.toISOString();
|
|
6
|
+
}
|
|
7
|
+
export function resolvePageUpdatedAt(sitemapLastmod, metaUpdatedAt) {
|
|
8
|
+
return toIsoDate(metaUpdatedAt) || toIsoDate(sitemapLastmod);
|
|
9
|
+
}
|
|
10
|
+
export function resolveIndexedUpdatedAt(input) {
|
|
11
|
+
if (input.declared)
|
|
12
|
+
return input.declared;
|
|
13
|
+
if (!input.detectDrift)
|
|
14
|
+
return "";
|
|
15
|
+
const { previous } = input;
|
|
16
|
+
if (!previous?.contentHash)
|
|
17
|
+
return "";
|
|
18
|
+
if (previous.source === "runtime" && previous.contentHash !== input.contentHash)
|
|
19
|
+
return input.now.toISOString();
|
|
20
|
+
return previous.updatedAt;
|
|
21
|
+
}
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -452,7 +452,7 @@ export interface MarkdownSource {
|
|
|
452
452
|
title?: string;
|
|
453
453
|
/** Page description. */
|
|
454
454
|
description?: string;
|
|
455
|
-
/** ISO timestamp for `last_updated`.
|
|
455
|
+
/** ISO timestamp for `last_updated`. Omitted when not set. */
|
|
456
456
|
updatedAt?: string;
|
|
457
457
|
}
|
|
458
458
|
/**
|
|
@@ -536,7 +536,7 @@ export interface PageIndexedContext {
|
|
|
536
536
|
keywords: string[];
|
|
537
537
|
/** Full markdown content */
|
|
538
538
|
markdown: string;
|
|
539
|
-
/** ISO timestamp */
|
|
539
|
+
/** ISO timestamp of the last content change from page meta. Empty string when the page gives none. */
|
|
540
540
|
updatedAt: string;
|
|
541
541
|
/** Whether this is a new page or an update */
|
|
542
542
|
isUpdate: boolean;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-ai-ready",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.4.0",
|
|
5
5
|
"description": "Best practice AI & LLM discoverability for Nuxt sites.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Harlan Wilton",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"webmcp.d.ts"
|
|
46
46
|
],
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@libsql/client": "^0.
|
|
48
|
+
"@libsql/client": "^0.18.0",
|
|
49
49
|
"@neondatabase/serverless": "^1.0.0",
|
|
50
50
|
"@nuxtjs/sitemap": ">=8.3.1",
|
|
51
51
|
"better-sqlite3": "^11.0.0 || ^12.0.0 || ^13.0.0",
|
|
@@ -66,62 +66,62 @@
|
|
|
66
66
|
}
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@mdream/js": "^1.7.
|
|
69
|
+
"@mdream/js": "^1.7.2",
|
|
70
70
|
"@nuxt/kit": "^4.5.2",
|
|
71
71
|
"citty": "^0.2.2",
|
|
72
72
|
"consola": "^3.4.2",
|
|
73
73
|
"defu": "^6.1.7",
|
|
74
74
|
"drizzle-orm": "1.0.0-rc.4",
|
|
75
|
-
"mdream": "^1.7.
|
|
75
|
+
"mdream": "^1.7.2",
|
|
76
76
|
"nuxt-site-config": "^4.2.3",
|
|
77
|
-
"nuxtseo-shared": "^5.3.
|
|
77
|
+
"nuxtseo-shared": "^5.3.16",
|
|
78
78
|
"pathe": "^2.0.3",
|
|
79
|
-
"pkg-types": "^2.3.
|
|
79
|
+
"pkg-types": "^2.3.3",
|
|
80
80
|
"site-config-stack": "^4.2.3",
|
|
81
81
|
"sitemapd": "^0.2.2",
|
|
82
82
|
"ufo": "^1.6.4",
|
|
83
83
|
"uncrypto": "^0.1.3",
|
|
84
|
-
"yaml": "^2.9.
|
|
84
|
+
"yaml": "^2.9.1"
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
87
|
"@antfu/eslint-config": "^9.5.1",
|
|
88
88
|
"@arethetypeswrong/cli": "^0.18.5",
|
|
89
89
|
"@harlan-zw/comark-content": "^0.1.5",
|
|
90
90
|
"@libsql/client": "^0.18.0",
|
|
91
|
-
"@nuxt/content": "^3.16.
|
|
91
|
+
"@nuxt/content": "^3.16.1",
|
|
92
92
|
"@nuxt/module-builder": "^1.0.3",
|
|
93
|
-
"@nuxt/test-utils": "^4.2
|
|
93
|
+
"@nuxt/test-utils": "^4.3.2",
|
|
94
94
|
"@nuxtjs/eslint-config-typescript": "^12.1.0",
|
|
95
|
-
"@nuxtjs/mcp-toolkit": "^0.
|
|
96
|
-
"@nuxtjs/robots": "^6.2.
|
|
97
|
-
"@nuxtjs/sitemap": "^8.5.
|
|
95
|
+
"@nuxtjs/mcp-toolkit": "^0.21.0",
|
|
96
|
+
"@nuxtjs/robots": "^6.2.3",
|
|
97
|
+
"@nuxtjs/sitemap": "^8.5.1",
|
|
98
98
|
"@types/better-sqlite3": "^9.6.0",
|
|
99
|
-
"@vitest/coverage-v8": "^5.0.
|
|
100
|
-
"@vue/test-utils": "^2.5.
|
|
101
|
-
"@vueuse/nuxt": "^
|
|
99
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
100
|
+
"@vue/test-utils": "^2.5.1",
|
|
101
|
+
"@vueuse/nuxt": "^15.0.0",
|
|
102
102
|
"better-sqlite3": "^13.0.3",
|
|
103
103
|
"bumpp": "^12.3.0",
|
|
104
|
-
"eslint": "^10.
|
|
105
|
-
"eslint-plugin-harlanzw": "^0.
|
|
104
|
+
"eslint": "^10.11.0",
|
|
105
|
+
"eslint-plugin-harlanzw": "^0.23.0",
|
|
106
106
|
"execa": "^10.0.1",
|
|
107
107
|
"h3": "^1.15.11",
|
|
108
|
-
"happy-dom": "^20.14.
|
|
108
|
+
"happy-dom": "^20.14.5",
|
|
109
109
|
"nitropack": "^2.13.4",
|
|
110
110
|
"nuxt": "^4.5.2",
|
|
111
111
|
"nuxt-site-config": "^4.2.3",
|
|
112
|
-
"nuxtseo-layer-devtools": "^5.3.
|
|
112
|
+
"nuxtseo-layer-devtools": "^5.3.16",
|
|
113
113
|
"playwright": "^1.63.0",
|
|
114
114
|
"playwright-core": "^1.63.0",
|
|
115
115
|
"postgres": "^3.4.9",
|
|
116
116
|
"tinyglobby": "^0.2.17",
|
|
117
117
|
"typescript": "6.0.3",
|
|
118
118
|
"unbuild": "^3.6.1",
|
|
119
|
-
"vitest": "^5.0.
|
|
120
|
-
"vue": "^3.5.
|
|
119
|
+
"vitest": "^5.0.1",
|
|
120
|
+
"vue": "^3.5.43",
|
|
121
121
|
"vue-router": "^5.3.1",
|
|
122
122
|
"vue-tsc": "^3.3.11",
|
|
123
|
-
"wrangler": "^4.
|
|
124
|
-
"zod": "^4.5
|
|
123
|
+
"wrangler": "^4.136.3",
|
|
124
|
+
"zod": "^4.6.5"
|
|
125
125
|
},
|
|
126
126
|
"scripts": {
|
|
127
127
|
"lint": "eslint .",
|