nuxt-ai-ready 3.2.0 → 3.3.1
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 +197 -28
- package/dist/cli.mjs +259 -0
- package/dist/module.d.mts +1 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +2 -1
- package/dist/runtime/llms-txt-files.d.ts +30 -0
- package/dist/runtime/llms-txt-files.js +196 -0
- package/dist/runtime/llms-txt-format.js +5 -4
- package/dist/runtime/llms-txt-utils.d.ts +3 -2
- package/dist/runtime/llms-txt-utils.js +85 -224
- package/dist/runtime/markdown-text.d.ts +2 -0
- package/dist/runtime/markdown-text.js +3 -0
- package/dist/runtime/server/db/drizzle/queries.d.ts +2 -6
- package/dist/runtime/server/db/drizzle/queries.js +30 -10
- package/dist/runtime/server/db/queries.d.ts +5 -6
- package/dist/runtime/server/db/queries.js +40 -9
- package/dist/runtime/server/db/schema/postgres.d.ts +30 -0
- package/dist/runtime/server/db/schema/postgres.js +1 -0
- package/dist/runtime/server/db/schema/sqlite.d.ts +32 -0
- package/dist/runtime/server/db/schema/sqlite.js +1 -0
- package/dist/runtime/server/db/schema-sql.d.ts +1 -1
- package/dist/runtime/server/db/schema-sql.js +2 -1
- package/dist/runtime/server/db/seed.d.ts +8 -0
- package/dist/runtime/server/db/seed.js +16 -0
- package/dist/runtime/server/db/shared.d.ts +3 -3
- package/dist/runtime/server/db/shared.js +19 -1
- package/dist/runtime/server/middleware/markdown.js +1 -1
- package/dist/runtime/server/plugins/sitemap-seeder.js +9 -1
- package/dist/runtime/server/routes/llms-full.txt.get.js +2 -1
- package/dist/runtime/server/routes/llms.txt.get.js +21 -11
- package/dist/runtime/server/routes/sitemap.md.get.js +2 -1
- package/dist/runtime/server/utils/llms-full.js +31 -5
- package/dist/runtime/server/utils/markdown-request.js +2 -5
- package/dist/runtime/server/utils/negotiation-response.js +3 -0
- package/dist/runtime/server/utils/runCron.js +1 -1
- package/dist/runtime/server/utils/sitemap-lastmod.d.ts +4 -0
- package/dist/runtime/server/utils/sitemap-lastmod.js +19 -0
- package/dist/runtime/server/utils/sitemap-md.js +3 -2
- package/dist/runtime/server/utils/sitemap.d.ts +1 -0
- package/dist/runtime/server/utils/sitemap.js +26 -12
- package/dist/runtime/server/utils.js +50 -2
- package/dist/runtime/types.d.ts +13 -1
- package/dist/shared/{nuxt-ai-ready.DH9mSayy.mjs → nuxt-ai-ready.CKXM1jnE.mjs} +40 -12
- package/package.json +3 -3
- package/skills/nuxt-ai-ready/SKILL.md +34 -1
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
import { joinURL } from "ufo";
|
|
2
|
+
import { isMap, isScalar, parseDocument, stringify } from "yaml";
|
|
2
3
|
import { normalizeLlmsTxtConfig } from "../../llms-txt-format.js";
|
|
3
|
-
|
|
4
|
+
import { escapeMarkdownHtml } from "../../markdown-text.js";
|
|
5
|
+
const RE_FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n*/;
|
|
4
6
|
const RE_INLINE_WHITESPACE = /\s+/g;
|
|
7
|
+
function imageMetadataFence(frontmatter) {
|
|
8
|
+
const document = parseDocument(frontmatter);
|
|
9
|
+
const meta = document.get("meta", true);
|
|
10
|
+
if (document.errors.length || !isMap(meta))
|
|
11
|
+
return;
|
|
12
|
+
const images = {};
|
|
13
|
+
for (const { key, value } of meta.items) {
|
|
14
|
+
if (!isScalar(key) || typeof key.value !== "string" || !isScalar(value))
|
|
15
|
+
continue;
|
|
16
|
+
if (key.value !== "og:image" && !key.value.startsWith("og:image:") && key.value !== "twitter:image" && !key.value.startsWith("twitter:image:"))
|
|
17
|
+
continue;
|
|
18
|
+
if (typeof value.value === "string" || typeof value.value === "number" && Number.isFinite(value.value))
|
|
19
|
+
images[key.value] = value.value;
|
|
20
|
+
}
|
|
21
|
+
if (!Object.keys(images).length)
|
|
22
|
+
return;
|
|
23
|
+
return `\`\`\`yaml
|
|
24
|
+
${stringify({ meta: images }, { lineWidth: 0 }).trimEnd()}
|
|
25
|
+
\`\`\``;
|
|
26
|
+
}
|
|
5
27
|
export function formatPageForLlmsFullTxt(route, title, description, markdown, siteUrl) {
|
|
6
28
|
const canonicalUrl = siteUrl ? joinURL(siteUrl, route) : route;
|
|
7
|
-
const pageTitle = (title && title !== route ? title : route).trim().replace(RE_INLINE_WHITESPACE, " ");
|
|
8
|
-
const pageDescription = description.trim().replace(RE_INLINE_WHITESPACE, " ");
|
|
29
|
+
const pageTitle = escapeMarkdownHtml((title && title !== route ? title : route).trim().replace(RE_INLINE_WHITESPACE, " "));
|
|
30
|
+
const pageDescription = escapeMarkdownHtml(description.trim().replace(RE_INLINE_WHITESPACE, " "));
|
|
9
31
|
const content = markdown.replace(RE_FRONTMATTER, "").trim();
|
|
10
32
|
const parts = ["---", "", `- **Page:** ${pageTitle}`, `- **Source:** ${canonicalUrl}`];
|
|
11
33
|
if (pageDescription)
|
|
12
34
|
parts.push(`- **Description:** ${pageDescription}`);
|
|
35
|
+
const frontmatter = markdown.match(RE_FRONTMATTER)?.[1];
|
|
36
|
+
const images = frontmatter && imageMetadataFence(frontmatter);
|
|
37
|
+
if (images)
|
|
38
|
+
parts.push("", images);
|
|
13
39
|
parts.push("");
|
|
14
40
|
if (content) {
|
|
15
41
|
parts.push(content);
|
|
@@ -19,9 +45,9 @@ export function formatPageForLlmsFullTxt(route, title, description, markdown, si
|
|
|
19
45
|
`;
|
|
20
46
|
}
|
|
21
47
|
export function buildLlmsFullTxtHeader(siteInfo, llmsTxtConfig) {
|
|
22
|
-
const parts = [`# ${siteInfo?.name || siteInfo?.url || "Site"}`];
|
|
48
|
+
const parts = [`# ${escapeMarkdownHtml(siteInfo?.name || siteInfo?.url || "Site")}`];
|
|
23
49
|
if (siteInfo?.description)
|
|
24
|
-
parts.push("", `> ${siteInfo.description}`);
|
|
50
|
+
parts.push("", `> ${escapeMarkdownHtml(siteInfo.description)}`);
|
|
25
51
|
if (siteInfo?.url)
|
|
26
52
|
parts.push("", `Canonical Origin: ${siteInfo.url}`);
|
|
27
53
|
if (llmsTxtConfig) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { negotiateContent, parseAcceptHeader } 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";
|
|
4
|
+
import { hasMarkdownTwin, isReservedPath } from "../../markdown-path.js";
|
|
5
5
|
const RE_MD_EXT = /\.md$/;
|
|
6
6
|
export function toMarkdownRequest(event, isPrerender = !!import.meta.prerender) {
|
|
7
7
|
return { path: event.path, headers: getHeaders(event), isPrerender };
|
|
@@ -38,10 +38,7 @@ export function getRequestRenderInfo(request, mode = { _tag: "runtime", contentN
|
|
|
38
38
|
const isExplicit = originalPath.endsWith(".md");
|
|
39
39
|
if (isPrerender && !isExplicit)
|
|
40
40
|
return null;
|
|
41
|
-
|
|
42
|
-
const hasExtension = lastSegment.includes(".");
|
|
43
|
-
const extension = hasExtension ? lastSegment.substring(lastSegment.lastIndexOf(".")) : "";
|
|
44
|
-
if (hasExtension && extension !== ".md")
|
|
41
|
+
if (!isExplicit && !hasMarkdownTwin(originalPath))
|
|
45
42
|
return null;
|
|
46
43
|
const negotiation = isPrerender ? "markdown" : mode.contentNegotiation ? negotiateRequestRepresentation(request, mode.botNegotiation) : "html";
|
|
47
44
|
if (isExplicit) {
|
|
@@ -52,6 +52,9 @@ export function setUncacheableHeaders(event) {
|
|
|
52
52
|
}
|
|
53
53
|
export function setMarkdownHeaders(event, ctx, sourceHeaders) {
|
|
54
54
|
setHeader(event, "content-type", "text/markdown; charset=utf-8");
|
|
55
|
+
const sourcePolicy = sourceHeaders?.get("content-signal");
|
|
56
|
+
if (sourcePolicy !== null && sourcePolicy !== void 0 && getResponseHeader(event, "content-signal") === void 0)
|
|
57
|
+
setHeader(event, "content-signal", sourcePolicy);
|
|
55
58
|
const vary = [getResponseHeader(event, "vary"), sourceHeaders?.get("vary")].filter(Boolean).join(", ");
|
|
56
59
|
setHeader(event, "vary", mergeVaryHeader(vary, "Accept"));
|
|
57
60
|
setLinkHeader(event, ctx, "markdown");
|
|
@@ -170,7 +170,7 @@ async function pingSitemap(event, config, sitemapIntervalMinutes, debug) {
|
|
|
170
170
|
route: nextSitemap.route,
|
|
171
171
|
state: nextSitemap.crawlState
|
|
172
172
|
});
|
|
173
|
-
const routes = [...mapSitemapRoutes(result.urls).entries()].map(([route, url]) => ({ route, url: url.loc }));
|
|
173
|
+
const routes = [...mapSitemapRoutes(result.urls).entries()].map(([route, url]) => ({ route, url: url.loc, lastmod: url.lastmod }));
|
|
174
174
|
if (routes.length > 0)
|
|
175
175
|
await seedRoutes(event, routes, { refreshWindowMs });
|
|
176
176
|
if (result._tag === "failed") {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Mark dates added by this module without changing the public sitemap date. */
|
|
2
|
+
export declare function markIndexedSitemapLastmods(xml: string, enrichedUrls: ReadonlySet<string>): string;
|
|
3
|
+
/** The source crawl must never treat our indexed date as a CMS change. */
|
|
4
|
+
export declare function stripIndexedSitemapLastmods(body: Uint8Array): Uint8Array;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const INDEXED_LASTMOD_MARKER = "<!-- nuxt-ai-ready:indexed-lastmod -->";
|
|
2
|
+
export function markIndexedSitemapLastmods(xml, enrichedUrls) {
|
|
3
|
+
const escapedUrls = new Set([...enrichedUrls].map((url) => url.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'")));
|
|
4
|
+
return xml.replace(/<url>([\s\S]*?)<\/url>/g, (entry, body) => {
|
|
5
|
+
const loc = body.match(/<loc>([^<]*)<\/loc>/)?.[1];
|
|
6
|
+
return loc && escapedUrls.has(loc) ? entry.replace("<lastmod>", `${INDEXED_LASTMOD_MARKER}<lastmod>`) : entry;
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export function stripIndexedSitemapLastmods(body) {
|
|
10
|
+
let xml;
|
|
11
|
+
try {
|
|
12
|
+
xml = new TextDecoder("utf-8", { fatal: true }).decode(body);
|
|
13
|
+
} catch {
|
|
14
|
+
return body;
|
|
15
|
+
}
|
|
16
|
+
if (!xml.includes(INDEXED_LASTMOD_MARKER))
|
|
17
|
+
return body;
|
|
18
|
+
return new TextEncoder().encode(xml.replace(/<!-- nuxt-ai-ready:indexed-lastmod -->\s*<lastmod>[^<]*<\/lastmod>/g, ""));
|
|
19
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { toMarkdownPath } from "../../markdown-path.js";
|
|
2
|
+
import { escapeMarkdownHtml } from "../../markdown-text.js";
|
|
2
3
|
import { toDeployedRoute } from "../../route-path.js";
|
|
3
4
|
export const SITEMAP_MD_ROUTE = "/sitemap.md";
|
|
4
5
|
function topSegment(route) {
|
|
5
6
|
return route.split("/").filter(Boolean)[0] || "";
|
|
6
7
|
}
|
|
7
8
|
function escapeTitle(title) {
|
|
8
|
-
return title.replaceAll("[", "\\[").replaceAll("]", "\\]");
|
|
9
|
+
return escapeMarkdownHtml(title).replaceAll("[", "\\[").replaceAll("]", "\\]");
|
|
9
10
|
}
|
|
10
11
|
function formatEntry(entry, resolveHref) {
|
|
11
12
|
const title = escapeTitle(entry.title?.trim() || entry.route);
|
|
@@ -35,7 +36,7 @@ export function buildSitemapMd(entries, options = {}) {
|
|
|
35
36
|
return 1;
|
|
36
37
|
return a.localeCompare(b);
|
|
37
38
|
});
|
|
38
|
-
const parts = [`# ${options.siteName || "Site"} Sitemap`, "", "All pages in Markdown format."];
|
|
39
|
+
const parts = [`# ${escapeMarkdownHtml(options.siteName || "Site")} Sitemap`, "", "All pages in Markdown format."];
|
|
39
40
|
for (const key of keys) {
|
|
40
41
|
parts.push("", `## ${key || "Root"}`);
|
|
41
42
|
for (const entry of groups.get(key))
|
|
@@ -53,5 +53,6 @@ export declare function fetchSitemapByRoute(event: H3Event | undefined, route: s
|
|
|
53
53
|
* Fetch all URLs from all sitemaps (or single sitemap if not multi-sitemap)
|
|
54
54
|
* Used for backwards compatibility and llms.txt generation
|
|
55
55
|
*/
|
|
56
|
+
export declare function fetchSitemapUrlGroups(event: H3Event, names?: readonly string[]): Promise<Map<string, SitemapUrl[]>>;
|
|
56
57
|
export declare function fetchSitemapUrls(event: H3Event): Promise<SitemapUrl[]>;
|
|
57
58
|
export {};
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { createSitemapReader } from "sitemapd";
|
|
2
2
|
import { parseSitemap } from "sitemapd/parse";
|
|
3
|
+
import staticSitemapConfig from "#ai-ready-virtual/sitemap-config.mjs";
|
|
4
|
+
import { getRequestURL } from "#nuxtseo/h3";
|
|
3
5
|
import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
4
6
|
import { logger } from "../logger.js";
|
|
5
7
|
import { getCfEnv, hasAssets } from "./cloudflare.js";
|
|
6
8
|
import { createUniversalContext } from "./context.js";
|
|
7
9
|
import { fetchRawWithEvent } from "./fetch.js";
|
|
10
|
+
import { stripIndexedSitemapLastmods } from "./sitemap-lastmod.js";
|
|
8
11
|
const FETCH_TIMEOUT = 15e3;
|
|
9
12
|
const SITEMAP_FALLBACK_ORIGIN = "http://localhost";
|
|
10
13
|
const SITEMAP_READER_LIMITS = {
|
|
@@ -19,17 +22,18 @@ const SITEMAP_READER_LIMITS = {
|
|
|
19
22
|
const MAX_SITEMAP_CRAWL_ROUNDS = 10;
|
|
20
23
|
export function getSitemapsFromConfig(event) {
|
|
21
24
|
const runtimeConfig = useRuntimeConfig(event);
|
|
22
|
-
const sitemapConfig = runtimeConfig.sitemap;
|
|
25
|
+
const sitemapConfig = { ...staticSitemapConfig, ...runtimeConfig.sitemap };
|
|
23
26
|
if (!sitemapConfig?.sitemaps)
|
|
24
27
|
return [];
|
|
25
28
|
const sitemaps = [];
|
|
26
29
|
for (const [key, sitemap] of Object.entries(sitemapConfig.sitemaps)) {
|
|
27
30
|
if (key === "index")
|
|
28
31
|
continue;
|
|
29
|
-
|
|
32
|
+
const route = sitemap._route || sitemap.route || (key === "chunks" ? sitemapConfig.sitemaps.index?._route || sitemapConfig.sitemaps.index?.route : void 0);
|
|
33
|
+
if (route) {
|
|
30
34
|
sitemaps.push({
|
|
31
|
-
name: sitemap.sitemapName || key,
|
|
32
|
-
route:
|
|
35
|
+
name: (sitemap.sitemapName || key).replace(/\.xml$/, ""),
|
|
36
|
+
route: route.startsWith("/") ? route : `/${route}`
|
|
33
37
|
});
|
|
34
38
|
}
|
|
35
39
|
}
|
|
@@ -218,11 +222,12 @@ function createLocalSitemapLoader(event, usePublicAsset, loadedBodies) {
|
|
|
218
222
|
bytesRead: read.result.bytesRead
|
|
219
223
|
};
|
|
220
224
|
}
|
|
221
|
-
|
|
225
|
+
const sourceBody = stripIndexedSitemapLastmods(read.result.body);
|
|
226
|
+
loadedBodies.set(request.url, sourceBody);
|
|
222
227
|
return {
|
|
223
228
|
_tag: "body",
|
|
224
229
|
url: request.url,
|
|
225
|
-
body:
|
|
230
|
+
body: sourceBody
|
|
226
231
|
};
|
|
227
232
|
};
|
|
228
233
|
}
|
|
@@ -305,6 +310,7 @@ export async function crawlSitemapByRoute(event, input) {
|
|
|
305
310
|
const usePublicAsset = config.sitemapPrerendered && hasAssets(event);
|
|
306
311
|
const configuredSiteUrl = createUniversalContext(event).siteUrl ?? SITEMAP_FALLBACK_ORIGIN;
|
|
307
312
|
const siteUrl = parseAbsoluteUrl(configuredSiteUrl);
|
|
313
|
+
const requestOrigin = import.meta.dev && event ? getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).origin : void 0;
|
|
308
314
|
if (siteUrl._tag === "error") {
|
|
309
315
|
return {
|
|
310
316
|
_tag: "failed",
|
|
@@ -346,7 +352,7 @@ export async function crawlSitemapByRoute(event, input) {
|
|
|
346
352
|
const target = parseAbsoluteUrl(request.url);
|
|
347
353
|
if (target._tag === "error")
|
|
348
354
|
return { _tag: "deny", reason: `Invalid sitemap target: ${request.url}` };
|
|
349
|
-
if (target.value.origin !== siteUrl.value.origin) {
|
|
355
|
+
if (target.value.origin !== siteUrl.value.origin && target.value.origin !== requestOrigin) {
|
|
350
356
|
return {
|
|
351
357
|
_tag: "deny",
|
|
352
358
|
reason: `Sitemap target origin ${target.value.origin} differs from ${siteUrl.value.origin}`
|
|
@@ -433,17 +439,21 @@ export async function fetchSitemapByRoute(event, route) {
|
|
|
433
439
|
const result = await crawlSitemapByRoute(event, { route });
|
|
434
440
|
return result._tag === "complete" ? { urls: result.urls } : { urls: result.urls, error: result.error };
|
|
435
441
|
}
|
|
436
|
-
export async function
|
|
442
|
+
export async function fetchSitemapUrlGroups(event, names) {
|
|
437
443
|
const sitemaps = getSitemapsFromConfig(event);
|
|
444
|
+
const groups = /* @__PURE__ */ new Map();
|
|
438
445
|
if (sitemaps.length > 0) {
|
|
439
446
|
logger.debug(`[sitemap] Multi-sitemap mode: ${sitemaps.length} sitemaps`);
|
|
440
|
-
const allUrls = [];
|
|
441
447
|
for (const sitemap of sitemaps) {
|
|
448
|
+
if (names !== void 0 && !names.includes(sitemap.name))
|
|
449
|
+
continue;
|
|
442
450
|
const { urls: urls2 } = await fetchSitemapByRoute(event, sitemap.route);
|
|
443
|
-
|
|
451
|
+
groups.set(sitemap.name, [...groups.get(sitemap.name) || [], ...urls2]);
|
|
444
452
|
}
|
|
445
|
-
return
|
|
453
|
+
return groups;
|
|
446
454
|
}
|
|
455
|
+
if (names !== void 0 && !names.includes("sitemap"))
|
|
456
|
+
return groups;
|
|
447
457
|
logger.debug(`[sitemap] Single sitemap mode: /sitemap.xml`);
|
|
448
458
|
const { urls, error } = await fetchSitemapByRoute(event, "/sitemap.xml");
|
|
449
459
|
if (error) {
|
|
@@ -451,5 +461,9 @@ export async function fetchSitemapUrls(event) {
|
|
|
451
461
|
} else if (urls.length === 0) {
|
|
452
462
|
logger.warn("Sitemap is empty - add routes to sitemap or configure sitemap.sources in nuxt.config");
|
|
453
463
|
}
|
|
454
|
-
|
|
464
|
+
groups.set("sitemap", urls);
|
|
465
|
+
return groups;
|
|
466
|
+
}
|
|
467
|
+
export async function fetchSitemapUrls(event) {
|
|
468
|
+
return [...(await fetchSitemapUrlGroups(event)).values()].flat();
|
|
455
469
|
}
|
|
@@ -3,11 +3,57 @@ import { useNitroApp } from "#nuxtseo/nitro";
|
|
|
3
3
|
import { stripLlmsTxtDirectiveHtml } from "../llms-txt-directive.js";
|
|
4
4
|
import { hoistMetaDescription } from "./utils/frontmatter.js";
|
|
5
5
|
const RE_NBSP = /\u00A0/g;
|
|
6
|
+
const JSON_LD_MAX_BYTES = 65536;
|
|
7
|
+
const JSON_LD_MAX_BLOCKS = 16;
|
|
8
|
+
const IMAGE_META_FIELDS = [
|
|
9
|
+
"og:image",
|
|
10
|
+
"og:image:url",
|
|
11
|
+
"og:image:secure_url",
|
|
12
|
+
"og:image:type",
|
|
13
|
+
"og:image:width",
|
|
14
|
+
"og:image:height",
|
|
15
|
+
"og:image:alt",
|
|
16
|
+
"twitter:image",
|
|
17
|
+
"twitter:image:src",
|
|
18
|
+
"twitter:image:alt"
|
|
19
|
+
];
|
|
20
|
+
function parseJsonLdBlock(text, remainingBytes) {
|
|
21
|
+
if (text.length > remainingBytes)
|
|
22
|
+
return { _tag: "Skipped" };
|
|
23
|
+
const content = text.trim();
|
|
24
|
+
if (!content)
|
|
25
|
+
return { _tag: "Skipped" };
|
|
26
|
+
let value;
|
|
27
|
+
try {
|
|
28
|
+
value = JSON.parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
return { _tag: "Skipped" };
|
|
31
|
+
}
|
|
32
|
+
const isObject = (item) => typeof item === "object" && item !== null && !Array.isArray(item);
|
|
33
|
+
if (!isObject(value) && !(Array.isArray(value) && value.every(isObject)))
|
|
34
|
+
return { _tag: "Skipped" };
|
|
35
|
+
const markdown = `\`\`\`json
|
|
36
|
+
${content.replace(RE_NBSP, "\\u00a0")}
|
|
37
|
+
\`\`\``;
|
|
38
|
+
const bytes = new TextEncoder().encode(markdown).byteLength + 2;
|
|
39
|
+
if (bytes > remainingBytes)
|
|
40
|
+
return { _tag: "Skipped" };
|
|
41
|
+
return { _tag: "Preserved", markdown, bytes };
|
|
42
|
+
}
|
|
6
43
|
function normalizeWhitespace(text) {
|
|
7
44
|
return text.replace(RE_NBSP, " ");
|
|
8
45
|
}
|
|
9
46
|
function buildMdreamOptions(url, mdreamOptions, meta, extractUpdatedAt, additionalFields) {
|
|
10
47
|
const extraction = {
|
|
48
|
+
"script[type]": (el) => {
|
|
49
|
+
if (el.attributes.type?.trim().toLowerCase() !== "application/ld+json" || meta.jsonLd.blocks.length >= JSON_LD_MAX_BLOCKS)
|
|
50
|
+
return;
|
|
51
|
+
const block = parseJsonLdBlock(el.textContent, JSON_LD_MAX_BYTES - meta.jsonLd.bytes);
|
|
52
|
+
if (block._tag === "Preserved") {
|
|
53
|
+
meta.jsonLd.blocks.push(block.markdown);
|
|
54
|
+
meta.jsonLd.bytes += block.bytes;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
11
57
|
'meta[name="keywords"]': (el) => {
|
|
12
58
|
meta.metaKeywords = el.attributes.content || "";
|
|
13
59
|
},
|
|
@@ -30,6 +76,7 @@ function buildMdreamOptions(url, mdreamOptions, meta, extractUpdatedAt, addition
|
|
|
30
76
|
};
|
|
31
77
|
const frontmatter = {
|
|
32
78
|
additionalFields,
|
|
79
|
+
metaFields: [...IMAGE_META_FIELDS],
|
|
33
80
|
onExtract: (fm) => {
|
|
34
81
|
if (fm.title)
|
|
35
82
|
meta.title = fm.title;
|
|
@@ -70,13 +117,14 @@ export function extractLastUpdated(html) {
|
|
|
70
117
|
return void 0;
|
|
71
118
|
}
|
|
72
119
|
export async function convertHtmlToMarkdown(html, url, mdreamOptions, opts) {
|
|
73
|
-
const meta = { title: "", description: "", metaKeywords: "", headings: [], textContent: [] };
|
|
120
|
+
const meta = { title: "", description: "", metaKeywords: "", headings: [], textContent: [], jsonLd: { blocks: [], bytes: 0 } };
|
|
74
121
|
const options = buildMdreamOptions(url, mdreamOptions, meta, opts.extractUpdatedAt ?? false, opts.additionalFrontmatter ?? {});
|
|
75
122
|
const nitroApp = useNitroApp();
|
|
76
123
|
await nitroApp.hooks.callHook("ai-ready:mdreamConfig", options);
|
|
124
|
+
const convertedMarkdown = hoistMetaDescription(htmlToMarkdown(stripLlmsTxtDirectiveHtml(html), options));
|
|
77
125
|
const context = {
|
|
78
126
|
html,
|
|
79
|
-
markdown:
|
|
127
|
+
markdown: [convertedMarkdown, ...meta.jsonLd.blocks].join("\n\n"),
|
|
80
128
|
route: opts.hooks.route,
|
|
81
129
|
title: meta.title,
|
|
82
130
|
description: meta.description,
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -185,7 +185,9 @@ export interface ModuleOptions {
|
|
|
185
185
|
/**
|
|
186
186
|
* Structured llms.txt configuration
|
|
187
187
|
*/
|
|
188
|
-
llmsTxt?: LlmsTxtConfig;
|
|
188
|
+
llmsTxt?: LlmsTxtConfig | false;
|
|
189
|
+
/** Generate and serve llms-full.txt. Set false to omit the export and its discovery link. */
|
|
190
|
+
llmsFullTxt?: boolean;
|
|
189
191
|
/**
|
|
190
192
|
* Generate a Markdown sitemap at `/sitemap.md` from indexed pages and end
|
|
191
193
|
* every Markdown page with a `## Sitemap` section linking to it.
|
|
@@ -512,6 +514,10 @@ export interface LlmsTxtLink {
|
|
|
512
514
|
* Section in llms.txt
|
|
513
515
|
*/
|
|
514
516
|
export interface LlmsTxtSection {
|
|
517
|
+
/** Generate a separate file from these sitemap names. Chunked sitemaps retain their configured name. */
|
|
518
|
+
sitemap?: string | string[];
|
|
519
|
+
/** Generated file path. Defaults to /llms/<sitemap>.txt. */
|
|
520
|
+
path?: string;
|
|
515
521
|
/** The title of the section */
|
|
516
522
|
title: string;
|
|
517
523
|
/** Description rendered in the heading-free preamble, or in each optional link note (can be array for multiple paragraphs) */
|
|
@@ -525,6 +531,12 @@ export interface LlmsTxtSection {
|
|
|
525
531
|
* Structured llms.txt configuration
|
|
526
532
|
*/
|
|
527
533
|
export interface LlmsTxtConfig {
|
|
534
|
+
/** Page admission. Sitemap URLs are authoritative by default. Use all to include stored pages outside the sitemap. */
|
|
535
|
+
pages?: 'sitemap' | 'all' | false;
|
|
536
|
+
/** Only read these sitemap names. An empty array disables sitemap page discovery. */
|
|
537
|
+
sitemaps?: string[];
|
|
538
|
+
/** Maximum characters per generated file. Large lists become linked files. Minimum 1024. Default 80000. */
|
|
539
|
+
maxLength?: number;
|
|
528
540
|
/**
|
|
529
541
|
* Rewrite automatically generated page links to their Markdown representation
|
|
530
542
|
* when the final static output contains the file or the deployment retains
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import { readFile, mkdir, writeFile
|
|
2
|
+
import { readFile, access, mkdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { dirname, basename, join, resolve, relative } from 'node:path';
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -8,6 +8,7 @@ import defu$1, { defu } from 'defu';
|
|
|
8
8
|
import { useSiteConfig, withSiteUrl } from 'nuxt-site-config/kit';
|
|
9
9
|
import { resolveNuxtContentVersion, renderNitroTypeAugmentations, setupRuntimeAliases, setupNitroRuntimeCompatibility } from 'nuxtseo-shared/kit';
|
|
10
10
|
import { r as readPackageMetadata, a as resolvePackageMetadataPath } from './nuxt-ai-ready.B3hAuOLQ.mjs';
|
|
11
|
+
import { parseLlmsTxtConfig } from '../../dist/runtime/llms-txt-files.js';
|
|
11
12
|
import { toDeployedRoute } from '../../dist/runtime/route-path.js';
|
|
12
13
|
import { SITEMAP_MD_ROUTE } from '../../dist/runtime/server/utils/sitemap-md.js';
|
|
13
14
|
import { publicCacheControl, mergeVaryHeader } from '../../dist/runtime/cache-control.js';
|
|
@@ -210,7 +211,11 @@ export {}
|
|
|
210
211
|
});
|
|
211
212
|
addTemplate({
|
|
212
213
|
filename: "types/ai-ready-virtual.d.ts",
|
|
213
|
-
getContents: () => `declare module '#ai-ready-virtual/
|
|
214
|
+
getContents: () => `declare module '#ai-ready-virtual/sitemap-config.mjs' {
|
|
215
|
+
const config: { sitemaps?: Record<string, { sitemapName?: string, route?: string, _route?: string }> }
|
|
216
|
+
export default config
|
|
217
|
+
}
|
|
218
|
+
declare module '#ai-ready-virtual/read-page-data.mjs' {
|
|
214
219
|
export function readMarkdownLinkAvailabilityFromFilesystem(): Promise<{
|
|
215
220
|
runtimeMarkdownAvailable: boolean
|
|
216
221
|
paths: string[]
|
|
@@ -1840,7 +1845,7 @@ ${details}`);
|
|
|
1840
1845
|
const llmsFullRoute = withSiteUrl("llms-full.txt", { withBase: true });
|
|
1841
1846
|
defaultLlmsTxtSections.push({
|
|
1842
1847
|
title: "LLM Resources",
|
|
1843
|
-
links: [
|
|
1848
|
+
links: config.llmsFullTxt === false ? [] : [
|
|
1844
1849
|
{
|
|
1845
1850
|
title: "Full Content",
|
|
1846
1851
|
href: llmsFullRoute,
|
|
@@ -1853,18 +1858,31 @@ ${details}`);
|
|
|
1853
1858
|
defaultLlmsTxtSections.push({
|
|
1854
1859
|
title: "Agent Skills",
|
|
1855
1860
|
description: `Skills an agent can install from this site. The index at ${indexUrl} carries a sha256 digest for each one.`,
|
|
1856
|
-
links: agentSkillsResult.links.map((link) => ({
|
|
1861
|
+
links: [{ title: "Agent Skills index", href: indexUrl, description: "Every published skill and its sha256 digest." }, ...agentSkillsResult.links.map((link) => ({
|
|
1857
1862
|
title: link.name,
|
|
1858
1863
|
href: link.source === "local" ? withSiteUrl(link.href.slice(1), { withBase: true }) : resolveExternalSkillUrl(link.href, indexUrl),
|
|
1859
1864
|
description: link.description
|
|
1860
|
-
}))
|
|
1865
|
+
}))]
|
|
1861
1866
|
});
|
|
1862
1867
|
}
|
|
1868
|
+
const directiveConfig = config.llmsTxt === false ? void 0 : config.llmsTxt?.directive;
|
|
1863
1869
|
const llmsTxtDirective = {
|
|
1864
|
-
markdown:
|
|
1865
|
-
html:
|
|
1870
|
+
markdown: directiveConfig?.markdown !== false,
|
|
1871
|
+
html: directiveConfig?.html === true
|
|
1866
1872
|
};
|
|
1873
|
+
const parsedLlmsTxt = parseLlmsTxtConfig(config.llmsTxt || {});
|
|
1874
|
+
if (parsedLlmsTxt._tag === "Invalid")
|
|
1875
|
+
throw new Error(`[nuxt-ai-ready] ${parsedLlmsTxt.message}`);
|
|
1876
|
+
const publicLlmsTxt = await Promise.all(getLayerDirectories(nuxt).map(
|
|
1877
|
+
(layer) => access(join(layer.public, "llms.txt")).then(() => true).catch((error) => {
|
|
1878
|
+
if (error.code === "ENOENT")
|
|
1879
|
+
return false;
|
|
1880
|
+
throw error;
|
|
1881
|
+
})
|
|
1882
|
+
));
|
|
1883
|
+
const generateLlmsTxt = config.llmsTxt !== false && !publicLlmsTxt.some(Boolean);
|
|
1867
1884
|
const mergedLlmsTxt = config.llmsTxt ? {
|
|
1885
|
+
...parsedLlmsTxt.config,
|
|
1868
1886
|
markdownLinks: config.llmsTxt.markdownLinks ?? true,
|
|
1869
1887
|
directive: llmsTxtDirective,
|
|
1870
1888
|
sections: [
|
|
@@ -1880,6 +1898,9 @@ ${details}`);
|
|
|
1880
1898
|
await nuxt.callHook("ai-ready:llms-txt", llmsTxtPayload);
|
|
1881
1899
|
mergedLlmsTxt.sections = llmsTxtPayload.sections;
|
|
1882
1900
|
mergedLlmsTxt.notes = llmsTxtPayload.notes.length > 0 ? llmsTxtPayload.notes : void 0;
|
|
1901
|
+
const parsedHookLlmsTxt = parseLlmsTxtConfig(mergedLlmsTxt);
|
|
1902
|
+
if (parsedHookLlmsTxt._tag === "Invalid")
|
|
1903
|
+
throw new Error(`[nuxt-ai-ready] ${parsedHookLlmsTxt.message}`);
|
|
1883
1904
|
const prerenderCacheDir = join(nuxt.options.rootDir, "node_modules/.cache/nuxt-seo/ai-ready/routes");
|
|
1884
1905
|
const buildDbPath = join(nuxt.options.buildDir, ".data/ai-ready/build.db");
|
|
1885
1906
|
const runtimeSyncConfig = typeof config.runtimeSync === "object" ? config.runtimeSync : {};
|
|
@@ -1896,6 +1917,8 @@ ${details}`);
|
|
|
1896
1917
|
if (apiCatalogRegistered)
|
|
1897
1918
|
return;
|
|
1898
1919
|
apiCatalogRegistered = true;
|
|
1920
|
+
const resources = mergedLlmsTxt.sections?.find((section) => section.title === "LLM Resources");
|
|
1921
|
+
resources?.links?.push({ title: "API catalog", href: catalog.href, description: "The public API and service descriptions." });
|
|
1899
1922
|
addServerHandler({ route: API_CATALOG_PATH, handler: resolve$1("./runtime/server/routes/api-catalog"), lazy: true });
|
|
1900
1923
|
const apiCatalogLink = `<${catalog.href}>; rel="api-catalog"`;
|
|
1901
1924
|
extendRouteRules("/**", {
|
|
@@ -2273,6 +2296,7 @@ export function trackDrizzleWork(event, work) { return work }
|
|
|
2273
2296
|
cron: !!config.cron
|
|
2274
2297
|
})}`;
|
|
2275
2298
|
nitroConfig.virtual["#ai-ready-virtual/content-lookup.mjs"] = contentLookupModule(contentSource);
|
|
2299
|
+
nitroConfig.virtual["#ai-ready-virtual/sitemap-config.mjs"] = `export { default } from '#sitemap-virtual/static-config.mjs'`;
|
|
2276
2300
|
});
|
|
2277
2301
|
nuxt.options.runtimeConfig["nuxt-ai-ready"] = {
|
|
2278
2302
|
version: version || "0.0.0",
|
|
@@ -2288,6 +2312,7 @@ export function trackDrizzleWork(event, work) { return work }
|
|
|
2288
2312
|
swr: true
|
|
2289
2313
|
}),
|
|
2290
2314
|
llmsTxt: mergedLlmsTxt,
|
|
2315
|
+
llmsFullTxt: config.llmsFullTxt !== false,
|
|
2291
2316
|
llmsTxtCacheSeconds: config.llmsTxtCacheSeconds ?? 600,
|
|
2292
2317
|
prerenderCacheDir,
|
|
2293
2318
|
database,
|
|
@@ -2335,10 +2360,13 @@ export function trackDrizzleWork(event, work) { return work }
|
|
|
2335
2360
|
mode: "server",
|
|
2336
2361
|
src: resolve$1("./runtime/app/plugins/md-alternate.server")
|
|
2337
2362
|
});
|
|
2338
|
-
|
|
2339
|
-
|
|
2363
|
+
if (generateLlmsTxt) {
|
|
2364
|
+
addServerHandler({ middleware: true, method: void 0, handler: resolve$1("./runtime/server/routes/llms.txt.get"), lazy: true });
|
|
2365
|
+
}
|
|
2366
|
+
if (config.llmsFullTxt !== false)
|
|
2367
|
+
addServerHandler({ route: "/llms-full.txt", method: void 0, handler: resolve$1("./runtime/server/routes/llms-full.txt.get"), lazy: true });
|
|
2340
2368
|
if (config.sitemapMd !== false) {
|
|
2341
|
-
addServerHandler({ route: SITEMAP_MD_ROUTE, handler: resolve$1("./runtime/server/routes/sitemap.md.get"), lazy: true });
|
|
2369
|
+
addServerHandler({ route: SITEMAP_MD_ROUTE, method: void 0, handler: resolve$1("./runtime/server/routes/sitemap.md.get"), lazy: true });
|
|
2342
2370
|
}
|
|
2343
2371
|
if (agentSkillsResult._tag === "Enabled") {
|
|
2344
2372
|
addServerHandler({ route: AGENT_SKILLS_INDEX_ROUTE, handler: resolve$1("./runtime/server/routes/agent-skills-index"), lazy: true });
|
|
@@ -2414,7 +2442,7 @@ export function trackDrizzleWork(event, work) { return work }
|
|
|
2414
2442
|
}
|
|
2415
2443
|
if (prerenderEnabled) {
|
|
2416
2444
|
const { setupPrerenderHandler } = await import('../chunks/prerender.mjs');
|
|
2417
|
-
setupPrerenderHandler(config, buildDbPath, {
|
|
2445
|
+
setupPrerenderHandler({ ...config, llmsTxt: generateLlmsTxt ? config.llmsTxt : false }, buildDbPath, {
|
|
2418
2446
|
name: siteConfig.name,
|
|
2419
2447
|
url: siteConfig.url ? withSiteUrl("/", { withBase: true }) : void 0,
|
|
2420
2448
|
description: siteConfig.description
|
|
@@ -2437,7 +2465,7 @@ export function trackDrizzleWork(event, work) { return work }
|
|
|
2437
2465
|
icon: "carbon:ai-label"
|
|
2438
2466
|
}, resolve$1, nuxt);
|
|
2439
2467
|
}
|
|
2440
|
-
for (const route of ["/llms.txt", "/llms-full.txt"]) {
|
|
2468
|
+
for (const route of [generateLlmsTxt && "/llms.txt", generateLlmsTxt && "/llms/**", config.llmsFullTxt !== false && "/llms-full.txt"].filter((route2) => Boolean(route2))) {
|
|
2441
2469
|
extendRouteRules(route, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
|
|
2442
2470
|
}
|
|
2443
2471
|
if (config.sitemapMd !== false) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-ai-ready",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.3.1",
|
|
5
5
|
"description": "Best practice AI & LLM discoverability for Nuxt sites.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Harlan Wilton",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
|
-
"@mdream/js": "^2.0.
|
|
74
|
+
"@mdream/js": "^2.0.3",
|
|
75
75
|
"@nuxt/kit": "^4.6.0",
|
|
76
76
|
"@nuxtjs/robots": "^7.0.0",
|
|
77
77
|
"@nuxtjs/sitemap": "^9.0.0",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"confbox": "^0.3.1",
|
|
80
80
|
"consola": "^3.4.2",
|
|
81
81
|
"defu": "^6.1.7",
|
|
82
|
-
"mdream": "^2.0.
|
|
82
|
+
"mdream": "^2.0.3",
|
|
83
83
|
"nuxt-site-config": "^5.0.0",
|
|
84
84
|
"nuxtseo-shared": "^6.0.0",
|
|
85
85
|
"ohash": "^2.0.12",
|
|
@@ -31,7 +31,7 @@ A page visit in dev or production does not index anything.
|
|
|
31
31
|
|
|
32
32
|
With no `aiReady` config, the module adds:
|
|
33
33
|
|
|
34
|
-
- `/llms.txt`: site name, description, resource links, and
|
|
34
|
+
- `/llms.txt`: site name, description, resource links, and current sitemap pages. Stored pages supply metadata. Large lists split under `/llms/`.
|
|
35
35
|
- `/llms-full.txt`: the full Markdown of each prerendered page. Only prerendered pages go in the static file.
|
|
36
36
|
- A `.md` twin per page. `/about` becomes `/about.md`, and `/` becomes `/index.md`. Paths under `/api` and `/_` get none.
|
|
37
37
|
- Accept negotiation: a request that prefers `text/markdown` gets a `307` to the `.md` URL. Responses send `Vary: Accept`. AI bot user agents get Markdown only with `botNegotiation: true`.
|
|
@@ -58,6 +58,32 @@ export default defineNuxtConfig({
|
|
|
58
58
|
})
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
Publish separate discovery files from named sitemaps. Use module configuration before writing sitemap and database middleware:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
export default defineNuxtConfig({
|
|
65
|
+
aiReady: {
|
|
66
|
+
llmsFullTxt: false,
|
|
67
|
+
llmsTxt: {
|
|
68
|
+
markdownLinks: true,
|
|
69
|
+
sitemaps: ['pages', 'skills', 'demos'],
|
|
70
|
+
sections: [
|
|
71
|
+
{ title: 'Skills', sitemap: 'skills', path: '/llms/skills.txt' },
|
|
72
|
+
{ title: 'Demos', sitemap: 'demos', path: '/llms/demos.txt' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The root links each section file. Chunked sitemaps keep their configured name.
|
|
80
|
+
Every file stays within `maxLength`, default 80,000 characters. Static builds emit every linked child.
|
|
81
|
+
GET and HEAD work on every generated discovery file. Empty sections produce no link or file.
|
|
82
|
+
|
|
83
|
+
For manual discovery, place `public/llms.txt`. The module preserves it and keeps Markdown conversion enabled.
|
|
84
|
+
If a server handler owns discovery, set `llmsTxt: false`.
|
|
85
|
+
Use `llmsFullTxt: false` instead of redirecting the full export to the index.
|
|
86
|
+
|
|
61
87
|
Remove an element from the Markdown on every path, build and runtime. Put it in `mdreamOptions`; the defaults `minimal` and `clean` stay on:
|
|
62
88
|
|
|
63
89
|
```ts
|
|
@@ -92,6 +118,8 @@ Runtime indexing, database drivers, Cloudflare D1, and the server query helpers:
|
|
|
92
118
|
|
|
93
119
|
## Traps
|
|
94
120
|
|
|
121
|
+
- **Discovery is not authorization.** Selected sitemaps exclude stale rows from `llms.txt`; they do not delete stored content or protect other APIs.
|
|
122
|
+
- **Custom route policies must allow Markdown twins.** If middleware rejects `/skills/planning.md`, allow it whenever `/skills/planning` is allowed.
|
|
95
123
|
- **The same edit in both hooks applies twice.** During prerendering the Nitro `ai-ready:page:markdown` hook runs, then the Nuxt hook of the same name. Put an edit in one of them, not both.
|
|
96
124
|
- **Content source pages skip conversion hooks.** A Nuxt Content page ignores `mdreamOptions` and both Nitro hooks.
|
|
97
125
|
- **Dev has no page data.** Query helpers, MCP tools, and search return empty results in `nuxt dev`. Test with `nuxt build` and `node .output/server/index.mjs`.
|
|
@@ -126,6 +154,11 @@ Runtime indexing, database drivers, Cloudflare D1, and the server query helpers:
|
|
|
126
154
|
|
|
127
155
|
## Config
|
|
128
156
|
|
|
157
|
+
- `llmsTxt` (`object`): generated discovery. Set `false` for application-owned server routes. A public `llms.txt` disables generation automatically.
|
|
158
|
+
- `llmsTxt.pages` (`'sitemap'`): sitemap admission. Use `'all'` for stored pages outside the sitemap, or `false` for configured sections only.
|
|
159
|
+
- `llmsTxt.sitemaps`: selected sitemap names. An empty array stops sitemap discovery.
|
|
160
|
+
- `llmsTxt.maxLength` (`80000`): maximum characters per file. Minimum `1024`. Larger lists split without dropping links.
|
|
161
|
+
- `llmsFullTxt` (`true`): generate the full export and advertise its link. Set `false` to omit both.
|
|
129
162
|
- `llmsTxt.directive` (`{ markdown: true, html: false }`): a `> Index of every page on this site: [llms.txt](...)` line after the frontmatter of each page's `.md`; `html: true` adds a visually hidden `/llms.txt` link at the top of each HTML body.
|
|
130
163
|
- `llmsTxt.markdownLinks` (`true`): link page entries in `llms.txt` to their `.md` twins. `false` links canonical URLs. With `site.url` set, page links in `llms.txt`, `sitemap.md`, and `.md` bodies are absolute.
|
|
131
164
|
- `describedby` (`true`): the `describedby` link tag and header.
|