nuxt-ai-ready 0.8.1 → 0.9.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/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+
package/dist/cli.mjs ADDED
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import fsp from 'node:fs/promises';
4
+ import { defineCommand, runMain } from 'citty';
5
+ import { consola } from 'consola';
6
+ import { colors } from 'consola/utils';
7
+ import { resolve, join } from 'pathe';
8
+
9
+ async function getSecret(cwd) {
10
+ const secretPath = join(cwd, "node_modules/.cache/nuxt/ai-ready/secret");
11
+ if (!existsSync(secretPath)) {
12
+ return null;
13
+ }
14
+ return fsp.readFile(secretPath, "utf-8").then((s) => s.trim()).catch(() => null);
15
+ }
16
+ const main = defineCommand({
17
+ meta: {
18
+ name: "nuxt-ai-ready",
19
+ description: "Nuxt AI Ready CLI"
20
+ },
21
+ subCommands: {
22
+ status: defineCommand({
23
+ meta: {
24
+ name: "status",
25
+ description: "Show indexing status and IndexNow sync progress"
26
+ },
27
+ args: {
28
+ url: {
29
+ type: "string",
30
+ alias: "u",
31
+ description: "Site URL (default: http://localhost:3000)",
32
+ default: "http://localhost:3000"
33
+ },
34
+ cwd: {
35
+ type: "string",
36
+ description: "Working directory",
37
+ default: "."
38
+ }
39
+ },
40
+ async run({ args }) {
41
+ const cwd = resolve(args.cwd || ".");
42
+ const secret = await getSecret(cwd);
43
+ if (!secret) {
44
+ consola.error("No secret found. Run `nuxi dev` or `nuxi build` first to generate one.");
45
+ return;
46
+ }
47
+ const url = `${args.url}/__ai-ready/status?secret=${secret}`;
48
+ consola.info(`Fetching status from ${args.url}...`);
49
+ const res = await fetch(url).then((r) => r.json()).catch((err) => {
50
+ consola.error(`Failed to connect: ${err.message}`);
51
+ return null;
52
+ });
53
+ if (!res)
54
+ return;
55
+ consola.box("AI Ready Status");
56
+ consola.info(`Total pages: ${colors.cyan(res.total?.toString() || "0")}`);
57
+ consola.info(`Indexed: ${colors.green(res.indexed?.toString() || "0")}`);
58
+ consola.info(`Pending: ${colors.yellow(res.pending?.toString() || "0")}`);
59
+ if (res.indexNow) {
60
+ consola.log("");
61
+ consola.info(colors.bold("IndexNow:"));
62
+ consola.info(` Pending: ${colors.yellow(res.indexNow.pending?.toString() || "0")}`);
63
+ consola.info(` Total submitted: ${colors.green(res.indexNow.totalSubmitted?.toString() || "0")}`);
64
+ if (res.indexNow.lastSubmittedAt) {
65
+ const date = new Date(res.indexNow.lastSubmittedAt);
66
+ consola.info(` Last submitted: ${colors.dim(date.toISOString())}`);
67
+ }
68
+ if (res.indexNow.lastError) {
69
+ consola.info(` Last error: ${colors.red(res.indexNow.lastError)}`);
70
+ }
71
+ }
72
+ }
73
+ }),
74
+ poll: defineCommand({
75
+ meta: {
76
+ name: "poll",
77
+ description: "Trigger page indexing"
78
+ },
79
+ args: {
80
+ url: {
81
+ type: "string",
82
+ alias: "u",
83
+ description: "Site URL (default: http://localhost:3000)",
84
+ default: "http://localhost:3000"
85
+ },
86
+ limit: {
87
+ type: "string",
88
+ alias: "l",
89
+ description: "Max pages to process",
90
+ default: "10"
91
+ },
92
+ all: {
93
+ type: "boolean",
94
+ alias: "a",
95
+ description: "Process all pending pages"
96
+ },
97
+ cwd: {
98
+ type: "string",
99
+ description: "Working directory",
100
+ default: "."
101
+ }
102
+ },
103
+ async run({ args }) {
104
+ const cwd = resolve(args.cwd || ".");
105
+ const secret = await getSecret(cwd);
106
+ if (!secret) {
107
+ consola.error("No secret found. Run `nuxi dev` or `nuxi build` first.");
108
+ return;
109
+ }
110
+ const params = new URLSearchParams({ secret });
111
+ if (args.all) {
112
+ params.set("all", "true");
113
+ } else {
114
+ params.set("limit", args.limit || "10");
115
+ }
116
+ const url = `${args.url}/__ai-ready/poll?${params}`;
117
+ consola.info(`Triggering poll at ${args.url}...`);
118
+ const res = await fetch(url, { method: "POST" }).then((r) => r.json()).catch((err) => {
119
+ consola.error(`Failed: ${err.message}`);
120
+ return null;
121
+ });
122
+ if (!res)
123
+ return;
124
+ consola.success(`Indexed: ${colors.green(res.indexed?.toString() || "0")} pages`);
125
+ consola.info(`Remaining: ${colors.yellow(res.remaining?.toString() || "0")}`);
126
+ if (res.errors?.length) {
127
+ consola.warn(`Errors: ${res.errors.length}`);
128
+ }
129
+ if (res.duration) {
130
+ consola.info(`Duration: ${colors.dim(`${res.duration}ms`)}`);
131
+ }
132
+ }
133
+ }),
134
+ restore: defineCommand({
135
+ meta: {
136
+ name: "restore",
137
+ description: "Restore database from prerendered dump"
138
+ },
139
+ args: {
140
+ url: {
141
+ type: "string",
142
+ alias: "u",
143
+ description: "Site URL (default: http://localhost:3000)",
144
+ default: "http://localhost:3000"
145
+ },
146
+ clear: {
147
+ type: "boolean",
148
+ description: "Clear existing pages first (default: true)",
149
+ default: true
150
+ },
151
+ cwd: {
152
+ type: "string",
153
+ description: "Working directory",
154
+ default: "."
155
+ }
156
+ },
157
+ async run({ args }) {
158
+ const cwd = resolve(args.cwd || ".");
159
+ const secret = await getSecret(cwd);
160
+ if (!secret) {
161
+ consola.error("No secret found. Run `nuxi dev` or `nuxi build` first.");
162
+ return;
163
+ }
164
+ const params = new URLSearchParams({ secret });
165
+ if (!args.clear) {
166
+ params.set("clear", "false");
167
+ }
168
+ const url = `${args.url}/__ai-ready/restore?${params}`;
169
+ consola.info(`Restoring database at ${args.url}...`);
170
+ const res = await fetch(url, { method: "POST" }).then((r) => r.json()).catch((err) => {
171
+ consola.error(`Failed: ${err.message}`);
172
+ return null;
173
+ });
174
+ if (!res)
175
+ return;
176
+ consola.success(`Restored: ${colors.green(res.restored?.toString() || "0")} pages`);
177
+ if (res.cleared) {
178
+ consola.info(`Cleared: ${colors.yellow(res.cleared?.toString() || "0")} existing pages`);
179
+ }
180
+ }
181
+ }),
182
+ prune: defineCommand({
183
+ meta: {
184
+ name: "prune",
185
+ description: "Remove stale routes from database"
186
+ },
187
+ args: {
188
+ url: {
189
+ type: "string",
190
+ alias: "u",
191
+ description: "Site URL (default: http://localhost:3000)",
192
+ default: "http://localhost:3000"
193
+ },
194
+ dry: {
195
+ type: "boolean",
196
+ alias: "d",
197
+ description: "Preview without deleting"
198
+ },
199
+ ttl: {
200
+ type: "string",
201
+ description: "Override pruneTtl config"
202
+ },
203
+ cwd: {
204
+ type: "string",
205
+ description: "Working directory",
206
+ default: "."
207
+ }
208
+ },
209
+ async run({ args }) {
210
+ const cwd = resolve(args.cwd || ".");
211
+ const secret = await getSecret(cwd);
212
+ if (!secret && !args.dry) {
213
+ consola.error("No secret found. Run `nuxi dev` or `nuxi build` first.");
214
+ return;
215
+ }
216
+ const params = new URLSearchParams();
217
+ if (secret)
218
+ params.set("secret", secret);
219
+ if (args.dry)
220
+ params.set("dry", "true");
221
+ if (args.ttl)
222
+ params.set("ttl", args.ttl);
223
+ const url = `${args.url}/__ai-ready/prune?${params}`;
224
+ consola.info(`${args.dry ? "Previewing" : "Pruning"} stale routes at ${args.url}...`);
225
+ const res = await fetch(url, { method: "POST" }).then((r) => r.json()).catch((err) => {
226
+ consola.error(`Failed: ${err.message}`);
227
+ return null;
228
+ });
229
+ if (!res)
230
+ return;
231
+ if (args.dry) {
232
+ consola.info(`Would prune: ${colors.yellow(res.count?.toString() || "0")} routes`);
233
+ if (res.routes?.length) {
234
+ for (const route of res.routes.slice(0, 20)) {
235
+ consola.log(` ${colors.dim("\u2022")} ${route}`);
236
+ }
237
+ if (res.routes.length > 20) {
238
+ consola.log(` ${colors.dim(`... and ${res.routes.length - 20} more`)}`);
239
+ }
240
+ }
241
+ } else {
242
+ consola.success(`Pruned: ${colors.green(res.pruned?.toString() || "0")} routes`);
243
+ }
244
+ }
245
+ }),
246
+ indexnow: defineCommand({
247
+ meta: {
248
+ name: "indexnow",
249
+ description: "Trigger IndexNow sync"
250
+ },
251
+ args: {
252
+ url: {
253
+ type: "string",
254
+ alias: "u",
255
+ description: "Site URL (default: http://localhost:3000)",
256
+ default: "http://localhost:3000"
257
+ },
258
+ limit: {
259
+ type: "string",
260
+ alias: "l",
261
+ description: "Max URLs to submit",
262
+ default: "100"
263
+ },
264
+ cwd: {
265
+ type: "string",
266
+ description: "Working directory",
267
+ default: "."
268
+ }
269
+ },
270
+ async run({ args }) {
271
+ const cwd = resolve(args.cwd || ".");
272
+ const secret = await getSecret(cwd);
273
+ if (!secret) {
274
+ consola.error("No secret found. Run `nuxi dev` or `nuxi build` first.");
275
+ return;
276
+ }
277
+ const params = new URLSearchParams({
278
+ secret,
279
+ limit: args.limit || "100"
280
+ });
281
+ const url = `${args.url}/__ai-ready/indexnow?${params}`;
282
+ consola.info(`Triggering IndexNow sync at ${args.url}...`);
283
+ const res = await fetch(url, { method: "POST" }).then((r) => r.json()).catch((err) => {
284
+ consola.error(`Failed: ${err.message}`);
285
+ return null;
286
+ });
287
+ if (!res)
288
+ return;
289
+ if (res.success) {
290
+ consola.success(`Submitted: ${colors.green(res.submitted?.toString() || "0")} URLs`);
291
+ consola.info(`Remaining: ${colors.yellow(res.remaining?.toString() || "0")}`);
292
+ } else {
293
+ consola.error(`Failed: ${res.error || "Unknown error"}`);
294
+ }
295
+ }
296
+ })
297
+ }
298
+ });
299
+ runMain(main);
package/dist/module.d.mts CHANGED
@@ -46,7 +46,7 @@ interface ModulePublicRuntimeConfig {
46
46
  pruneTtl: number;
47
47
  };
48
48
  runtimeSyncSecret?: string;
49
- indexNowKey?: string;
49
+ indexNow?: string;
50
50
  }
51
51
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
52
52
 
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=4.0.0"
5
5
  },
6
6
  "configKey": "aiReady",
7
- "version": "0.8.1",
7
+ "version": "0.9.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
1
2
  import { mkdir, writeFile, appendFile, stat, access } from 'node:fs/promises';
2
3
  import { join, dirname } from 'node:path';
3
4
  import { useLogger, useNuxt, addTemplate, addTypeTemplate, defineNuxtModule, createResolver, hasNuxtModule, addServerHandler, addPlugin } from '@nuxt/kit';
@@ -17,6 +18,10 @@ import { join as join$1, isAbsolute } from 'pathe';
17
18
 
18
19
  const logger = useLogger("nuxt-ai-ready");
19
20
 
21
+ const moduleRegistrations = [];
22
+ function registerModule(registration) {
23
+ moduleRegistrations.push(registration);
24
+ }
20
25
  function hookNuxtSeoProLicense() {
21
26
  const nuxt = useNuxt();
22
27
  const isBuild = !nuxt.options.dev && !nuxt.options._prepare;
@@ -44,7 +49,13 @@ function hookNuxtSeoProLicense() {
44
49
  const siteName = siteConfig.name || void 0;
45
50
  const res = await $fetch("https://nuxtseo.com/api/pro/verify", {
46
51
  method: "POST",
47
- body: { apiKey: license, siteUrl, siteName }
52
+ body: {
53
+ apiKey: license,
54
+ siteUrl,
55
+ siteName,
56
+ // Include registered modules for dashboard integration
57
+ modules: moduleRegistrations.length > 0 ? moduleRegistrations : void 0
58
+ }
48
59
  }).catch((err) => {
49
60
  if (err?.response?.status === 401) {
50
61
  spinner.stop("\u274C Invalid API key");
@@ -68,7 +79,7 @@ function hookNuxtSeoProLicense() {
68
79
  }
69
80
  }
70
81
 
71
- async function fetchPreviousMeta(siteUrl, indexNowKey) {
82
+ async function fetchPreviousMeta(siteUrl, indexNow) {
72
83
  const metaUrl = `${siteUrl}/__ai-ready/pages.meta.json`;
73
84
  logger.debug(`[indexnow] Fetching previous meta from ${metaUrl}`);
74
85
  const prevMeta = await fetch(metaUrl).then((r) => r.ok ? r.json() : null).catch(() => null);
@@ -76,7 +87,7 @@ async function fetchPreviousMeta(siteUrl, indexNowKey) {
76
87
  logger.info("[indexnow] First deploy or no previous meta - skipping IndexNow");
77
88
  return null;
78
89
  }
79
- const keyUrl = `${siteUrl}/${indexNowKey}.txt`;
90
+ const keyUrl = `${siteUrl}/${indexNow}.txt`;
80
91
  const keyLive = await fetch(keyUrl).then((r) => r.ok).catch(() => false);
81
92
  if (!keyLive) {
82
93
  logger.info("[indexnow] Key file not live yet - skipping IndexNow");
@@ -84,7 +95,7 @@ async function fetchPreviousMeta(siteUrl, indexNowKey) {
84
95
  }
85
96
  return prevMeta;
86
97
  }
87
- async function handleStaticIndexNow(currentPages, siteUrl, indexNowKey, prevMeta) {
98
+ async function handleStaticIndexNow(currentPages, siteUrl, indexNow, prevMeta) {
88
99
  const { changed, added } = comparePageHashes(currentPages, prevMeta);
89
100
  const totalChanged = changed.length + added.length;
90
101
  if (totalChanged === 0) {
@@ -94,7 +105,7 @@ async function handleStaticIndexNow(currentPages, siteUrl, indexNowKey, prevMeta
94
105
  logger.info(`[indexnow] Submitting ${totalChanged} changed pages (${changed.length} modified, ${added.length} new)`);
95
106
  const result = await submitToIndexNowShared(
96
107
  [...changed, ...added],
97
- indexNowKey,
108
+ indexNow,
98
109
  siteUrl,
99
110
  { logger }
100
111
  );
@@ -104,7 +115,7 @@ async function handleStaticIndexNow(currentPages, siteUrl, indexNowKey, prevMeta
104
115
  logger.warn(`[indexnow] Failed to submit: ${result.error}`);
105
116
  }
106
117
  }
107
- function createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig, indexNowKey) {
118
+ function createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig, indexNow) {
108
119
  return {
109
120
  prerenderedRoutes: /* @__PURE__ */ new Set(),
110
121
  errorRoutes: /* @__PURE__ */ new Set(),
@@ -114,7 +125,7 @@ function createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig, in
114
125
  llmsFullTxtPath,
115
126
  siteInfo,
116
127
  llmsTxtConfig,
117
- indexNowKey
128
+ indexNow
118
129
  };
119
130
  }
120
131
  async function initCrawler(state) {
@@ -265,11 +276,11 @@ async function prerenderRoute(nitro, route) {
265
276
  nitro._prerenderedRoutes.push(_route);
266
277
  return stat(filePath);
267
278
  }
268
- function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig, indexNowKey) {
279
+ function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig, indexNow) {
269
280
  const nuxt = useNuxt();
270
281
  nuxt.hooks.hook("nitro:init", async (nitro) => {
271
282
  const llmsFullTxtPath = join(nitro.options.output.publicDir, "llms-full.txt");
272
- const state = createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig, indexNowKey);
283
+ const state = createCrawlerState(dbPath, llmsFullTxtPath, siteInfo, llmsTxtConfig, indexNow);
273
284
  let initPromise = null;
274
285
  nitro.hooks.hook("prerender:generate", async (route) => {
275
286
  if (route.error) {
@@ -334,8 +345,8 @@ function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig, indexNowKey) {
334
345
  logger.debug(`Created database dump at __ai-ready/pages.dump (${(dumpData.length / 1024).toFixed(1)}kb compressed)`);
335
346
  const pageHashes = pages.filter((p) => p.contentHash).map((p) => ({ route: p.route, hash: p.contentHash }));
336
347
  let prevMeta = null;
337
- if (state.indexNowKey && state.siteInfo?.url) {
338
- prevMeta = await fetchPreviousMeta(state.siteInfo.url, state.indexNowKey);
348
+ if (state.indexNow && state.siteInfo?.url) {
349
+ prevMeta = await fetchPreviousMeta(state.siteInfo.url, state.indexNow);
339
350
  }
340
351
  const buildId = Date.now().toString(36);
341
352
  const metaContent = JSON.stringify({
@@ -346,8 +357,8 @@ function setupPrerenderHandler(dbPath, siteInfo, llmsTxtConfig, indexNowKey) {
346
357
  });
347
358
  await writeFile(join(publicDataDir, "pages.meta.json"), metaContent, "utf-8");
348
359
  logger.debug(`Wrote build metadata: buildId=${buildId}, ${pageHashes.length} page hashes`);
349
- if (state.indexNowKey && state.siteInfo?.url && prevMeta) {
350
- await handleStaticIndexNow(pageHashes, state.siteInfo.url, state.indexNowKey, prevMeta);
360
+ if (state.indexNow && state.siteInfo?.url && prevMeta) {
361
+ await handleStaticIndexNow(pageHashes, state.siteInfo.url, state.indexNow, prevMeta);
351
362
  }
352
363
  }
353
364
  const llmsStats = await prerenderRoute(nitro, "/llms.txt");
@@ -624,6 +635,28 @@ const module$1 = defineNuxtModule({
624
635
  mergedLlmsTxt.notes = llmsTxtPayload.notes.length > 0 ? llmsTxtPayload.notes : void 0;
625
636
  const prerenderCacheDir = join(nuxt.options.rootDir, "node_modules/.cache/nuxt-seo/ai-ready/routes");
626
637
  const buildDbPath = join(nuxt.options.buildDir, ".data/ai-ready/build.db");
638
+ const runtimeSyncConfig = typeof config.runtimeSync === "object" ? config.runtimeSync : {};
639
+ const runtimeSyncEnabled = !!config.runtimeSync || !!config.cron;
640
+ const indexNow = config.indexNow === true ? createHash("sha256").update(useSiteConfig().url || "nuxt-ai-ready").digest("hex").slice(0, 32) : config.indexNow || process.env.NUXT_AI_READY_INDEX_NOW_KEY;
641
+ let runtimeSyncSecret = config.runtimeSyncSecret || process.env.NUXT_AI_READY_RUNTIME_SYNC_SECRET;
642
+ if (!runtimeSyncSecret && runtimeSyncEnabled) {
643
+ runtimeSyncSecret = randomBytes(32).toString("hex");
644
+ logger.info(`Generated runtimeSyncSecret (use NUXT_AI_READY_RUNTIME_SYNC_SECRET env to set explicitly)`);
645
+ }
646
+ if (runtimeSyncSecret) {
647
+ const cacheDir = join(nuxt.options.rootDir, "node_modules/.cache/nuxt/ai-ready");
648
+ await mkdir(cacheDir, { recursive: true });
649
+ await writeFile(join(cacheDir, "secret"), runtimeSyncSecret);
650
+ }
651
+ registerModule({
652
+ name: "nuxt-ai-ready",
653
+ secret: runtimeSyncSecret,
654
+ features: {
655
+ cron: !!config.cron,
656
+ indexNow: !!indexNow,
657
+ runtimeSync: runtimeSyncEnabled
658
+ }
659
+ });
627
660
  nuxt.hooks.hook("nitro:config", (nitroConfig) => {
628
661
  nitroConfig.experimental = nitroConfig.experimental || {};
629
662
  nitroConfig.experimental.asyncContext = true;
@@ -636,7 +669,7 @@ const module$1 = defineNuxtModule({
636
669
  nitroConfig.vercel.config.crons = nitroConfig.vercel.config.crons || [];
637
670
  nitroConfig.vercel.config.crons.push({
638
671
  schedule: cronSchedule,
639
- path: "/__ai-ready/cron"
672
+ path: runtimeSyncSecret ? `/__ai-ready/cron?secret=${runtimeSyncSecret}` : "/__ai-ready/cron"
640
673
  });
641
674
  } else {
642
675
  nitroConfig.experimental.tasks = true;
@@ -697,9 +730,6 @@ export async function readPageDataFromFilesystem() {
697
730
  export const errorRoutes = []`;
698
731
  });
699
732
  const database = refineDatabaseConfig(config.database || {}, nuxt.options.rootDir);
700
- const runtimeSyncConfig = typeof config.runtimeSync === "object" ? config.runtimeSync : {};
701
- const runtimeSyncEnabled = !!config.runtimeSync || !!config.cron;
702
- const indexNowKey = config.indexNowKey || process.env.NUXT_AI_READY_INDEX_NOW_KEY;
703
733
  nuxt.options.runtimeConfig["nuxt-ai-ready"] = {
704
734
  version: version || "0.0.0",
705
735
  debug: config.debug || false,
@@ -718,8 +748,8 @@ export const errorRoutes = []`;
718
748
  batchSize: runtimeSyncConfig.batchSize ?? 20,
719
749
  pruneTtl: runtimeSyncConfig.pruneTtl ?? 0
720
750
  },
721
- runtimeSyncSecret: config.runtimeSyncSecret,
722
- indexNowKey
751
+ runtimeSyncSecret,
752
+ indexNow
723
753
  };
724
754
  addServerHandler({
725
755
  middleware: true,
@@ -746,8 +776,8 @@ export const errorRoutes = []`;
746
776
  addServerHandler({ route: "/__ai-ready/prune", method: "post", handler: resolve("./runtime/server/routes/__ai-ready/prune.post") });
747
777
  addServerHandler({ route: "/__ai-ready/restore", method: "post", handler: resolve("./runtime/server/routes/__ai-ready/restore.post") });
748
778
  }
749
- if (indexNowKey) {
750
- addServerHandler({ route: `/${indexNowKey}.txt`, handler: resolve("./runtime/server/routes/indexnow-key.get") });
779
+ if (indexNow) {
780
+ addServerHandler({ route: `/${indexNow}.txt`, handler: resolve("./runtime/server/routes/indexnow-key.get") });
751
781
  addServerHandler({ route: "/__ai-ready/indexnow", method: "post", handler: resolve("./runtime/server/routes/__ai-ready/indexnow.post") });
752
782
  if (!runtimeSyncEnabled) {
753
783
  addServerHandler({ route: "/__ai-ready/status", handler: resolve("./runtime/server/routes/__ai-ready/status.get") });
@@ -773,7 +803,7 @@ export const errorRoutes = []`;
773
803
  name: siteConfig.name,
774
804
  url: siteConfig.url,
775
805
  description: siteConfig.description
776
- }, mergedLlmsTxt, indexNowKey);
806
+ }, mergedLlmsTxt, indexNow);
777
807
  }
778
808
  nuxt.options.nitro.routeRules = nuxt.options.nitro.routeRules || {};
779
809
  nuxt.options.nitro.routeRules["/llms.txt"] = { headers: { "Content-Type": "text/plain; charset=utf-8" } };
@@ -1,3 +1,13 @@
1
- import { eventHandler } from "h3";
1
+ import { createError, eventHandler, getQuery } from "h3";
2
+ import { useRuntimeConfig } from "nitropack/runtime";
2
3
  import { runCron } from "../../utils/runCron.js";
3
- export default eventHandler((event) => runCron(event));
4
+ export default eventHandler((event) => {
5
+ const config = useRuntimeConfig(event)["nuxt-ai-ready"];
6
+ if (config.runtimeSyncSecret) {
7
+ const { secret } = getQuery(event);
8
+ if (secret !== config.runtimeSyncSecret) {
9
+ throw createError({ statusCode: 401, message: "Unauthorized" });
10
+ }
11
+ }
12
+ return runCron(event);
13
+ });
@@ -3,7 +3,7 @@ import { useRuntimeConfig } from "nitropack/runtime";
3
3
  import { syncToIndexNow } from "../../utils/indexnow.js";
4
4
  export default eventHandler(async (event) => {
5
5
  const config = useRuntimeConfig(event)["nuxt-ai-ready"];
6
- if (!config.indexNowKey) {
6
+ if (!config.indexNow) {
7
7
  throw createError({ statusCode: 400, message: "IndexNow not configured" });
8
8
  }
9
9
  const query = getQuery(event);
@@ -1,8 +1,14 @@
1
- import { eventHandler } from "h3";
1
+ import { createError, eventHandler, getQuery } from "h3";
2
2
  import { useRuntimeConfig } from "nitropack/runtime";
3
3
  import { countPages, countPagesNeedingIndexNowSync, getIndexNowStats } from "../../db/queries.js";
4
4
  export default eventHandler(async (event) => {
5
5
  const config = useRuntimeConfig(event)["nuxt-ai-ready"];
6
+ if (config.runtimeSyncSecret) {
7
+ const { secret } = getQuery(event);
8
+ if (secret !== config.runtimeSyncSecret) {
9
+ throw createError({ statusCode: 401, message: "Unauthorized" });
10
+ }
11
+ }
6
12
  const [total, pending] = await Promise.all([
7
13
  countPages(event),
8
14
  countPages(event, { where: { pending: true } })
@@ -12,7 +18,7 @@ export default eventHandler(async (event) => {
12
18
  indexed: total - pending,
13
19
  pending
14
20
  };
15
- if (config.indexNowKey) {
21
+ if (config.indexNow) {
16
22
  const [indexNowPending, indexNowStats] = await Promise.all([
17
23
  countPagesNeedingIndexNowSync(event),
18
24
  getIndexNowStats(event)
@@ -135,7 +135,7 @@ export default eventHandler(async (event) => {
135
135
  indexNowRemaining: run.indexNowRemaining,
136
136
  errors: run.errors
137
137
  }));
138
- if (runtimeConfig.indexNowKey) {
138
+ if (runtimeConfig.indexNow) {
139
139
  const [indexNowPending, indexNowStats, indexNowLogEntries] = await Promise.all([
140
140
  countPagesNeedingIndexNowSync(event),
141
141
  getIndexNowStats(event),
@@ -2,8 +2,8 @@ import { eventHandler, setHeader } from "h3";
2
2
  import { useRuntimeConfig } from "nitropack/runtime";
3
3
  export default eventHandler((event) => {
4
4
  const config = useRuntimeConfig(event)["nuxt-ai-ready"];
5
- if (!config.indexNowKey)
5
+ if (!config.indexNow)
6
6
  return null;
7
7
  setHeader(event, "Content-Type", "text/plain; charset=utf-8");
8
- return config.indexNowKey;
8
+ return config.indexNow;
9
9
  });
@@ -1,4 +1,5 @@
1
- import { useNitroApp, useRuntimeConfig } from "nitropack/runtime";
1
+ import { useSiteConfig } from "#site-config/server/composables/useSiteConfig";
2
+ import { useEvent, useNitroApp, useRuntimeConfig } from "nitropack/runtime";
2
3
  import { getPageHash, isPageFresh, queryPages, upsertPage } from "../db/queries.js";
3
4
  import { computeContentHash } from "../db/shared.js";
4
5
  import { logger } from "../logger.js";
@@ -15,7 +16,9 @@ export async function indexPage(route, html, options = {}, event) {
15
16
  const existing = await queryPages(event, { route });
16
17
  const isUpdate = !!existing;
17
18
  const isError = html.includes("__NUXT_ERROR__") || html.includes("nuxt-error-page");
18
- const result = await convertHtmlToMarkdown(html, route, config.mdreamOptions, { extractUpdatedAt: true });
19
+ const siteConfig = useSiteConfig(event || useEvent());
20
+ const fullUrl = `${siteConfig.url}${route}`;
21
+ const result = await convertHtmlToMarkdown(html, fullUrl, config.mdreamOptions, { extractUpdatedAt: true });
19
22
  const updatedAt = result.updatedAt || (/* @__PURE__ */ new Date()).toISOString();
20
23
  const headings = JSON.stringify(result.headings);
21
24
  const keywords = extractKeywords(result.textContent, result.metaKeywords);
@@ -53,7 +53,7 @@ export async function submitToIndexNow(routes, config, siteUrl) {
53
53
  export async function syncToIndexNow(event, limit = 100, options) {
54
54
  const config = useRuntimeConfig(event)["nuxt-ai-ready"];
55
55
  const siteConfig = getSiteConfig(event);
56
- if (!config.indexNowKey) {
56
+ if (!config.indexNow) {
57
57
  return { success: false, submitted: 0, remaining: 0, error: "IndexNow not configured" };
58
58
  }
59
59
  if (!siteConfig.url) {
@@ -80,7 +80,7 @@ export async function syncToIndexNow(event, limit = 100, options) {
80
80
  return { success: true, submitted: 0, remaining: 0 };
81
81
  }
82
82
  const routes = pages.map((p) => p.route);
83
- const result = await submitToIndexNow(routes, { key: config.indexNowKey }, siteConfig.url);
83
+ const result = await submitToIndexNow(routes, { key: config.indexNow }, siteConfig.url);
84
84
  const dbUpdates = async () => {
85
85
  if (config.debug) {
86
86
  await logIndexNowSubmission(event, routes.length, result.success, result.error);
@@ -28,7 +28,7 @@ export async function runCron(providedEvent, options) {
28
28
  const results = {};
29
29
  const allErrors = [];
30
30
  if (debug) {
31
- logger.info(`[cron] Starting cron run (batchSize: ${options?.batchSize ?? config.runtimeSync.batchSize}, indexNow: ${!!config.indexNowKey})`);
31
+ logger.info(`[cron] Starting cron run (batchSize: ${options?.batchSize ?? config.runtimeSync.batchSize}, indexNow: ${!!config.indexNow})`);
32
32
  }
33
33
  if (config.runtimeSync.enabled) {
34
34
  results.stale = await checkAndHandleStale(event).catch((err) => {
@@ -72,7 +72,7 @@ export async function runCron(providedEvent, options) {
72
72
  logger.info(`[cron] Index: ${indexResult.indexed} pages (${indexResult.remaining} remaining${indexResult.errors.length > 0 ? `, ${indexResult.errors.length} errors` : ""})`);
73
73
  }
74
74
  }
75
- if (config.indexNowKey) {
75
+ if (config.indexNow) {
76
76
  const indexNowResult = await syncToIndexNow(event, 100).catch((err) => {
77
77
  console.warn("[ai-ready:cron] IndexNow sync failed:", err.message);
78
78
  return { success: false, submitted: 0, remaining: 0, error: err.message };
@@ -35,7 +35,8 @@ function buildMdreamOptions(url, mdreamOptions, meta, extractUpdatedAt = false)
35
35
  }
36
36
  }
37
37
  });
38
- let options = { origin: url, ...mdreamOptions };
38
+ const origin = new URL(url).origin;
39
+ let options = { origin, ...mdreamOptions };
39
40
  if (mdreamOptions?.preset === "minimal") {
40
41
  options = withMinimalPreset(options);
41
42
  }
@@ -106,16 +106,15 @@ export interface ModuleOptions {
106
106
  /**
107
107
  * Enable scheduled cron task (runs every minute)
108
108
  * When true, automatically enables runtimeSync for background indexing
109
- * Also runs IndexNow sync if indexNowKey is configured
109
+ * Also runs IndexNow sync if indexNow is enabled
110
110
  */
111
111
  cron?: boolean;
112
112
  /**
113
- * IndexNow API key for instant search engine notifications
114
- * When set, enables IndexNow submissions to Bing, Yandex, Naver, Seznam
115
- * Get one from https://www.bing.com/indexnow
116
- * Can also be set via NUXT_AI_READY_INDEX_NOW_KEY env var
113
+ * Enable IndexNow for instant search engine notifications
114
+ * Submits to Bing, Yandex, Naver, Seznam when content changes
115
+ * Set to `true` to derive key from site URL, or provide your own string
117
116
  */
118
- indexNowKey?: string;
117
+ indexNow?: boolean | string;
119
118
  /**
120
119
  * Secret token for authenticating runtime sync endpoints
121
120
  * When set, requires ?secret=<token> query param for poll/prune/indexnow endpoints
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ai-ready",
3
3
  "type": "module",
4
- "version": "0.8.1",
4
+ "version": "0.9.0",
5
5
  "description": "Best practice AI & LLM discoverability for Nuxt sites.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -32,6 +32,9 @@
32
32
  }
33
33
  },
34
34
  "main": "./dist/module.mjs",
35
+ "bin": {
36
+ "nuxt-ai-ready": "./dist/cli.mjs"
37
+ },
35
38
  "files": [
36
39
  "dist",
37
40
  "mcp.d.ts"
@@ -48,6 +51,7 @@
48
51
  "dependencies": {
49
52
  "@clack/prompts": "^0.11.0",
50
53
  "@nuxt/kit": "^4.2.2",
54
+ "citty": "^0.1.6",
51
55
  "consola": "^3.4.2",
52
56
  "db0": "^0.3.4",
53
57
  "defu": "^6.1.4",
@@ -78,12 +82,12 @@
78
82
  "@vue/test-utils": "^2.4.6",
79
83
  "@vueuse/nuxt": "^14.1.0",
80
84
  "agents": "^0.3.5",
81
- "ai": "^6.0.35",
85
+ "ai": "^6.0.38",
82
86
  "better-sqlite3": "^12.6.0",
83
87
  "bumpp": "^10.4.0",
84
88
  "eslint": "^9.39.2",
85
89
  "execa": "^9.6.1",
86
- "happy-dom": "^20.3.0",
90
+ "happy-dom": "^20.3.1",
87
91
  "nuxt": "^4.2.2",
88
92
  "nuxt-site-config": "^3.2.17",
89
93
  "playwright": "^1.57.0",
@@ -94,7 +98,7 @@
94
98
  "vue": "^3.5.26",
95
99
  "vue-router": "^4.6.4",
96
100
  "vue-tsc": "^3.2.2",
97
- "wrangler": "^4.59.1",
101
+ "wrangler": "^4.59.2",
98
102
  "zod": "^4.3.5"
99
103
  },
100
104
  "resolutions": {