nuxt-ai-ready 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunks/agent-skills.mjs +148 -39
- package/dist/chunks/prerender.mjs +1 -1
- package/dist/module.d.mts +11 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +1 -1
- package/dist/runtime/server/middleware/markdown.prerender.js +2 -1
- package/dist/runtime/server/utils/negotiation-decision.d.ts +6 -1
- package/dist/runtime/server/utils/negotiation-decision.js +2 -0
- package/dist/runtime/server/utils/negotiation-response.d.ts +2 -0
- package/dist/runtime/server/utils/negotiation-response.js +8 -1
- package/dist/runtime/types.d.ts +35 -4
- package/dist/shared/{nuxt-ai-ready.D5URd0TD.mjs → nuxt-ai-ready.B8hPhZL9.mjs} +128 -13
- package/package.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { realpath, readFile } from 'node:fs/promises';
|
|
3
|
-
import { resolve, isAbsolute, relative, sep } from 'node:path';
|
|
2
|
+
import { realpath, readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { resolve, isAbsolute, join, relative, sep } from 'node:path';
|
|
4
4
|
import { parseDocument } from 'yaml';
|
|
5
|
-
import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.
|
|
5
|
+
import { A as AGENT_SKILLS_SCHEMA } from '../shared/nuxt-ai-ready.B8hPhZL9.mjs';
|
|
6
6
|
import 'node:module';
|
|
7
7
|
import '@nuxt/kit';
|
|
8
8
|
import 'defu';
|
|
@@ -23,9 +23,28 @@ const urlBase = "https://example.com/.well-known/agent-skills/index.json";
|
|
|
23
23
|
function skillRoute(name) {
|
|
24
24
|
return `/.well-known/agent-skills/${name}/SKILL.md`;
|
|
25
25
|
}
|
|
26
|
+
const ROOT_SKILL_ALIAS = "/SKILL.md";
|
|
27
|
+
function aliasesOf(skill) {
|
|
28
|
+
return skill.alias === void 0 ? [] : Array.isArray(skill.alias) ? skill.alias : [skill.alias];
|
|
29
|
+
}
|
|
26
30
|
function skillUrl(name) {
|
|
27
31
|
return `${name}/SKILL.md`;
|
|
28
32
|
}
|
|
33
|
+
function validateAlias(alias, index, sitemapMd) {
|
|
34
|
+
if (alias === void 0)
|
|
35
|
+
return [];
|
|
36
|
+
if (Array.isArray(alias))
|
|
37
|
+
return alias.flatMap((entry) => validateAlias(entry, index, sitemapMd));
|
|
38
|
+
if (typeof alias !== "string" || !/^\/(?:[^/?#\s]+\/)*[^/?#\s]+\.md$/.test(alias) || alias.split("/").includes(".."))
|
|
39
|
+
return [{ index, field: "alias", message: 'must be a path-absolute route ending in .md, such as "/SKILL.md"' }];
|
|
40
|
+
if (alias.startsWith("/.well-known/"))
|
|
41
|
+
return [{ index, field: "alias", message: "must not use the /.well-known/ prefix reserved for discovery routes" }];
|
|
42
|
+
if (alias === "/index.md")
|
|
43
|
+
return [{ index, field: "alias", message: 'must not use the module-owned markdown route "/index.md"' }];
|
|
44
|
+
if (sitemapMd && alias === "/sitemap.md")
|
|
45
|
+
return [{ index, field: "alias", message: 'must not use the module-owned markdown route "/sitemap.md"' }];
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
29
48
|
function isRecord(value) {
|
|
30
49
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
50
|
}
|
|
@@ -51,7 +70,7 @@ function validateCommonFields(skill, index) {
|
|
|
51
70
|
}
|
|
52
71
|
return issues;
|
|
53
72
|
}
|
|
54
|
-
function validateSkill(skill, index) {
|
|
73
|
+
function validateSkill(skill, index, sitemapMd) {
|
|
55
74
|
if (!isRecord(skill)) {
|
|
56
75
|
return [{ index, field: "source", message: "must be a local or external skill entry" }];
|
|
57
76
|
}
|
|
@@ -59,6 +78,7 @@ function validateSkill(skill, index) {
|
|
|
59
78
|
if (skill.source === "local") {
|
|
60
79
|
if (typeof skill.file !== "string" || skill.file.trim().length === 0)
|
|
61
80
|
issues.push({ index, field: "file", message: "must be a non-empty path relative to the Nuxt root directory" });
|
|
81
|
+
issues.push(...validateAlias(skill.alias, index, sitemapMd));
|
|
62
82
|
return issues;
|
|
63
83
|
}
|
|
64
84
|
if (skill.source === "external") {
|
|
@@ -83,33 +103,25 @@ function resolveExternalEntry(skill) {
|
|
|
83
103
|
digest: skill.digest
|
|
84
104
|
};
|
|
85
105
|
}
|
|
86
|
-
function
|
|
106
|
+
function readSkillFrontmatter(content) {
|
|
87
107
|
const match = content.match(/^\uFEFF?---[\t ]*\r?\n([\s\S]*?)\r?\n---[\t ]*(?:\r?\n|$)/);
|
|
88
|
-
if (!match?.[1])
|
|
89
|
-
return
|
|
90
|
-
index,
|
|
91
|
-
field: "file",
|
|
92
|
-
message: "must contain YAML frontmatter with name and description fields"
|
|
93
|
-
}];
|
|
94
|
-
}
|
|
108
|
+
if (!match?.[1])
|
|
109
|
+
return { _tag: "Err", message: "must contain YAML frontmatter with name and description fields" };
|
|
95
110
|
const document = parseDocument(match[1], { prettyErrors: false });
|
|
96
|
-
if (document.errors.length > 0)
|
|
97
|
-
return [
|
|
98
|
-
index,
|
|
99
|
-
field: "file",
|
|
100
|
-
message: `contains invalid YAML frontmatter: ${document.errors[0]?.message || "unknown YAML error"}`
|
|
101
|
-
}];
|
|
102
|
-
}
|
|
111
|
+
if (document.errors.length > 0)
|
|
112
|
+
return { _tag: "Err", message: `contains invalid YAML frontmatter: ${document.errors[0]?.message || "unknown YAML error"}` };
|
|
103
113
|
const metadata = document.toJS();
|
|
104
|
-
if (!isRecord(metadata))
|
|
105
|
-
return
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
if (!isRecord(metadata))
|
|
115
|
+
return { _tag: "Err", message: "frontmatter must be a YAML mapping with name and description fields" };
|
|
116
|
+
return { _tag: "Ok", metadata, hasBody: content.slice(match[0].length).trim().length > 0 };
|
|
117
|
+
}
|
|
118
|
+
function parseLocalSkillMetadata(content, skill, index) {
|
|
119
|
+
const frontmatter = readSkillFrontmatter(content);
|
|
120
|
+
if (frontmatter._tag === "Err")
|
|
121
|
+
return [{ index, field: "file", message: frontmatter.message }];
|
|
122
|
+
const { metadata } = frontmatter;
|
|
111
123
|
const issues = [];
|
|
112
|
-
if (!
|
|
124
|
+
if (!frontmatter.hasBody) {
|
|
113
125
|
issues.push({
|
|
114
126
|
index,
|
|
115
127
|
field: "file",
|
|
@@ -174,7 +186,7 @@ async function resolveLocalEntry(skill, index, rootDir) {
|
|
|
174
186
|
issues: metadataIssues
|
|
175
187
|
};
|
|
176
188
|
}
|
|
177
|
-
const
|
|
189
|
+
const routes = [skillRoute(skill.name), ...aliasesOf(skill)];
|
|
178
190
|
const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
|
179
191
|
return {
|
|
180
192
|
_tag: "Resolved",
|
|
@@ -185,7 +197,7 @@ async function resolveLocalEntry(skill, index, rootDir) {
|
|
|
185
197
|
url: skillUrl(skill.name),
|
|
186
198
|
digest
|
|
187
199
|
},
|
|
188
|
-
|
|
200
|
+
routes,
|
|
189
201
|
content: text
|
|
190
202
|
};
|
|
191
203
|
});
|
|
@@ -198,40 +210,57 @@ async function resolveLocalEntry(skill, index, rootDir) {
|
|
|
198
210
|
}]
|
|
199
211
|
}));
|
|
200
212
|
}
|
|
201
|
-
async function resolveAgentSkillsConfig(config, rootDir) {
|
|
213
|
+
async function resolveAgentSkillsConfig(config, rootDir, options = {}) {
|
|
202
214
|
if (config === false || config === void 0)
|
|
203
215
|
return { _tag: "Disabled" };
|
|
204
|
-
if (!isRecord(config) || !Array.isArray(config.skills)) {
|
|
216
|
+
if (!isRecord(config) || config.skills !== void 0 && !Array.isArray(config.skills)) {
|
|
205
217
|
return {
|
|
206
218
|
_tag: "Invalid",
|
|
207
|
-
issues: [{ field: "agentSkills", message: "must
|
|
219
|
+
issues: [{ field: "agentSkills", message: "skills must be an array when set" }]
|
|
208
220
|
};
|
|
209
221
|
}
|
|
210
|
-
const
|
|
222
|
+
const configuredSkills = config.skills ?? [];
|
|
223
|
+
if (configuredSkills.length === 0)
|
|
224
|
+
return { _tag: "Disabled" };
|
|
225
|
+
const sitemapMd = options.sitemapMd !== false;
|
|
226
|
+
const issues = configuredSkills.flatMap((skill, index) => validateSkill(skill, index, sitemapMd));
|
|
211
227
|
const seenNames = /* @__PURE__ */ new Set();
|
|
212
|
-
|
|
228
|
+
const seenAliases = /* @__PURE__ */ new Set();
|
|
229
|
+
for (const [index, skill] of configuredSkills.entries()) {
|
|
213
230
|
if (!isRecord(skill) || typeof skill.name !== "string" || seenNames.has(skill.name)) {
|
|
214
231
|
if (isRecord(skill) && typeof skill.name === "string" && seenNames.has(skill.name))
|
|
215
232
|
issues.push({ index, field: "name", message: `duplicates the skill name "${skill.name}"` });
|
|
216
233
|
continue;
|
|
217
234
|
}
|
|
218
235
|
seenNames.add(skill.name);
|
|
236
|
+
for (const alias of aliasesOf(skill)) {
|
|
237
|
+
if (seenAliases.has(alias))
|
|
238
|
+
issues.push({ index, field: "alias", message: `duplicates the alias "${alias}"` });
|
|
239
|
+
seenAliases.add(alias);
|
|
240
|
+
}
|
|
219
241
|
}
|
|
220
242
|
if (issues.length > 0)
|
|
221
243
|
return { _tag: "Invalid", issues };
|
|
222
|
-
const skills =
|
|
244
|
+
const skills = configuredSkills;
|
|
223
245
|
const resolved = await Promise.all(skills.map((skill, index) => skill.source === "local" ? resolveLocalEntry(skill, index, rootDir) : Promise.resolve({ _tag: "Resolved", entry: resolveExternalEntry(skill) })));
|
|
224
246
|
const fileIssues = resolved.flatMap((result) => result._tag === "Invalid" ? result.issues : []);
|
|
225
247
|
if (fileIssues.length > 0)
|
|
226
248
|
return { _tag: "Invalid", issues: fileIssues };
|
|
227
249
|
const entries = [];
|
|
228
250
|
const localArtifacts = {};
|
|
251
|
+
const links = [];
|
|
229
252
|
for (const result of resolved) {
|
|
230
253
|
if (result._tag !== "Resolved")
|
|
231
254
|
continue;
|
|
232
255
|
entries.push(result.entry);
|
|
233
|
-
if ("
|
|
234
|
-
|
|
256
|
+
if ("routes" in result) {
|
|
257
|
+
for (const route of result.routes)
|
|
258
|
+
localArtifacts[route] = result.content;
|
|
259
|
+
const [alias] = result.routes.slice(1).sort((a, b) => a.length - b.length);
|
|
260
|
+
links.push({ name: result.entry.name, description: result.entry.description, href: alias ?? result.routes[0] });
|
|
261
|
+
} else {
|
|
262
|
+
links.push({ name: result.entry.name, description: result.entry.description, href: result.entry.url });
|
|
263
|
+
}
|
|
235
264
|
}
|
|
236
265
|
return {
|
|
237
266
|
_tag: "Enabled",
|
|
@@ -239,8 +268,88 @@ async function resolveAgentSkillsConfig(config, rootDir) {
|
|
|
239
268
|
$schema: AGENT_SKILLS_SCHEMA,
|
|
240
269
|
skills: entries
|
|
241
270
|
},
|
|
242
|
-
localArtifacts
|
|
271
|
+
localArtifacts,
|
|
272
|
+
links
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function toPosix(path) {
|
|
276
|
+
return path.split(sep).join("/");
|
|
277
|
+
}
|
|
278
|
+
async function discoverAgentSkills(options) {
|
|
279
|
+
const dir = options.dir.replace(/^\.?\/+/, "").replace(/\/+$/, "");
|
|
280
|
+
const skills = [];
|
|
281
|
+
const issues = [];
|
|
282
|
+
const seen = /* @__PURE__ */ new Set();
|
|
283
|
+
for (const scanDir of options.scanDirs) {
|
|
284
|
+
const base = resolve(scanDir, dir);
|
|
285
|
+
if (!isWithinDirectory(options.rootDir, base) && base !== options.rootDir)
|
|
286
|
+
continue;
|
|
287
|
+
const entries = await readdir(base, { withFileTypes: true }).catch(() => {
|
|
288
|
+
return [];
|
|
289
|
+
});
|
|
290
|
+
for (const entry of entries.filter((entry2) => entry2.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
291
|
+
if (seen.has(entry.name))
|
|
292
|
+
continue;
|
|
293
|
+
const file = join(base, entry.name, "SKILL.md");
|
|
294
|
+
const content = await readFile(file, "utf8").catch(() => {
|
|
295
|
+
return null;
|
|
296
|
+
});
|
|
297
|
+
if (content === null)
|
|
298
|
+
continue;
|
|
299
|
+
const relativeFile = toPosix(relative(options.rootDir, file));
|
|
300
|
+
const frontmatter = readSkillFrontmatter(content);
|
|
301
|
+
if (frontmatter._tag === "Err") {
|
|
302
|
+
issues.push({ field: "file", message: `${relativeFile} ${frontmatter.message}` });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const { name, description } = frontmatter.metadata;
|
|
306
|
+
if (name !== entry.name) {
|
|
307
|
+
issues.push({ field: "name", message: `${relativeFile} frontmatter name "${String(name)}" must equal its directory name "${entry.name}"` });
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (typeof description !== "string" || description.length === 0) {
|
|
311
|
+
issues.push({ field: "description", message: `${relativeFile} frontmatter must contain a description` });
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
seen.add(entry.name);
|
|
315
|
+
skills.push({
|
|
316
|
+
source: "local",
|
|
317
|
+
name: entry.name,
|
|
318
|
+
description,
|
|
319
|
+
file: relativeFile,
|
|
320
|
+
alias: `/${dir}/${entry.name}/SKILL.md`
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { skills, issues };
|
|
325
|
+
}
|
|
326
|
+
function mergeAgentSkills(discovered, configured) {
|
|
327
|
+
const overridden = new Set(configured.map((skill) => skill.name));
|
|
328
|
+
return [...discovered.filter((skill) => !overridden.has(skill.name)), ...configured];
|
|
329
|
+
}
|
|
330
|
+
function applyRootAlias(skills, root) {
|
|
331
|
+
if (root === false)
|
|
332
|
+
return { _tag: "Ok", skills: [...skills] };
|
|
333
|
+
const locals = skills.filter((skill) => skill.source === "local");
|
|
334
|
+
const target = root === void 0 ? locals.length === 1 ? locals[0] : void 0 : locals.find((skill) => skill.name === root);
|
|
335
|
+
if (root !== void 0 && !target)
|
|
336
|
+
return { _tag: "Invalid", issues: [{ field: "root", message: `names no local skill: "${root}"` }] };
|
|
337
|
+
if (!target || aliasesOf(target).includes(ROOT_SKILL_ALIAS))
|
|
338
|
+
return { _tag: "Ok", skills: [...skills] };
|
|
339
|
+
return {
|
|
340
|
+
_tag: "Ok",
|
|
341
|
+
skills: skills.map((skill) => skill === target ? { ...skill, alias: [...aliasesOf(skill), ROOT_SKILL_ALIAS] } : skill)
|
|
243
342
|
};
|
|
244
343
|
}
|
|
344
|
+
async function prepareAgentSkills(config, options) {
|
|
345
|
+
if (config.dir !== void 0 && config.dir !== false && typeof config.dir !== "string")
|
|
346
|
+
return { _tag: "Invalid", issues: [{ field: "dir", message: "must be a string or false when set" }] };
|
|
347
|
+
if (config.skills !== void 0 && !Array.isArray(config.skills))
|
|
348
|
+
return { _tag: "Invalid", issues: [{ field: "agentSkills", message: "skills must be an array when set" }] };
|
|
349
|
+
const discovered = config.dir === false ? { skills: [], issues: [] } : await discoverAgentSkills({ rootDir: options.rootDir, scanDirs: options.scanDirs, dir: config.dir ?? "skills" });
|
|
350
|
+
if (discovered.issues.length > 0)
|
|
351
|
+
return { _tag: "Invalid", issues: discovered.issues };
|
|
352
|
+
return { _tag: "Ok", skills: mergeAgentSkills(discovered.skills, config.skills ?? []) };
|
|
353
|
+
}
|
|
245
354
|
|
|
246
|
-
export { AGENT_SKILLS_SCHEMA, resolveAgentSkillsConfig };
|
|
355
|
+
export { AGENT_SKILLS_SCHEMA, ROOT_SKILL_ALIAS, applyRootAlias, discoverAgentSkills, mergeAgentSkills, prepareAgentSkills, readSkillFrontmatter, resolveAgentSkillsConfig };
|
|
@@ -5,7 +5,7 @@ import { colorize } from 'consola/utils';
|
|
|
5
5
|
import { resolveLocaleFromRoute } from 'nuxtseo-shared/i18n-runtime';
|
|
6
6
|
import { collectSitemap } from 'sitemapd/parse';
|
|
7
7
|
import { withLeadingSlash, joinURL, withBase } from 'ufo';
|
|
8
|
-
import { l as logger, s as supportsNativeNodeSqlite, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.
|
|
8
|
+
import { l as logger, s as supportsNativeNodeSqlite, M as MARKDOWN_LINK_AVAILABILITY_FILE } from '../shared/nuxt-ai-ready.B8hPhZL9.mjs';
|
|
9
9
|
import { normalizePagePath, toMarkdownPath } from '../../dist/runtime/markdown-path.js';
|
|
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';
|
package/dist/module.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
-
import { LlmsTxtConfig, ContentNegotiationPolicy, ModuleOptions } from '../dist/runtime/types.js';
|
|
2
|
+
import { LlmsTxtConfig, AgentSkillConfig, ContentNegotiationPolicy, ModuleOptions } from '../dist/runtime/types.js';
|
|
3
3
|
export { AgentSkillConfig, AgentSkillsConfig, AgentSkillsIndex, AgentSkillsIndexEntry, ApiCatalogConfig, ApiCatalogEntry, ApiCatalogLinkTarget, ApiCatalogLinks, ExternalAgentSkillConfig, LocalAgentSkillConfig, McpServerCardConfig, ModuleOptions } from '../dist/runtime/types.js';
|
|
4
4
|
import { RuntimeI18nConfig } from 'nuxtseo-shared/i18n-runtime';
|
|
5
5
|
import { ResolvedWebMcpToolsConfig } from '../dist/runtime/site-tool-config.js';
|
|
@@ -73,11 +73,21 @@ interface ModuleHooks {
|
|
|
73
73
|
sections: LlmsTxtConfig['sections'];
|
|
74
74
|
notes: string[];
|
|
75
75
|
}) => void | Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Hook called with every agent skill about to be published: the ones
|
|
78
|
+
* discovered under `agentSkills.dir` merged with `agentSkills.skills`.
|
|
79
|
+
* Mutate the array to add external entries, drop a skill, or change its
|
|
80
|
+
* aliases. The root alias is applied after this hook.
|
|
81
|
+
*/
|
|
82
|
+
'ai-ready:agent-skills': (payload: {
|
|
83
|
+
skills: AgentSkillConfig[];
|
|
84
|
+
}) => void | Promise<void>;
|
|
76
85
|
}
|
|
77
86
|
declare module '@nuxt/schema' {
|
|
78
87
|
interface NuxtHooks {
|
|
79
88
|
'ai-ready:page:markdown': ModuleHooks['ai-ready:page:markdown'];
|
|
80
89
|
'ai-ready:llms-txt': ModuleHooks['ai-ready:llms-txt'];
|
|
90
|
+
'ai-ready:agent-skills': ModuleHooks['ai-ready:agent-skills'];
|
|
81
91
|
}
|
|
82
92
|
}
|
|
83
93
|
interface ModulePublicRuntimeConfig {
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import 'defu';
|
|
|
7
7
|
import 'nuxt-site-config/kit';
|
|
8
8
|
import 'nuxtseo-shared/kit';
|
|
9
9
|
import 'pkg-types';
|
|
10
|
-
export { m as default } from './shared/nuxt-ai-ready.
|
|
10
|
+
export { m as default } from './shared/nuxt-ai-ready.B8hPhZL9.mjs';
|
|
11
11
|
import '../dist/runtime/markdown-path.js';
|
|
12
12
|
import '../dist/runtime/server/utils/sitemap-md.js';
|
|
13
13
|
import '../dist/runtime/server/utils/discovery-response.js';
|
|
@@ -9,6 +9,7 @@ import { fetchRawWithEvent } from "../utils/fetch.js";
|
|
|
9
9
|
import { buildFrontmatter } from "../utils/frontmatter.js";
|
|
10
10
|
import { extractKeywords } from "../utils/keywords.js";
|
|
11
11
|
import { getMarkdownRenderInfo } from "../utils/markdown-request.js";
|
|
12
|
+
import { agentSkillArtifactPaths } from "../utils/negotiation-response.js";
|
|
12
13
|
import { consumePrerenderedHtml } from "../utils/prerender-html.js";
|
|
13
14
|
import { isSitemapMdRequest } from "../utils/sitemap-md.js";
|
|
14
15
|
function extractHeadingsFromMarkdown(markdown) {
|
|
@@ -23,7 +24,7 @@ function extractHeadingsFromMarkdown(markdown) {
|
|
|
23
24
|
return headings;
|
|
24
25
|
}
|
|
25
26
|
export default defineEventHandler(async (event) => {
|
|
26
|
-
if (event.path.startsWith("/.well-known/"))
|
|
27
|
+
if (event.path.startsWith("/.well-known/") || agentSkillArtifactPaths().has(event.path))
|
|
27
28
|
return;
|
|
28
29
|
if (!import.meta.prerender) {
|
|
29
30
|
return;
|
|
@@ -13,10 +13,15 @@ export interface NegotiationInput {
|
|
|
13
13
|
request: MarkdownRequest;
|
|
14
14
|
routeRule: NegotiationRouteRule;
|
|
15
15
|
policy: ContentNegotiationPolicy;
|
|
16
|
+
/**
|
|
17
|
+
* Routes that already serve a Markdown file verbatim, such as an agent skill
|
|
18
|
+
* alias. They are answered by their own handler, never rendered.
|
|
19
|
+
*/
|
|
20
|
+
artifactPaths?: ReadonlySet<string>;
|
|
16
21
|
}
|
|
17
22
|
export type NegotiationDecision = {
|
|
18
23
|
_tag: 'skip';
|
|
19
|
-
reason: 'well-known' | 'internal' | 'not-a-page' | 'deferred';
|
|
24
|
+
reason: 'well-known' | 'internal' | 'artifact' | 'not-a-page' | 'deferred';
|
|
20
25
|
} | {
|
|
21
26
|
_tag: 'not-acceptable';
|
|
22
27
|
} | {
|
|
@@ -6,6 +6,8 @@ export function resolveNegotiationDecision(input) {
|
|
|
6
6
|
const path = withoutQuery(request.path);
|
|
7
7
|
if (path.startsWith("/.well-known/"))
|
|
8
8
|
return { _tag: "skip", reason: "well-known" };
|
|
9
|
+
if (input.artifactPaths?.has(path))
|
|
10
|
+
return { _tag: "skip", reason: "artifact" };
|
|
9
11
|
if (request.headers[INTERNAL_HEADER])
|
|
10
12
|
return { _tag: "skip", reason: "internal" };
|
|
11
13
|
const negotiation = resolveContentNegotiation({ policy: input.policy, routeRule: input.routeRule });
|
|
@@ -3,6 +3,8 @@ import type { ModulePublicRuntimeConfig } from '../../../module.js';
|
|
|
3
3
|
import type { RuntimeRouteContext } from './i18n.js';
|
|
4
4
|
import type { NegotiationDecision, NegotiationStage } from './negotiation-decision.js';
|
|
5
5
|
import { sendRedirect } from '#nuxtseo/h3';
|
|
6
|
+
/** Routes an agent skill answers verbatim; negotiation must not render them. */
|
|
7
|
+
export declare function agentSkillArtifactPaths(): ReadonlySet<string>;
|
|
6
8
|
export type LinkUrlResolver = (path: string) => string;
|
|
7
9
|
export interface NegotiationContext {
|
|
8
10
|
config: ModulePublicRuntimeConfig;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createNitroRouteRuleMatcher } from "nuxtseo-shared/server";
|
|
2
|
+
import { localAgentSkillArtifacts } from "#ai-ready-virtual/agent-skills.mjs";
|
|
2
3
|
import { appendHeader, createError, getRequestURL, getResponseHeader, sendRedirect, setHeader } from "#nuxtseo/h3";
|
|
3
4
|
import { useRuntimeConfig } from "#nuxtseo/nitro";
|
|
4
5
|
import { withSiteUrl } from "#site-config/server/composables/utils";
|
|
@@ -11,6 +12,11 @@ import { buildLinkHeader } from "./link-header.js";
|
|
|
11
12
|
import { toMarkdownRequest } from "./markdown-request.js";
|
|
12
13
|
import { resolveNegotiationDecision } from "./negotiation-decision.js";
|
|
13
14
|
const APPLIED_KEY = "nuxt-ai-ready:negotiation-applied";
|
|
15
|
+
let artifactPaths;
|
|
16
|
+
export function agentSkillArtifactPaths() {
|
|
17
|
+
artifactPaths ??= new Set(Object.keys(localAgentSkillArtifacts));
|
|
18
|
+
return artifactPaths;
|
|
19
|
+
}
|
|
14
20
|
export function setLinkHeader(event, ctx, variant) {
|
|
15
21
|
setHeader(event, "link", buildLinkHeader(ctx.path, variant, ctx.config, ctx.resolveUrl, ctx.routeContext));
|
|
16
22
|
}
|
|
@@ -74,7 +80,8 @@ export function decideNegotiation(event, stage) {
|
|
|
74
80
|
stage,
|
|
75
81
|
request: toMarkdownRequest(event),
|
|
76
82
|
routeRule: getRouteRuleMatcher(runtimeConfig)(event.path),
|
|
77
|
-
policy: config.contentNegotiation
|
|
83
|
+
policy: config.contentNegotiation,
|
|
84
|
+
artifactPaths: agentSkillArtifactPaths()
|
|
78
85
|
});
|
|
79
86
|
}
|
|
80
87
|
export async function applyNegotiation(event, decision) {
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -46,6 +46,13 @@ export interface LocalAgentSkillConfig {
|
|
|
46
46
|
description: string;
|
|
47
47
|
/** SKILL.md path, resolved relative to the Nuxt root directory. */
|
|
48
48
|
file: string;
|
|
49
|
+
/**
|
|
50
|
+
* Also serve the same bytes at these path-absolute `.md` routes, such as
|
|
51
|
+
* `/SKILL.md`. Content negotiation leaves the routes alone, so the response
|
|
52
|
+
* is the file itself with no generated frontmatter. The discovery index
|
|
53
|
+
* keeps advertising the `.well-known` artifact URL.
|
|
54
|
+
*/
|
|
55
|
+
alias?: string | string[];
|
|
49
56
|
}
|
|
50
57
|
export interface ExternalAgentSkillConfig {
|
|
51
58
|
/** Advertise an artifact already hosted at a URL. */
|
|
@@ -63,7 +70,29 @@ export interface ExternalAgentSkillConfig {
|
|
|
63
70
|
}
|
|
64
71
|
export type AgentSkillConfig = LocalAgentSkillConfig | ExternalAgentSkillConfig;
|
|
65
72
|
export interface AgentSkillsConfig {
|
|
66
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Explicit entries. Discovered skills are merged in first; an explicit entry
|
|
75
|
+
* with the same name replaces the discovered one.
|
|
76
|
+
*/
|
|
77
|
+
skills?: AgentSkillConfig[];
|
|
78
|
+
/**
|
|
79
|
+
* Directory scanned for `<name>/SKILL.md` in the project root and in every
|
|
80
|
+
* layer inside it. Each match is published as a local skill and also served
|
|
81
|
+
* at `/<dir>/<name>/SKILL.md`, mirroring the repository path. `false` turns
|
|
82
|
+
* discovery off.
|
|
83
|
+
* @default 'skills'
|
|
84
|
+
*/
|
|
85
|
+
dir?: string | false;
|
|
86
|
+
/**
|
|
87
|
+
* Name of the local skill also served at `/SKILL.md`. Defaults to the only
|
|
88
|
+
* local skill when exactly one exists. `false` publishes no root alias.
|
|
89
|
+
*/
|
|
90
|
+
root?: string | false;
|
|
91
|
+
/**
|
|
92
|
+
* Add an "Agent Skills" section to llms.txt listing every published skill.
|
|
93
|
+
* @default true
|
|
94
|
+
*/
|
|
95
|
+
llmsTxt?: boolean;
|
|
67
96
|
}
|
|
68
97
|
export interface AgentSkillsIndexEntry {
|
|
69
98
|
name: string;
|
|
@@ -231,9 +260,11 @@ export interface ModuleOptions {
|
|
|
231
260
|
exposedTo?: string[];
|
|
232
261
|
};
|
|
233
262
|
/**
|
|
234
|
-
* Publish an Agent Skills Discovery v0.2.0 index and
|
|
235
|
-
*
|
|
236
|
-
*
|
|
263
|
+
* Publish an Agent Skills Discovery v0.2.0 index and host local SKILL.md
|
|
264
|
+
* artifacts under `/.well-known/agent-skills/`. Skills in `skills/<name>/SKILL.md`
|
|
265
|
+
* are discovered and published without configuration; nothing is published
|
|
266
|
+
* when none exist. Set `false` to turn the feature off.
|
|
267
|
+
* @default {}
|
|
237
268
|
*/
|
|
238
269
|
agentSkills?: false | AgentSkillsConfig;
|
|
239
270
|
/**
|
|
@@ -3,7 +3,7 @@ import { readFile, mkdir, writeFile, access } from 'node:fs/promises';
|
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { dirname, basename, join } from 'node:path';
|
|
5
5
|
import { hasNuxtModule, useNuxt, useLogger, addTypeTemplate, addTemplate, defineNuxtModule, createResolver, addServerImports, addServerPlugin, addServerHandler, extendRouteRules, addPlugin, addImports } from '@nuxt/kit';
|
|
6
|
-
import defu from 'defu';
|
|
6
|
+
import defu$1, { defu } from 'defu';
|
|
7
7
|
import { installNuxtSiteConfig, useSiteConfig, withSiteUrl } from 'nuxt-site-config/kit';
|
|
8
8
|
import { resolveNuxtContentVersion, renderNitroTypeAugmentations, setupNitroRuntimeCompatibility } from 'nuxtseo-shared/kit';
|
|
9
9
|
import { readPackageJSON, resolvePackageJSON } from 'pkg-types';
|
|
@@ -863,6 +863,34 @@ function ensureStaticHeader(contents, route, name, value) {
|
|
|
863
863
|
const prefix = blockStart === routeLineEnd ? eol : "";
|
|
864
864
|
return `${contents.slice(0, blockStart)}${prefix} ${name}: ${value}${eol}${contents.slice(blockStart)}`;
|
|
865
865
|
}
|
|
866
|
+
const CLOUDFLARE_STATIC_HEADER_RULE_LIMIT = 100;
|
|
867
|
+
const RE_EXACT_MARKDOWN_ROUTE$1 = /^\/[^*\s]*\.md[\t ]*\r?$/;
|
|
868
|
+
function splitHeaderBlocks(contents) {
|
|
869
|
+
const blocks = [];
|
|
870
|
+
let current = { route: null, text: "" };
|
|
871
|
+
for (const line of contents.split(/(?<=\n)/)) {
|
|
872
|
+
const isRoute = /^[^\s#]/.test(line);
|
|
873
|
+
if (isRoute) {
|
|
874
|
+
blocks.push(current);
|
|
875
|
+
current = { route: line.trimEnd(), text: "" };
|
|
876
|
+
}
|
|
877
|
+
current.text += line;
|
|
878
|
+
}
|
|
879
|
+
blocks.push(current);
|
|
880
|
+
return blocks;
|
|
881
|
+
}
|
|
882
|
+
function enforceStaticHeaderBudget(contents, limit) {
|
|
883
|
+
const blocks = splitHeaderBlocks(contents);
|
|
884
|
+
const ruleCount = blocks.filter((block) => block.route !== null).length;
|
|
885
|
+
if (ruleCount <= limit)
|
|
886
|
+
return { contents, total: ruleCount, dropped: 0 };
|
|
887
|
+
const kept = blocks.filter((block) => block.route === null || !RE_EXACT_MARKDOWN_ROUTE$1.test(block.route));
|
|
888
|
+
return {
|
|
889
|
+
contents: kept.map((block) => block.text).join(""),
|
|
890
|
+
total: kept.filter((block) => block.route !== null).length,
|
|
891
|
+
dropped: ruleCount - kept.filter((block) => block.route !== null).length
|
|
892
|
+
};
|
|
893
|
+
}
|
|
866
894
|
|
|
867
895
|
const RE_MD_EXT = /\.md$/;
|
|
868
896
|
function isStaticMarkdownSourceRoute(route) {
|
|
@@ -895,9 +923,13 @@ function pageRouteFromMarkdownTwin(fileName) {
|
|
|
895
923
|
return "/";
|
|
896
924
|
return isStaticMarkdownSourceRoute(pageRoute) ? pageRoute : null;
|
|
897
925
|
}
|
|
898
|
-
function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby) {
|
|
926
|
+
function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby, artifactRoutes) {
|
|
927
|
+
const artifacts = new Set(artifactRoutes);
|
|
899
928
|
const rules = /* @__PURE__ */ new Map();
|
|
900
929
|
for (const entry of prerenderedRoutes) {
|
|
930
|
+
if (entry.route !== void 0 && artifacts.has(entry.route) || entry.fileName !== void 0 && artifacts.has(entry.fileName)) {
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
901
933
|
const pageRoute = pageRouteFromMarkdownTwin(entry.fileName);
|
|
902
934
|
if (pageRoute === null)
|
|
903
935
|
continue;
|
|
@@ -914,6 +946,34 @@ function prerenderedMarkdownHeaderRules(prerenderedRoutes, baseURL, describedby)
|
|
|
914
946
|
}
|
|
915
947
|
return [...rules.values()];
|
|
916
948
|
}
|
|
949
|
+
const RE_EXACT_MARKDOWN_ROUTE = /^\/[^*:\s]*\.md$/;
|
|
950
|
+
function isStaticHeaderRouteRule([, rule]) {
|
|
951
|
+
return Boolean(rule && "headers" in rule && rule.headers);
|
|
952
|
+
}
|
|
953
|
+
function planStaticMarkdownHeaderRules(routeRules, rules, limit) {
|
|
954
|
+
if (limit === null)
|
|
955
|
+
return { _tag: "apply", rules };
|
|
956
|
+
const entries = Object.entries(routeRules).filter(isStaticHeaderRouteRule);
|
|
957
|
+
const registeredMarkdown = entries.map(([route]) => route).filter((route) => RE_EXACT_MARKDOWN_ROUTE.test(route));
|
|
958
|
+
const other = entries.length - registeredMarkdown.length;
|
|
959
|
+
const markdown = /* @__PURE__ */ new Set([...registeredMarkdown, ...rules.map((rule) => rule.route)]);
|
|
960
|
+
const total = other + markdown.size;
|
|
961
|
+
if (total <= limit)
|
|
962
|
+
return { _tag: "apply", rules };
|
|
963
|
+
return { _tag: "skip", drop: registeredMarkdown, total, dropped: markdown.size };
|
|
964
|
+
}
|
|
965
|
+
function applyStaticMarkdownHeaderPlan(routeRules, plan) {
|
|
966
|
+
if (plan._tag === "skip") {
|
|
967
|
+
for (const route of plan.drop) {
|
|
968
|
+
const rule = routeRules[route];
|
|
969
|
+
if (rule)
|
|
970
|
+
rule.headers = void 0;
|
|
971
|
+
}
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
for (const { route, headers } of plan.rules)
|
|
975
|
+
routeRules[route] = defu({ headers }, routeRules[route]);
|
|
976
|
+
}
|
|
917
977
|
|
|
918
978
|
const DEFAULT_MAX_OUTPUT_CHARS = 1500;
|
|
919
979
|
const DEFAULT_LIST_LIMIT = 20;
|
|
@@ -1083,11 +1143,35 @@ const module$1 = defineNuxtModule({
|
|
|
1083
1143
|
logger.debug("Module is disabled, skipping setup.");
|
|
1084
1144
|
return;
|
|
1085
1145
|
}
|
|
1086
|
-
const
|
|
1087
|
-
|
|
1088
|
-
const details =
|
|
1089
|
-
|
|
1146
|
+
const agentSkillsConfig = config.agentSkills === false ? false : config.agentSkills ?? {};
|
|
1147
|
+
const agentSkillsError = (issues) => {
|
|
1148
|
+
const details = issues.map((issue) => `${issue.index === void 0 ? "agentSkills" : `agentSkills.skills[${issue.index}]`}.${issue.field}: ${issue.message}`).join("\n");
|
|
1149
|
+
return new Error(`[nuxt-ai-ready] Invalid Agent Skills configuration:
|
|
1090
1150
|
${details}`);
|
|
1151
|
+
};
|
|
1152
|
+
let agentSkillsResult = { _tag: "Disabled" };
|
|
1153
|
+
if (agentSkillsConfig !== false) {
|
|
1154
|
+
const { applyRootAlias, prepareAgentSkills, resolveAgentSkillsConfig } = await import('../chunks/agent-skills.mjs');
|
|
1155
|
+
const prepared = await prepareAgentSkills(agentSkillsConfig, {
|
|
1156
|
+
rootDir: nuxt.options.rootDir,
|
|
1157
|
+
scanDirs: nuxt.options._layers.map((layer) => layer.config.rootDir ?? layer.cwd)
|
|
1158
|
+
});
|
|
1159
|
+
if (prepared._tag === "Invalid")
|
|
1160
|
+
throw agentSkillsError(prepared.issues);
|
|
1161
|
+
const payload = { skills: prepared.skills };
|
|
1162
|
+
await nuxt.callHook("ai-ready:agent-skills", payload);
|
|
1163
|
+
const rooted = applyRootAlias(payload.skills, agentSkillsConfig.root);
|
|
1164
|
+
if (rooted._tag === "Invalid")
|
|
1165
|
+
throw agentSkillsError(rooted.issues);
|
|
1166
|
+
agentSkillsResult = await resolveAgentSkillsConfig(
|
|
1167
|
+
{ ...agentSkillsConfig, skills: rooted.skills },
|
|
1168
|
+
nuxt.options.rootDir,
|
|
1169
|
+
{ sitemapMd: config.sitemapMd !== false }
|
|
1170
|
+
);
|
|
1171
|
+
if (agentSkillsResult._tag === "Invalid")
|
|
1172
|
+
throw agentSkillsError(agentSkillsResult.issues);
|
|
1173
|
+
if (agentSkillsResult._tag === "Enabled")
|
|
1174
|
+
logger.debug(`Publishing ${agentSkillsResult.index.skills.length} agent skill(s): ${agentSkillsResult.index.skills.map((skill) => skill.name).join(", ")}`);
|
|
1091
1175
|
}
|
|
1092
1176
|
const mcpServerCardResult = parseMcpServerCardConfig(config.mcpServerCard);
|
|
1093
1177
|
if (mcpServerCardResult._tag === "Invalid")
|
|
@@ -1263,6 +1347,18 @@ ${details}`);
|
|
|
1263
1347
|
}
|
|
1264
1348
|
]
|
|
1265
1349
|
});
|
|
1350
|
+
if (agentSkillsResult._tag === "Enabled" && agentSkillsConfig !== false && agentSkillsConfig.llmsTxt !== false) {
|
|
1351
|
+
const indexUrl = withSiteUrl(AGENT_SKILLS_INDEX_ROUTE.slice(1), { withBase: true });
|
|
1352
|
+
defaultLlmsTxtSections.push({
|
|
1353
|
+
title: "Agent Skills",
|
|
1354
|
+
description: `Skills an agent can install from this site. The index at ${indexUrl} carries a sha256 digest for each one.`,
|
|
1355
|
+
links: agentSkillsResult.links.map((link) => ({
|
|
1356
|
+
title: link.name,
|
|
1357
|
+
href: link.href.startsWith("/") ? withSiteUrl(link.href.slice(1), { withBase: true }) : new URL(link.href, indexUrl).href,
|
|
1358
|
+
description: link.description
|
|
1359
|
+
}))
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1266
1362
|
const mergedLlmsTxt = config.llmsTxt ? {
|
|
1267
1363
|
markdownLinks: config.llmsTxt.markdownLinks ?? false,
|
|
1268
1364
|
sections: [
|
|
@@ -1630,7 +1726,7 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
|
|
|
1630
1726
|
mdreamOptions: config.mdreamOptions || {},
|
|
1631
1727
|
sitemapMd: config.sitemapMd !== false,
|
|
1632
1728
|
describedby: config.describedby !== false,
|
|
1633
|
-
markdownCacheHeaders: defu(config.markdownCacheHeaders, {
|
|
1729
|
+
markdownCacheHeaders: defu$1(config.markdownCacheHeaders, {
|
|
1634
1730
|
maxAge: 3600,
|
|
1635
1731
|
swr: true
|
|
1636
1732
|
}),
|
|
@@ -1786,14 +1882,24 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
|
|
|
1786
1882
|
});
|
|
1787
1883
|
}
|
|
1788
1884
|
nuxt.hooks.hook("nitro:build:before", (nitro) => {
|
|
1885
|
+
const isCloudflarePreset = String(nitro.options.preset || "").startsWith("cloudflare");
|
|
1789
1886
|
nitro.hooks.hook("prerender:done", () => {
|
|
1790
|
-
|
|
1791
|
-
nitro.
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1887
|
+
const plan = planStaticMarkdownHeaderRules(
|
|
1888
|
+
nitro.options.routeRules,
|
|
1889
|
+
prerenderedMarkdownHeaderRules(
|
|
1890
|
+
nitro._prerenderedRoutes || [],
|
|
1891
|
+
staticBaseURL,
|
|
1892
|
+
config.describedby !== false,
|
|
1893
|
+
agentSkillsResult._tag === "Enabled" ? Object.keys(agentSkillsResult.localArtifacts) : []
|
|
1894
|
+
),
|
|
1895
|
+
isCloudflarePreset ? CLOUDFLARE_STATIC_HEADER_RULE_LIMIT : null
|
|
1896
|
+
);
|
|
1897
|
+
if (plan._tag === "skip") {
|
|
1898
|
+
applyStaticMarkdownHeaderPlan(nitro.options.routeRules, plan);
|
|
1899
|
+
logger.warn(`_headers would hold ${plan.total} rules and Cloudflare allows ${CLOUDFLARE_STATIC_HEADER_RULE_LIMIT}. Skipped the ${plan.dropped} per-page .md rules; the /*.md glob still sets the charset and describedby entries, but static markdown does not send a per-page canonical Link.`);
|
|
1900
|
+
return;
|
|
1796
1901
|
}
|
|
1902
|
+
applyStaticMarkdownHeaderPlan(nitro.options.routeRules, plan);
|
|
1797
1903
|
});
|
|
1798
1904
|
nitro.hooks.hook("compiled", async () => {
|
|
1799
1905
|
const headersPath = join(nitro.options.output.publicDir, "_headers");
|
|
@@ -1815,6 +1921,15 @@ export const logger = createModuleLogger('nuxt-ai-ready', ${!!config.debug})
|
|
|
1815
1921
|
staticDescribedbyEntry(nitro.options.baseURL || "/")
|
|
1816
1922
|
);
|
|
1817
1923
|
}
|
|
1924
|
+
if (isCloudflarePreset) {
|
|
1925
|
+
const budget = enforceStaticHeaderBudget(mergedHeaders, CLOUDFLARE_STATIC_HEADER_RULE_LIMIT);
|
|
1926
|
+
if (budget.dropped > 0) {
|
|
1927
|
+
mergedHeaders = budget.contents;
|
|
1928
|
+
logger.warn(`_headers had ${budget.total + budget.dropped} rules and Cloudflare allows ${CLOUDFLARE_STATIC_HEADER_RULE_LIMIT}. Dropped the ${budget.dropped} per-page .md rules; the /*.md glob still sets the charset and describedby entries, but static markdown no longer sends a per-page canonical Link.`);
|
|
1929
|
+
}
|
|
1930
|
+
if (budget.total > CLOUDFLARE_STATIC_HEADER_RULE_LIMIT)
|
|
1931
|
+
logger.warn(`_headers still has ${budget.total} rules after dropping the .md rules. Cloudflare will reject the deploy until the other rules fit under ${CLOUDFLARE_STATIC_HEADER_RULE_LIMIT}.`);
|
|
1932
|
+
}
|
|
1818
1933
|
if (mergedHeaders !== headers) {
|
|
1819
1934
|
await writeFile(headersPath, mergedHeaders);
|
|
1820
1935
|
logger.debug("Merged .md charset header into _headers");
|