indxel-cli 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -7,9 +7,10 @@ import { Command as Command8 } from "commander";
7
7
  import { Command } from "commander";
8
8
  import chalk from "chalk";
9
9
  import ora from "ora";
10
- import { existsSync as existsSync3 } from "fs";
11
- import { writeFile as writeFile2, mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
12
- import { join as join3 } from "path";
10
+ import { existsSync as existsSync4 } from "fs";
11
+ import { writeFile as writeFile2, mkdir as mkdir2, readFile as readFile4 } from "fs/promises";
12
+ import { join as join4 } from "path";
13
+ import { createInterface } from "readline";
13
14
 
14
15
  // src/detect.ts
15
16
  import { existsSync } from "fs";
@@ -18,6 +19,7 @@ import { join } from "path";
18
19
  async function detectProject(cwd) {
19
20
  const info = {
20
21
  root: cwd,
22
+ framework: "unknown",
21
23
  isNextJs: false,
22
24
  usesAppRouter: false,
23
25
  appDir: "app",
@@ -26,6 +28,19 @@ async function detectProject(cwd) {
26
28
  hasSitemap: false,
27
29
  hasRobots: false
28
30
  };
31
+ info.isTypeScript = existsSync(join(cwd, "tsconfig.json")) || existsSync(join(cwd, "tsconfig.ts"));
32
+ let pkg = null;
33
+ const pkgPath = join(cwd, "package.json");
34
+ if (existsSync(pkgPath)) {
35
+ try {
36
+ pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
37
+ } catch {
38
+ }
39
+ }
40
+ const deps = {
41
+ ...pkg?.dependencies,
42
+ ...pkg?.devDependencies
43
+ };
29
44
  const nextConfigs = [
30
45
  "next.config.ts",
31
46
  "next.config.js",
@@ -33,31 +48,93 @@ async function detectProject(cwd) {
33
48
  "next.config.cjs"
34
49
  ];
35
50
  info.isNextJs = nextConfigs.some((f) => existsSync(join(cwd, f)));
36
- const pkgPath = join(cwd, "package.json");
37
- if (existsSync(pkgPath)) {
38
- try {
39
- const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
40
- const nextDep = pkg.dependencies?.next ?? pkg.devDependencies?.next;
41
- if (nextDep) {
42
- info.isNextJs = true;
43
- info.nextVersion = nextDep.replace(/[\^~>=<]/g, "").trim();
51
+ if (deps.next) {
52
+ info.isNextJs = true;
53
+ info.nextVersion = deps.next.replace(/[\^~>=<]/g, "").trim();
54
+ }
55
+ if (info.isNextJs) {
56
+ info.framework = "nextjs";
57
+ info.frameworkVersion = info.nextVersion;
58
+ if (existsSync(join(cwd, "src", "app"))) {
59
+ info.usesAppRouter = true;
60
+ info.appDir = "src/app";
61
+ } else if (existsSync(join(cwd, "app"))) {
62
+ info.usesAppRouter = true;
63
+ info.appDir = "app";
64
+ }
65
+ }
66
+ if (!info.isNextJs) {
67
+ const nuxtConfigs = ["nuxt.config.ts", "nuxt.config.js"];
68
+ const hasNuxtConfig = nuxtConfigs.some((f) => existsSync(join(cwd, f)));
69
+ if (hasNuxtConfig || deps.nuxt) {
70
+ info.framework = "nuxt";
71
+ info.frameworkVersion = deps.nuxt?.replace(/[\^~>=<]/g, "").trim();
72
+ if (existsSync(join(cwd, "pages"))) {
73
+ info.usesAppRouter = true;
74
+ info.appDir = "pages";
44
75
  }
45
- } catch {
46
76
  }
47
77
  }
48
- info.isTypeScript = existsSync(join(cwd, "tsconfig.json")) || existsSync(join(cwd, "tsconfig.ts"));
49
- if (existsSync(join(cwd, "src", "app"))) {
50
- info.usesAppRouter = true;
51
- info.appDir = "src/app";
52
- } else if (existsSync(join(cwd, "app"))) {
53
- info.usesAppRouter = true;
54
- info.appDir = "app";
78
+ if (info.framework === "unknown") {
79
+ const remixDep = deps["@remix-run/react"] ?? deps["@remix-run/node"] ?? deps["@remix-run/cloudflare"];
80
+ const hasRemixConfig = existsSync(join(cwd, "remix.config.js")) || existsSync(join(cwd, "remix.config.ts"));
81
+ if (hasRemixConfig || remixDep) {
82
+ info.framework = "remix";
83
+ info.frameworkVersion = remixDep?.replace(/[\^~>=<]/g, "").trim();
84
+ if (existsSync(join(cwd, "app", "routes"))) {
85
+ info.usesAppRouter = true;
86
+ info.appDir = "app/routes";
87
+ } else if (existsSync(join(cwd, "app"))) {
88
+ info.usesAppRouter = true;
89
+ info.appDir = "app";
90
+ }
91
+ }
92
+ }
93
+ if (info.framework === "unknown") {
94
+ const astroConfigs = ["astro.config.mjs", "astro.config.ts", "astro.config.js"];
95
+ const hasAstroConfig = astroConfigs.some((f) => existsSync(join(cwd, f)));
96
+ if (hasAstroConfig || deps.astro) {
97
+ info.framework = "astro";
98
+ info.frameworkVersion = deps.astro?.replace(/[\^~>=<]/g, "").trim();
99
+ if (existsSync(join(cwd, "src", "pages"))) {
100
+ info.usesAppRouter = true;
101
+ info.appDir = "src/pages";
102
+ }
103
+ }
104
+ }
105
+ if (info.framework === "unknown") {
106
+ const hasSvelteConfig = existsSync(join(cwd, "svelte.config.js")) || existsSync(join(cwd, "svelte.config.ts"));
107
+ if (hasSvelteConfig && deps["@sveltejs/kit"] || deps["@sveltejs/kit"]) {
108
+ info.framework = "sveltekit";
109
+ info.frameworkVersion = deps["@sveltejs/kit"]?.replace(/[\^~>=<]/g, "").trim();
110
+ if (existsSync(join(cwd, "src", "routes"))) {
111
+ info.usesAppRouter = true;
112
+ info.appDir = "src/routes";
113
+ }
114
+ }
55
115
  }
56
116
  info.hasSeoConfig = existsSync(join(cwd, "seo.config.ts")) || existsSync(join(cwd, "seo.config.js"));
57
- info.hasSitemap = existsSync(join(cwd, info.appDir, "sitemap.ts")) || existsSync(join(cwd, info.appDir, "sitemap.js")) || existsSync(join(cwd, info.appDir, "sitemap.xml"));
58
- info.hasRobots = existsSync(join(cwd, info.appDir, "robots.ts")) || existsSync(join(cwd, info.appDir, "robots.js")) || existsSync(join(cwd, info.appDir, "robots.txt"));
117
+ const sitemapDirs = info.framework === "nextjs" ? [info.appDir] : [info.appDir, "public", "."];
118
+ const robotsDirs = info.framework === "nextjs" ? [info.appDir] : [info.appDir, "public", "."];
119
+ info.hasSitemap = sitemapDirs.some(
120
+ (dir) => existsSync(join(cwd, dir, "sitemap.ts")) || existsSync(join(cwd, dir, "sitemap.js")) || existsSync(join(cwd, dir, "sitemap.xml"))
121
+ );
122
+ info.hasRobots = robotsDirs.some(
123
+ (dir) => existsSync(join(cwd, dir, "robots.ts")) || existsSync(join(cwd, dir, "robots.js")) || existsSync(join(cwd, dir, "robots.txt"))
124
+ );
59
125
  return info;
60
126
  }
127
+ function frameworkLabel(fw) {
128
+ const labels = {
129
+ nextjs: "Next.js",
130
+ nuxt: "Nuxt",
131
+ remix: "Remix",
132
+ astro: "Astro",
133
+ sveltekit: "SvelteKit",
134
+ unknown: "Unknown"
135
+ };
136
+ return labels[fw];
137
+ }
61
138
 
62
139
  // src/store.ts
63
140
  import { existsSync as existsSync2 } from "fs";
@@ -159,13 +236,13 @@ async function resolveApiKey(explicit) {
159
236
  }
160
237
 
161
238
  // src/templates.ts
162
- function seoConfigTemplate(isTypeScript) {
239
+ function seoConfigTemplate(isTypeScript, siteUrl = "https://example.com") {
163
240
  if (isTypeScript) {
164
241
  return `import { defineSEO } from 'indxel'
165
242
 
166
243
  export default defineSEO({
167
244
  siteName: 'My Site',
168
- siteUrl: 'https://example.com',
245
+ siteUrl: '${siteUrl}',
169
246
  titleTemplate: '%s | My Site',
170
247
  defaultDescription: 'A short description of your site for search engines.',
171
248
  defaultOGImage: '/og-image.png',
@@ -177,7 +254,7 @@ export default defineSEO({
177
254
  // organization: {
178
255
  // name: 'My Company',
179
256
  // logo: '/logo.png',
180
- // url: 'https://example.com',
257
+ // url: '${siteUrl}',
181
258
  // },
182
259
  })
183
260
  `;
@@ -186,7 +263,7 @@ export default defineSEO({
186
263
 
187
264
  module.exports = defineSEO({
188
265
  siteName: 'My Site',
189
- siteUrl: 'https://example.com',
266
+ siteUrl: '${siteUrl}',
190
267
  titleTemplate: '%s | My Site',
191
268
  defaultDescription: 'A short description of your site for search engines.',
192
269
  defaultOGImage: '/og-image.png',
@@ -194,12 +271,12 @@ module.exports = defineSEO({
194
271
  })
195
272
  `;
196
273
  }
197
- function sitemapTemplate(isTypeScript) {
274
+ function sitemapTemplate(isTypeScript, siteUrl = "https://example.com") {
198
275
  if (isTypeScript) {
199
276
  return `import type { MetadataRoute } from 'next'
200
277
 
201
278
  export default function sitemap(): MetadataRoute.Sitemap {
202
- const baseUrl = 'https://example.com'
279
+ const baseUrl = '${siteUrl}'
203
280
 
204
281
  return [
205
282
  {
@@ -223,7 +300,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
223
300
  }
224
301
  return `/** @returns {import('next').MetadataRoute.Sitemap} */
225
302
  export default function sitemap() {
226
- const baseUrl = 'https://example.com'
303
+ const baseUrl = '${siteUrl}'
227
304
 
228
305
  return [
229
306
  {
@@ -236,12 +313,12 @@ export default function sitemap() {
236
313
  }
237
314
  `;
238
315
  }
239
- function robotsTemplate(isTypeScript) {
316
+ function robotsTemplate(isTypeScript, siteUrl = "https://example.com") {
240
317
  if (isTypeScript) {
241
318
  return `import type { MetadataRoute } from 'next'
242
319
 
243
320
  export default function robots(): MetadataRoute.Robots {
244
- const baseUrl = 'https://example.com'
321
+ const baseUrl = '${siteUrl}'
245
322
 
246
323
  return {
247
324
  rules: [
@@ -258,7 +335,7 @@ export default function robots(): MetadataRoute.Robots {
258
335
  }
259
336
  return `/** @returns {import('next').MetadataRoute.Robots} */
260
337
  export default function robots() {
261
- const baseUrl = 'https://example.com'
338
+ const baseUrl = '${siteUrl}'
262
339
 
263
340
  return {
264
341
  rules: [
@@ -274,7 +351,233 @@ export default function robots() {
274
351
  `;
275
352
  }
276
353
 
354
+ // src/config.ts
355
+ import { existsSync as existsSync3 } from "fs";
356
+ import { readFile as readFile3 } from "fs/promises";
357
+ import { join as join3 } from "path";
358
+ var CONFIG_FILES = [".indxelrc.json", ".indxelrc", "indxel.config.json"];
359
+ async function loadConfig(cwd) {
360
+ for (const file of CONFIG_FILES) {
361
+ const path = join3(cwd, file);
362
+ if (existsSync3(path)) {
363
+ try {
364
+ const content = await readFile3(path, "utf-8");
365
+ return JSON.parse(content);
366
+ } catch {
367
+ }
368
+ }
369
+ }
370
+ return {};
371
+ }
372
+ var URL_ENV_VARS = {
373
+ // Framework-specific
374
+ nextjs: ["NEXT_PUBLIC_APP_URL", "NEXT_PUBLIC_SITE_URL", "NEXT_PUBLIC_BASE_URL", "NEXT_PUBLIC_URL"],
375
+ nuxt: ["NUXT_PUBLIC_SITE_URL", "NUXT_PUBLIC_APP_URL", "NUXT_SITE_URL"],
376
+ remix: ["APP_URL", "BASE_URL"],
377
+ astro: ["SITE_URL", "PUBLIC_SITE_URL", "ASTRO_SITE"],
378
+ sveltekit: ["PUBLIC_SITE_URL", "VITE_SITE_URL"],
379
+ // Generic (works for all)
380
+ generic: ["SITE_URL", "BASE_URL", "APP_URL", "DEPLOY_URL", "URL"]
381
+ };
382
+ async function detectProjectUrl(cwd, framework) {
383
+ const candidates = [];
384
+ const config = await loadConfig(cwd);
385
+ if (config.baseUrl) {
386
+ candidates.push({ url: normalize(config.baseUrl), source: ".indxelrc.json", weight: 10 });
387
+ }
388
+ for (const ext of ["ts", "js"]) {
389
+ const configPath = join3(cwd, `seo.config.${ext}`);
390
+ if (existsSync3(configPath)) {
391
+ try {
392
+ const content = await readFile3(configPath, "utf-8");
393
+ const match = content.match(/siteUrl\s*:\s*['"`]([^'"`]+)['"`]/);
394
+ if (match?.[1] && match[1] !== "https://example.com") {
395
+ candidates.push({ url: normalize(match[1]), source: `seo.config.${ext}`, weight: 10 });
396
+ }
397
+ } catch {
398
+ }
399
+ }
400
+ }
401
+ const envVars = [
402
+ ...framework && URL_ENV_VARS[framework] ? URL_ENV_VARS[framework] : [],
403
+ ...URL_ENV_VARS.generic
404
+ ];
405
+ const uniqueVars = [...new Set(envVars)];
406
+ const envFiles = [".env.production", ".env.production.local", ".env.local", ".env"];
407
+ for (const file of envFiles) {
408
+ const envPath = join3(cwd, file);
409
+ if (!existsSync3(envPath)) continue;
410
+ try {
411
+ const content = await readFile3(envPath, "utf-8");
412
+ for (const varName of uniqueVars) {
413
+ const match = content.match(new RegExp(`^${varName}\\s*=\\s*['"]?([^'"
414
+ ]+)['"]?`, "m"));
415
+ if (match?.[1]) {
416
+ const url = match[1].trim();
417
+ if (isProductionUrl(url)) {
418
+ const weight = file.includes("production") ? 7 : 5;
419
+ candidates.push({ url: normalize(url), source: `${file} (${varName})`, weight });
420
+ }
421
+ }
422
+ }
423
+ } catch {
424
+ }
425
+ }
426
+ const fwUrl = await detectFromFrameworkConfig(cwd, framework);
427
+ if (fwUrl) {
428
+ candidates.push(fwUrl);
429
+ }
430
+ const vercelUrl = await detectFromVercel(cwd);
431
+ if (vercelUrl) {
432
+ candidates.push(vercelUrl);
433
+ }
434
+ const netlifyUrl = await detectFromNetlify(cwd);
435
+ if (netlifyUrl) {
436
+ candidates.push(netlifyUrl);
437
+ }
438
+ try {
439
+ const pkg = JSON.parse(await readFile3(join3(cwd, "package.json"), "utf-8"));
440
+ if (pkg.homepage && isProductionUrl(pkg.homepage)) {
441
+ candidates.push({ url: normalize(pkg.homepage), source: "package.json (homepage)", weight: 4 });
442
+ }
443
+ } catch {
444
+ }
445
+ if (candidates.length === 0) {
446
+ return { url: null, source: "", confident: false, all: [] };
447
+ }
448
+ const originCounts = /* @__PURE__ */ new Map();
449
+ for (const c of candidates) {
450
+ const origin = new URL(c.url).origin;
451
+ const existing = originCounts.get(origin);
452
+ if (!existing || c.weight > existing.best.weight) {
453
+ originCounts.set(origin, {
454
+ count: (existing?.count ?? 0) + 1,
455
+ best: c
456
+ });
457
+ } else {
458
+ existing.count++;
459
+ }
460
+ }
461
+ let best = null;
462
+ for (const [origin, data] of originCounts) {
463
+ const score = data.count * data.best.weight;
464
+ if (!best || score > best.count * best.best.weight) {
465
+ best = { origin, ...data };
466
+ }
467
+ }
468
+ const result = best.best;
469
+ const confident = result.weight >= 10 || best.count >= 2;
470
+ return {
471
+ url: result.url,
472
+ source: result.source,
473
+ confident,
474
+ all: candidates
475
+ };
476
+ }
477
+ async function resolveProjectUrl(cwd) {
478
+ const result = await detectProjectUrl(cwd);
479
+ return result.url;
480
+ }
481
+ function normalize(url) {
482
+ if (!url.startsWith("http")) url = `https://${url}`;
483
+ return url.replace(/\/+$/, "");
484
+ }
485
+ function isProductionUrl(url) {
486
+ if (!url || !url.startsWith("http")) return false;
487
+ if (url.includes("localhost")) return false;
488
+ if (url.includes("127.0.0.1")) return false;
489
+ if (url.includes("0.0.0.0")) return false;
490
+ return true;
491
+ }
492
+ async function detectFromFrameworkConfig(cwd, framework) {
493
+ if (!framework || framework === "astro") {
494
+ for (const file of ["astro.config.mjs", "astro.config.ts", "astro.config.js"]) {
495
+ const configPath = join3(cwd, file);
496
+ if (existsSync3(configPath)) {
497
+ try {
498
+ const content = await readFile3(configPath, "utf-8");
499
+ const match = content.match(/site\s*:\s*['"`]([^'"`]+)['"`]/);
500
+ if (match?.[1] && isProductionUrl(match[1])) {
501
+ return { url: normalize(match[1]), source: `${file} (site)`, weight: 8 };
502
+ }
503
+ } catch {
504
+ }
505
+ }
506
+ }
507
+ }
508
+ if (!framework || framework === "nuxt") {
509
+ for (const file of ["nuxt.config.ts", "nuxt.config.js"]) {
510
+ const configPath = join3(cwd, file);
511
+ if (existsSync3(configPath)) {
512
+ try {
513
+ const content = await readFile3(configPath, "utf-8");
514
+ const match = content.match(/url\s*:\s*['"`]([^'"`]+)['"`]/);
515
+ if (match?.[1] && isProductionUrl(match[1])) {
516
+ return { url: normalize(match[1]), source: `${file} (site.url)`, weight: 8 };
517
+ }
518
+ } catch {
519
+ }
520
+ }
521
+ }
522
+ }
523
+ return null;
524
+ }
525
+ async function detectFromVercel(cwd) {
526
+ const vercelPath = join3(cwd, ".vercel", "project.json");
527
+ if (!existsSync3(vercelPath)) return null;
528
+ try {
529
+ const content = JSON.parse(await readFile3(vercelPath, "utf-8"));
530
+ if (content.projectName) {
531
+ return {
532
+ url: `https://${content.projectName}.vercel.app`,
533
+ source: ".vercel/project.json",
534
+ weight: 3
535
+ // Low weight — *.vercel.app is often not the real domain
536
+ };
537
+ }
538
+ } catch {
539
+ }
540
+ return null;
541
+ }
542
+ async function detectFromNetlify(cwd) {
543
+ const tomlPath = join3(cwd, "netlify.toml");
544
+ if (existsSync3(tomlPath)) {
545
+ try {
546
+ const content = await readFile3(tomlPath, "utf-8");
547
+ const match = content.match(/URL\s*=\s*['"]([^'"]+)['"]/i);
548
+ if (match?.[1] && isProductionUrl(match[1])) {
549
+ return { url: normalize(match[1]), source: "netlify.toml", weight: 6 };
550
+ }
551
+ } catch {
552
+ }
553
+ }
554
+ const statePath = join3(cwd, ".netlify", "state.json");
555
+ if (existsSync3(statePath)) {
556
+ try {
557
+ const content = JSON.parse(await readFile3(statePath, "utf-8"));
558
+ if (content.siteId) {
559
+ return {
560
+ url: `https://${content.siteId}.netlify.app`,
561
+ source: ".netlify/state.json",
562
+ weight: 2
563
+ };
564
+ }
565
+ } catch {
566
+ }
567
+ }
568
+ return null;
569
+ }
570
+
277
571
  // src/commands/init.ts
572
+ function ask(question) {
573
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
574
+ return new Promise((resolve) => {
575
+ rl.question(question, (answer) => {
576
+ rl.close();
577
+ resolve(answer.trim());
578
+ });
579
+ });
580
+ }
278
581
  var PRE_PUSH_HOOK = `#!/bin/sh
279
582
  # indxel SEO guard \u2014 blocks push if critical SEO errors are found
280
583
  echo "\\033[36m[indxel]\\033[0m Running SEO check before push..."
@@ -286,59 +589,84 @@ if [ $? -ne 0 ]; then
286
589
  exit 1
287
590
  fi
288
591
  `;
289
- var initCommand = new Command("init").description("Initialize indxel in your Next.js project").option("--cwd <path>", "Project directory", process.cwd()).option("--force", "Overwrite existing files", false).option("--hook", "Install git pre-push hook to block pushes on SEO errors", false).action(async (opts) => {
592
+ var initCommand = new Command("init").description("Initialize indxel in your project").option("--cwd <path>", "Project directory", process.cwd()).option("--force", "Overwrite existing files", false).option("--hook", "Install git pre-push hook to block pushes on SEO errors", false).action(async (opts) => {
290
593
  const cwd = opts.cwd;
291
594
  const spinner = ora("Detecting project...").start();
292
595
  const project = await detectProject(cwd);
293
- if (!project.isNextJs) {
294
- spinner.fail("Not a Next.js project");
596
+ if (project.framework === "unknown") {
597
+ spinner.fail("No supported framework detected");
295
598
  console.log(
296
- chalk.dim(" indxel currently supports Next.js projects only.")
599
+ chalk.dim(" Supported: Next.js, Nuxt, Remix, Astro, SvelteKit.")
297
600
  );
298
601
  console.log(
299
- chalk.dim(" Make sure you're in a directory with a next.config file.")
602
+ chalk.dim(" Make sure you're in a project directory with a config file and package.json.")
300
603
  );
301
604
  process.exit(1);
302
605
  }
303
- spinner.succeed(
304
- `Detected Next.js ${project.nextVersion ?? ""} (${project.usesAppRouter ? "App Router" : "Pages Router"})`
305
- );
606
+ const label = frameworkLabel(project.framework);
607
+ const version = project.frameworkVersion ? ` ${project.frameworkVersion}` : "";
608
+ spinner.succeed(`Detected ${label}${version}`);
306
609
  const ext = project.isTypeScript ? "ts" : "js";
307
610
  const filesCreated = [];
611
+ let siteUrl = "https://example.com";
308
612
  if (!project.hasSeoConfig || opts.force) {
309
- const configPath = join3(cwd, `seo.config.${ext}`);
310
- await writeFile2(configPath, seoConfigTemplate(project.isTypeScript), "utf-8");
613
+ const detection = await detectProjectUrl(cwd, project.framework);
614
+ if (detection.url && detection.confident) {
615
+ siteUrl = detection.url;
616
+ console.log(chalk.green(" \u2713") + ` Detected URL: ${chalk.bold(siteUrl)}` + chalk.dim(` (${detection.source})`));
617
+ } else if (detection.url) {
618
+ const answer = await ask(
619
+ chalk.bold(" Site URL") + chalk.dim(` [${detection.url}] `) + chalk.bold(": ")
620
+ );
621
+ siteUrl = answer && answer !== "" ? answer.startsWith("http") ? answer : `https://${answer}` : detection.url;
622
+ } else {
623
+ const answer = await ask(chalk.bold(" Site URL: ") + chalk.dim("(e.g., https://mysite.com) "));
624
+ if (answer && answer !== "") {
625
+ siteUrl = answer.startsWith("http") ? answer : `https://${answer}`;
626
+ }
627
+ }
628
+ const configPath = join4(cwd, `seo.config.${ext}`);
629
+ await writeFile2(configPath, seoConfigTemplate(project.isTypeScript, siteUrl), "utf-8");
311
630
  filesCreated.push(`seo.config.${ext}`);
312
- console.log(chalk.green(" \u2713") + ` Generated seo.config.${ext}`);
631
+ console.log(chalk.green(" \u2713") + ` Generated seo.config.${ext}` + chalk.dim(` (${siteUrl})`));
313
632
  } else {
314
633
  console.log(chalk.dim(` - seo.config.${ext} already exists (skip)`));
315
634
  }
316
- if (!project.hasSitemap || opts.force) {
317
- const sitemapPath = join3(cwd, project.appDir, `sitemap.${ext}`);
318
- await writeFile2(sitemapPath, sitemapTemplate(project.isTypeScript), "utf-8");
319
- filesCreated.push(`${project.appDir}/sitemap.${ext}`);
320
- console.log(chalk.green(" \u2713") + ` Generated ${project.appDir}/sitemap.${ext}`);
321
- } else {
322
- console.log(chalk.dim(` - sitemap already exists (skip)`));
323
- }
324
- if (!project.hasRobots || opts.force) {
325
- const robotsPath = join3(cwd, project.appDir, `robots.${ext}`);
326
- await writeFile2(robotsPath, robotsTemplate(project.isTypeScript), "utf-8");
327
- filesCreated.push(`${project.appDir}/robots.${ext}`);
328
- console.log(chalk.green(" \u2713") + ` Generated ${project.appDir}/robots.${ext}`);
635
+ if (project.framework === "nextjs") {
636
+ if (!project.hasSitemap || opts.force) {
637
+ const sitemapPath = join4(cwd, project.appDir, `sitemap.${ext}`);
638
+ await writeFile2(sitemapPath, sitemapTemplate(project.isTypeScript, siteUrl), "utf-8");
639
+ filesCreated.push(`${project.appDir}/sitemap.${ext}`);
640
+ console.log(chalk.green(" \u2713") + ` Generated ${project.appDir}/sitemap.${ext}`);
641
+ } else {
642
+ console.log(chalk.dim(` - sitemap already exists (skip)`));
643
+ }
644
+ if (!project.hasRobots || opts.force) {
645
+ const robotsPath = join4(cwd, project.appDir, `robots.${ext}`);
646
+ await writeFile2(robotsPath, robotsTemplate(project.isTypeScript, siteUrl), "utf-8");
647
+ filesCreated.push(`${project.appDir}/robots.${ext}`);
648
+ console.log(chalk.green(" \u2713") + ` Generated ${project.appDir}/robots.${ext}`);
649
+ } else {
650
+ console.log(chalk.dim(` - robots already exists (skip)`));
651
+ }
329
652
  } else {
330
- console.log(chalk.dim(` - robots already exists (skip)`));
653
+ if (!project.hasSitemap) {
654
+ console.log(chalk.yellow(" \u26A0") + " No sitemap detected \u2014 add a public/sitemap.xml or generate one with your framework");
655
+ }
656
+ if (!project.hasRobots) {
657
+ console.log(chalk.yellow(" \u26A0") + " No robots.txt detected \u2014 add a public/robots.txt");
658
+ }
331
659
  }
332
- const gitDir = join3(cwd, ".git");
333
- const hasGit = existsSync3(gitDir);
660
+ const gitDir = join4(cwd, ".git");
661
+ const hasGit = existsSync4(gitDir);
334
662
  if (opts.hook || opts.force) {
335
663
  if (!hasGit) {
336
664
  console.log(chalk.yellow(" \u26A0") + " No .git directory found \u2014 skip hook install");
337
665
  } else {
338
- const hooksDir = join3(gitDir, "hooks");
339
- const hookPath = join3(hooksDir, "pre-push");
340
- if (existsSync3(hookPath) && !opts.force) {
341
- const existing = await readFile3(hookPath, "utf-8");
666
+ const hooksDir = join4(gitDir, "hooks");
667
+ const hookPath = join4(hooksDir, "pre-push");
668
+ if (existsSync4(hookPath) && !opts.force) {
669
+ const existing = await readFile4(hookPath, "utf-8");
342
670
  if (existing.includes("indxel")) {
343
671
  console.log(chalk.dim(" - pre-push hook already installed (skip)"));
344
672
  } else {
@@ -354,28 +682,37 @@ var initCommand = new Command("init").description("Initialize indxel in your Nex
354
682
  } else if (hasGit) {
355
683
  console.log(chalk.dim(" - Use --hook to install git pre-push guard"));
356
684
  }
685
+ const gitignorePath = join4(cwd, ".gitignore");
686
+ if (existsSync4(gitignorePath)) {
687
+ const gitignoreContent = await readFile4(gitignorePath, "utf-8");
688
+ if (!gitignoreContent.includes(".indxel")) {
689
+ await writeFile2(gitignorePath, gitignoreContent.trimEnd() + "\n\n# Indxel local config (contains API key)\n.indxel/\n", "utf-8");
690
+ console.log(chalk.green(" \u2713") + " Added .indxel/ to .gitignore");
691
+ }
692
+ }
693
+ const staticDirName = project.framework === "sveltekit" ? "static" : "public";
357
694
  const existingKey = await loadIndexNowKey(cwd);
358
695
  if (!existingKey || opts.force) {
359
696
  const key = generateIndexNowKey();
360
- const publicDir = join3(cwd, "public");
361
- if (!existsSync3(publicDir)) {
697
+ const publicDir = join4(cwd, staticDirName);
698
+ if (!existsSync4(publicDir)) {
362
699
  await mkdir2(publicDir, { recursive: true });
363
700
  }
364
- await writeFile2(join3(publicDir, `${key}.txt`), key, "utf-8");
701
+ await writeFile2(join4(publicDir, `${key}.txt`), key, "utf-8");
365
702
  await saveIndexNowKey(cwd, key);
366
- filesCreated.push(`public/${key}.txt`);
703
+ filesCreated.push(`${staticDirName}/${key}.txt`);
367
704
  console.log(chalk.green(" \u2713") + " IndexNow ready \u2014 Bing, Yandex & Naver will pick up your pages on deploy");
368
705
  } else {
369
- const keyFile = join3(cwd, "public", `${existingKey}.txt`);
370
- if (existsSync3(keyFile)) {
706
+ const keyFile = join4(cwd, staticDirName, `${existingKey}.txt`);
707
+ if (existsSync4(keyFile)) {
371
708
  console.log(chalk.dim(" - IndexNow already set up (skip)"));
372
709
  } else {
373
- const publicDir = join3(cwd, "public");
374
- if (!existsSync3(publicDir)) {
710
+ const publicDir = join4(cwd, staticDirName);
711
+ if (!existsSync4(publicDir)) {
375
712
  await mkdir2(publicDir, { recursive: true });
376
713
  }
377
714
  await writeFile2(keyFile, existingKey, "utf-8");
378
- filesCreated.push(`public/${existingKey}.txt`);
715
+ filesCreated.push(`${staticDirName}/${existingKey}.txt`);
379
716
  console.log(chalk.green(" \u2713") + " IndexNow key file restored");
380
717
  }
381
718
  }
@@ -390,8 +727,8 @@ var initCommand = new Command("init").description("Initialize indxel in your Nex
390
727
  }
391
728
  console.log("");
392
729
  console.log(chalk.dim(" Next steps:"));
393
- console.log(chalk.dim(` 1. Edit seo.config.${ext} with your site details`));
394
- console.log(chalk.dim(" 2. Run ") + chalk.bold("npx indxel check") + chalk.dim(" to audit your pages"));
730
+ console.log(chalk.dim(" 1. Run ") + chalk.bold("npx indxel check") + chalk.dim(" to audit your pages"));
731
+ console.log(chalk.dim(" 2. Run ") + chalk.bold("npx indxel crawl") + chalk.dim(" to crawl your live site"));
395
732
  if (!opts.hook && hasGit) {
396
733
  console.log(chalk.dim(" 3. Run ") + chalk.bold("npx indxel init --hook") + chalk.dim(" to guard git pushes"));
397
734
  }
@@ -408,23 +745,23 @@ import ora2 from "ora";
408
745
  import { validateMetadata } from "indxel";
409
746
 
410
747
  // src/scanner.ts
411
- import { readFile as readFile4 } from "fs/promises";
412
- import { join as join4, dirname, sep } from "path";
748
+ import { readFile as readFile5 } from "fs/promises";
749
+ import { join as join5, dirname, sep } from "path";
413
750
  import { glob } from "glob";
414
751
  async function scanPages(projectRoot, appDir) {
415
- const appDirFull = join4(projectRoot, appDir);
752
+ const appDirFull = join5(projectRoot, appDir);
416
753
  const pageFiles = await glob("**/page.{tsx,ts,jsx,js}", {
417
754
  cwd: appDirFull,
418
755
  ignore: ["**/node_modules/**", "**/_*/**"]
419
756
  });
420
757
  const pages = [];
421
758
  for (const file of pageFiles) {
422
- const fullPath = join4(appDirFull, file);
423
- const content = await readFile4(fullPath, "utf-8");
759
+ const fullPath = join5(appDirFull, file);
760
+ const content = await readFile5(fullPath, "utf-8");
424
761
  const route = filePathToRoute(file);
425
762
  const isClient = isClientComponent(content);
426
763
  const page = {
427
- filePath: join4(appDir, file),
764
+ filePath: join5(appDir, file),
428
765
  route,
429
766
  hasMetadata: false,
430
767
  hasDynamicMetadata: false,
@@ -444,175 +781,817 @@ async function scanPages(projectRoot, appDir) {
444
781
  }
445
782
  pages.push(page);
446
783
  }
447
- const layoutFiles = await glob("**/layout.{tsx,ts,jsx,js}", {
784
+ const layoutFiles = await glob("**/layout.{tsx,ts,jsx,js}", {
785
+ cwd: appDirFull,
786
+ ignore: ["**/node_modules/**", "**/_*/**"]
787
+ });
788
+ const sortedLayouts = layoutFiles.sort((a, b) => {
789
+ const depthA = a.split(sep).length;
790
+ const depthB = b.split(sep).length;
791
+ return depthB - depthA;
792
+ });
793
+ for (const file of sortedLayouts) {
794
+ const fullPath = join5(appDirFull, file);
795
+ const content = await readFile5(fullPath, "utf-8");
796
+ const route = filePathToRoute(file).replace(/\/layout$/, "") || "/";
797
+ const hasMetadataExport = hasExport(content, "metadata") || hasExport(content, "generateMetadata");
798
+ if (hasMetadataExport) {
799
+ const layoutMeta = extractStaticMetadata(content);
800
+ const templateMatch = content.match(/template\s*:\s*["'`]([^"'`]+)["'`]/);
801
+ const titleTemplate = templateMatch?.[1] ?? null;
802
+ for (const page of pages) {
803
+ if (page.route.startsWith(route) || route === "/") {
804
+ if (page.extractedMetadata.title && titleTemplate && !page.titleIsAbsolute) {
805
+ page.extractedMetadata.title = titleTemplate.replace("%s", page.extractedMetadata.title);
806
+ page.titleIsAbsolute = true;
807
+ }
808
+ mergeMetadata(page.extractedMetadata, layoutMeta);
809
+ if (!page.hasMetadata) {
810
+ page.hasMetadata = true;
811
+ }
812
+ }
813
+ }
814
+ }
815
+ }
816
+ return pages.sort((a, b) => a.route.localeCompare(b.route));
817
+ }
818
+ function filePathToRoute(filePath) {
819
+ const dir = dirname(filePath);
820
+ if (dir === ".") return "/";
821
+ const route = "/" + dir.split(sep).join("/");
822
+ return route.replace(/\/\([^)]+\)/g, "") || "/";
823
+ }
824
+ function isClientComponent(source) {
825
+ return /^[\s]*(['"])use client\1/.test(source);
826
+ }
827
+ function hasExport(source, name) {
828
+ const patterns = [
829
+ new RegExp(`export\\s+(const|let|var)\\s+${name}\\b`),
830
+ new RegExp(`export\\s+(async\\s+)?function\\s+${name}\\b`),
831
+ new RegExp(`export\\s+\\{[^}]*\\b${name}\\b[^}]*\\}`)
832
+ ];
833
+ return patterns.some((p) => p.test(source));
834
+ }
835
+ function findMetadataBlock(source) {
836
+ const match = source.match(/export\s+(const|let|var)\s+metadata[\s:]/);
837
+ if (!match || match.index === void 0) return null;
838
+ const start = source.indexOf("{", match.index);
839
+ if (start === -1) return null;
840
+ let depth = 0;
841
+ for (let i = start; i < source.length; i++) {
842
+ if (source[i] === "{") depth++;
843
+ else if (source[i] === "}") {
844
+ depth--;
845
+ if (depth === 0) return source.substring(start, i + 1);
846
+ }
847
+ }
848
+ return null;
849
+ }
850
+ function isMetadataFromWrapper(source) {
851
+ return /export\s+(const|let|var)\s+metadata\s*=\s*[a-zA-Z_$]\w*\s*\(/.test(source);
852
+ }
853
+ function extractStaticMetadata(source) {
854
+ const meta = createEmptyMetadata();
855
+ const usesWrapper = isMetadataFromWrapper(source);
856
+ const metaBlock = findMetadataBlock(source) ?? source;
857
+ const absoluteMatch = metaBlock.match(
858
+ /absolute\s*:\s*(["'`])(.*?)\1/s
859
+ );
860
+ if (absoluteMatch) {
861
+ meta.title = absoluteMatch[2];
862
+ } else {
863
+ const defaultMatch = metaBlock.match(
864
+ /default\s*:\s*(["'`])(.*?)\1/s
865
+ );
866
+ if (defaultMatch) {
867
+ meta.title = defaultMatch[2];
868
+ } else {
869
+ const titleMatch = metaBlock.match(
870
+ /(?:^|[,{\n])\s*title\s*:\s*(["'`])(.*?)\1/s
871
+ );
872
+ if (titleMatch) {
873
+ meta.title = titleMatch[2];
874
+ }
875
+ }
876
+ }
877
+ const descMatch = metaBlock.match(
878
+ /(?:^|[,{\n])\s*description\s*:\s*\n?\s*(["'`])(.*?)\1/s
879
+ );
880
+ if (descMatch) {
881
+ meta.description = descMatch[2];
882
+ }
883
+ if (/openGraph\s*:\s*\{/.test(metaBlock)) {
884
+ const ogTitleMatch = metaBlock.match(
885
+ /openGraph\s*:\s*\{[^}]*title\s*:\s*["'`]([^"'`]+)["'`]/s
886
+ );
887
+ meta.ogTitle = ogTitleMatch ? ogTitleMatch[1] : "[detected]";
888
+ const ogDescMatch = metaBlock.match(
889
+ /openGraph\s*:\s*\{[^}]*description\s*:\s*["'`]([^"'`]+)["'`]/s
890
+ );
891
+ meta.ogDescription = ogDescMatch ? ogDescMatch[1] : "[detected]";
892
+ if (!meta.ogImage) meta.ogImage = "[detected]";
893
+ if (/images\s*:\s*\[/.test(metaBlock)) {
894
+ meta.ogImage = "[detected]";
895
+ }
896
+ }
897
+ if (/twitter\s*:\s*\{/.test(metaBlock)) {
898
+ const cardMatch = metaBlock.match(
899
+ /card\s*:\s*["'`](summary|summary_large_image)["'`]/
900
+ );
901
+ meta.twitterCard = cardMatch ? cardMatch[1] : "[detected]";
902
+ }
903
+ if (/robots\s*:\s*\{/.test(metaBlock) || /robots\s*:\s*["'`]/.test(metaBlock)) {
904
+ const robotsMatch = metaBlock.match(
905
+ /robots\s*:\s*["'`]([^"'`]+)["'`]/
906
+ );
907
+ if (robotsMatch) meta.robots = robotsMatch[1];
908
+ }
909
+ if (/alternates\s*:\s*\{/.test(metaBlock)) {
910
+ const canonicalMatch = metaBlock.match(
911
+ /canonical\s*:\s*["'`]([^"'`]+)["'`]/
912
+ );
913
+ if (canonicalMatch) {
914
+ meta.canonical = canonicalMatch[1];
915
+ } else if (/canonical\s*:/.test(metaBlock)) {
916
+ meta.canonical = "[detected]";
917
+ }
918
+ if (/languages\s*:\s*\{/.test(metaBlock)) {
919
+ const langs = {};
920
+ const langMatches = metaBlock.matchAll(
921
+ /["'`](\w{2}(?:-\w{2})?)["'`]\s*:\s*["'`]([^"'`]+)["'`]/g
922
+ );
923
+ for (const m of langMatches) {
924
+ if (m[1] && m[2] && m[2].startsWith("http")) {
925
+ langs[m[1]] = m[2];
926
+ }
927
+ }
928
+ if (Object.keys(langs).length > 0) {
929
+ meta.alternates = langs;
930
+ }
931
+ }
932
+ }
933
+ if (!meta.title && /(?:^|[,{\n])\s*title\s*:\s*(?:\{|[a-zA-Z_$])/.test(metaBlock)) {
934
+ meta.title = "[detected]";
935
+ }
936
+ if (!meta.description && /(?:^|[,{\n])\s*description\s*:\s*[a-zA-Z_$]/.test(metaBlock)) {
937
+ meta.description = "[detected]";
938
+ }
939
+ if (usesWrapper && (meta.title || meta.description)) {
940
+ if (!meta.ogTitle) meta.ogTitle = "[detected]";
941
+ if (!meta.ogDescription) meta.ogDescription = "[detected]";
942
+ if (!meta.ogImage) meta.ogImage = "[detected]";
943
+ if (!meta.twitterCard) meta.twitterCard = "[detected]";
944
+ if (!meta.canonical) meta.canonical = "[detected]";
945
+ if (!meta.robots) meta.robots = "[detected]";
946
+ }
947
+ if (/application\/ld\+json/.test(source) || /generateLD/.test(source) || /JsonLD/.test(source)) {
948
+ meta.structuredData = [{ "@context": "https://schema.org", "@type": "detected" }];
949
+ }
950
+ if (/viewport\s*[:=]/.test(source)) {
951
+ meta.viewport = "detected";
952
+ }
953
+ if (/icons\s*:\s*\{/.test(metaBlock) || /favicon/.test(metaBlock)) {
954
+ meta.favicon = "detected";
955
+ }
956
+ return meta;
957
+ }
958
+ function createEmptyMetadata() {
959
+ return {
960
+ title: null,
961
+ description: null,
962
+ canonical: null,
963
+ ogTitle: null,
964
+ ogDescription: null,
965
+ ogImage: null,
966
+ ogType: null,
967
+ twitterCard: null,
968
+ twitterTitle: null,
969
+ twitterDescription: null,
970
+ robots: null,
971
+ alternates: null,
972
+ structuredData: null,
973
+ viewport: null,
974
+ favicon: null
975
+ };
976
+ }
977
+ function mergeMetadata(target, source) {
978
+ for (const key of Object.keys(source)) {
979
+ if (target[key] === null || target[key] === void 0) {
980
+ target[key] = source[key];
981
+ }
982
+ }
983
+ }
984
+
985
+ // src/scanner-nuxt.ts
986
+ import { readFile as readFile6 } from "fs/promises";
987
+ import { join as join6, sep as sep2 } from "path";
988
+ import { glob as glob2 } from "glob";
989
+ async function scanNuxtPages(projectRoot, appDir) {
990
+ const appDirFull = join6(projectRoot, appDir);
991
+ const pageFiles = await glob2("**/*.vue", {
992
+ cwd: appDirFull,
993
+ ignore: ["**/node_modules/**", "**/components/**", "**/_*/**"]
994
+ });
995
+ const pages = [];
996
+ for (const file of pageFiles) {
997
+ const fullPath = join6(appDirFull, file);
998
+ const content = await readFile6(fullPath, "utf-8");
999
+ const route = vueFilePathToRoute(file);
1000
+ const page = {
1001
+ filePath: join6(appDir, file),
1002
+ route,
1003
+ hasMetadata: false,
1004
+ hasDynamicMetadata: false,
1005
+ isClientComponent: false,
1006
+ titleIsAbsolute: false,
1007
+ extractedMetadata: createEmptyMetadata2()
1008
+ };
1009
+ const hasUseSeoMeta = /useSeoMeta\s*\(/.test(content);
1010
+ const hasUseHead = /useHead\s*\(/.test(content);
1011
+ const hasDefinePageMeta = /definePageMeta\s*\(/.test(content);
1012
+ page.hasMetadata = hasUseSeoMeta || hasUseHead || hasDefinePageMeta;
1013
+ if (page.hasMetadata) {
1014
+ const hasComputed = /computed\s*\(/.test(content) || /useAsyncData/.test(content) || /useFetch/.test(content);
1015
+ page.hasDynamicMetadata = hasComputed;
1016
+ }
1017
+ if (hasUseSeoMeta) {
1018
+ page.extractedMetadata = extractNuxtSeoMeta(content);
1019
+ } else if (hasUseHead) {
1020
+ page.extractedMetadata = extractNuxtUseHead(content);
1021
+ }
1022
+ pages.push(page);
1023
+ }
1024
+ const layoutSources = [];
1025
+ try {
1026
+ const appVueContent = await readFile6(join6(projectRoot, "app.vue"), "utf-8");
1027
+ layoutSources.push({ content: appVueContent, scope: "global" });
1028
+ } catch {
1029
+ }
1030
+ const layoutDir = join6(projectRoot, "layouts");
1031
+ try {
1032
+ const layoutFiles = await glob2("**/*.vue", {
1033
+ cwd: layoutDir,
1034
+ ignore: ["**/node_modules/**"]
1035
+ });
1036
+ for (const file of layoutFiles) {
1037
+ try {
1038
+ const content = await readFile6(join6(layoutDir, file), "utf-8");
1039
+ layoutSources.push({ content, scope: "global" });
1040
+ } catch {
1041
+ }
1042
+ }
1043
+ } catch {
1044
+ }
1045
+ for (const layout of layoutSources) {
1046
+ const hasUseSeoMeta = /useSeoMeta\s*\(/.test(layout.content);
1047
+ const hasUseHead = /useHead\s*\(/.test(layout.content);
1048
+ if (!hasUseSeoMeta && !hasUseHead) continue;
1049
+ const layoutMeta = hasUseSeoMeta ? extractNuxtSeoMeta(layout.content) : extractNuxtUseHead(layout.content);
1050
+ const templateMatch = layout.content.match(/titleTemplate\s*:\s*["'`]([^"'`]+)["'`]/);
1051
+ const titleTemplate = templateMatch?.[1] ?? null;
1052
+ for (const page of pages) {
1053
+ if (page.extractedMetadata.title && titleTemplate && !page.titleIsAbsolute && page.extractedMetadata.title !== "[detected]") {
1054
+ page.extractedMetadata.title = titleTemplate.replace("%s", page.extractedMetadata.title);
1055
+ page.titleIsAbsolute = true;
1056
+ }
1057
+ mergeMetadata2(page.extractedMetadata, layoutMeta);
1058
+ if (!page.hasMetadata) page.hasMetadata = true;
1059
+ }
1060
+ }
1061
+ return pages.sort((a, b) => a.route.localeCompare(b.route));
1062
+ }
1063
+ function vueFilePathToRoute(filePath) {
1064
+ let route = filePath.replace(/\.vue$/, "");
1065
+ route = route.replace(/\/index$/, "").replace(/^index$/, "");
1066
+ route = "/" + route.split(sep2).join("/");
1067
+ route = route.replace(/\/\([^)]+\)/g, "") || "/";
1068
+ return route;
1069
+ }
1070
+ function extractNuxtSeoMeta(source) {
1071
+ const meta = createEmptyMetadata2();
1072
+ const block = findCallBlock(source, "useSeoMeta");
1073
+ if (!block) return meta;
1074
+ const titleMatch = block.match(/(?<![a-zA-Z])title\s*:\s*["'`]([^"'`]+)["'`]/);
1075
+ if (titleMatch) meta.title = titleMatch[1];
1076
+ const descMatch = block.match(/(?<![a-zA-Z])description\s*:\s*["'`]([^"'`]+)["'`]/);
1077
+ if (descMatch) meta.description = descMatch[1];
1078
+ const ogTitleMatch = block.match(/ogTitle\s*:\s*["'`]([^"'`]+)["'`]/);
1079
+ if (ogTitleMatch) meta.ogTitle = ogTitleMatch[1];
1080
+ const ogDescMatch = block.match(/ogDescription\s*:\s*["'`]([^"'`]+)["'`]/);
1081
+ if (ogDescMatch) meta.ogDescription = ogDescMatch[1];
1082
+ const ogImageMatch = block.match(/ogImage\s*:\s*["'`]([^"'`]+)["'`]/);
1083
+ if (ogImageMatch) meta.ogImage = ogImageMatch[1];
1084
+ const twitterCardMatch = block.match(/twitterCard\s*:\s*["'`](summary|summary_large_image)["'`]/);
1085
+ if (twitterCardMatch) meta.twitterCard = twitterCardMatch[1];
1086
+ const robotsMatch = block.match(/(?<![a-zA-Z])robots\s*:\s*["'`]([^"'`]+)["'`]/);
1087
+ if (robotsMatch) meta.robots = robotsMatch[1];
1088
+ if (!meta.title && /(?<![a-zA-Z])title\s*:\s*[a-zA-Z_$]/.test(block)) meta.title = "[detected]";
1089
+ if (!meta.description && /(?<![a-zA-Z])description\s*:\s*[a-zA-Z_$]/.test(block)) meta.description = "[detected]";
1090
+ if (!meta.ogTitle && /ogTitle\s*:\s*[a-zA-Z_$]/.test(block)) meta.ogTitle = "[detected]";
1091
+ if (!meta.ogDescription && /ogDescription\s*:\s*[a-zA-Z_$]/.test(block)) meta.ogDescription = "[detected]";
1092
+ if (!meta.ogImage && /ogImage\s*:\s*[a-zA-Z_$]/.test(block)) meta.ogImage = "[detected]";
1093
+ return meta;
1094
+ }
1095
+ function extractNuxtUseHead(source) {
1096
+ const meta = createEmptyMetadata2();
1097
+ const block = findCallBlock(source, "useHead");
1098
+ if (!block) return meta;
1099
+ const titleMatch = block.match(/title\s*:\s*["'`]([^"'`]+)["'`]/);
1100
+ if (titleMatch) meta.title = titleMatch[1];
1101
+ const descMatch = block.match(/name\s*:\s*["'`]description["'`]\s*,\s*content\s*:\s*["'`]([^"'`]+)["'`]/);
1102
+ if (descMatch) meta.description = descMatch[1];
1103
+ const ogTitleMatch = block.match(/property\s*:\s*["'`]og:title["'`]\s*,\s*content\s*:\s*["'`]([^"'`]+)["'`]/);
1104
+ if (ogTitleMatch) meta.ogTitle = ogTitleMatch[1];
1105
+ const ogDescMatch = block.match(/property\s*:\s*["'`]og:description["'`]\s*,\s*content\s*:\s*["'`]([^"'`]+)["'`]/);
1106
+ if (ogDescMatch) meta.ogDescription = ogDescMatch[1];
1107
+ const ogImageMatch = block.match(/property\s*:\s*["'`]og:image["'`]\s*,\s*content\s*:\s*["'`]([^"'`]+)["'`]/);
1108
+ if (ogImageMatch) meta.ogImage = ogImageMatch[1];
1109
+ if (!meta.title && /title\s*:\s*[a-zA-Z_$]/.test(block)) meta.title = "[detected]";
1110
+ if (/application\/ld\+json/.test(source)) {
1111
+ meta.structuredData = [{ "@context": "https://schema.org", "@type": "detected" }];
1112
+ }
1113
+ return meta;
1114
+ }
1115
+ function findCallBlock(source, funcName) {
1116
+ const regex = new RegExp(`${funcName}\\s*\\(`);
1117
+ const match = source.match(regex);
1118
+ if (!match || match.index === void 0) return null;
1119
+ const start = source.indexOf("(", match.index);
1120
+ if (start === -1) return null;
1121
+ let depth = 0;
1122
+ for (let i = start; i < source.length; i++) {
1123
+ if (source[i] === "(") depth++;
1124
+ else if (source[i] === ")") {
1125
+ depth--;
1126
+ if (depth === 0) return source.substring(start, i + 1);
1127
+ }
1128
+ }
1129
+ return null;
1130
+ }
1131
+ function createEmptyMetadata2() {
1132
+ return {
1133
+ title: null,
1134
+ description: null,
1135
+ canonical: null,
1136
+ ogTitle: null,
1137
+ ogDescription: null,
1138
+ ogImage: null,
1139
+ ogType: null,
1140
+ twitterCard: null,
1141
+ twitterTitle: null,
1142
+ twitterDescription: null,
1143
+ robots: null,
1144
+ alternates: null,
1145
+ structuredData: null,
1146
+ viewport: null,
1147
+ favicon: null
1148
+ };
1149
+ }
1150
+ function mergeMetadata2(target, source) {
1151
+ for (const key of Object.keys(source)) {
1152
+ if (target[key] === null || target[key] === void 0) {
1153
+ target[key] = source[key];
1154
+ }
1155
+ }
1156
+ }
1157
+
1158
+ // src/scanner-remix.ts
1159
+ import { readFile as readFile7 } from "fs/promises";
1160
+ import { join as join7, sep as sep3 } from "path";
1161
+ import { glob as glob3 } from "glob";
1162
+ async function scanRemixPages(projectRoot, appDir) {
1163
+ const appDirFull = join7(projectRoot, appDir);
1164
+ const routeFiles = await glob3("**/*.{tsx,ts,jsx,js}", {
1165
+ cwd: appDirFull,
1166
+ ignore: ["**/node_modules/**", "**/__*/**", "**/*.server.*", "**/*.client.*"]
1167
+ });
1168
+ const pages = [];
1169
+ for (const file of routeFiles) {
1170
+ const fullPath = join7(appDirFull, file);
1171
+ const content = await readFile7(fullPath, "utf-8");
1172
+ const route = remixFilePathToRoute(file);
1173
+ const page = {
1174
+ filePath: join7(appDir, file),
1175
+ route,
1176
+ hasMetadata: false,
1177
+ hasDynamicMetadata: false,
1178
+ isClientComponent: false,
1179
+ titleIsAbsolute: false,
1180
+ extractedMetadata: createEmptyMetadata3()
1181
+ };
1182
+ const hasMetaExport = hasExport2(content, "meta");
1183
+ page.hasMetadata = hasMetaExport;
1184
+ if (hasMetaExport) {
1185
+ const metaBody = extractMetaBody(content);
1186
+ if (metaBody) {
1187
+ const usesData = /\bdata\./.test(metaBody) || /\bdata\[/.test(metaBody);
1188
+ const usesMatches = /\bmatches\b/.test(metaBody);
1189
+ const usesParams = /\bparams\./.test(metaBody);
1190
+ page.hasDynamicMetadata = usesData || usesMatches || usesParams;
1191
+ }
1192
+ }
1193
+ if (hasMetaExport) {
1194
+ page.extractedMetadata = extractRemixMeta(content);
1195
+ }
1196
+ if (/application\/ld\+json/.test(content)) {
1197
+ page.extractedMetadata.structuredData = [{ "@context": "https://schema.org", "@type": "detected" }];
1198
+ }
1199
+ pages.push(page);
1200
+ }
1201
+ for (const rootFile of ["root.tsx", "root.jsx", "root.ts", "root.js"]) {
1202
+ const rootPath = join7(projectRoot, "app", rootFile);
1203
+ try {
1204
+ const rootContent = await readFile7(rootPath, "utf-8");
1205
+ if (hasExport2(rootContent, "meta")) {
1206
+ const rootMeta = extractRemixMeta(rootContent);
1207
+ for (const page of pages) {
1208
+ mergeMetadata3(page.extractedMetadata, rootMeta);
1209
+ if (!page.hasMetadata) page.hasMetadata = true;
1210
+ }
1211
+ }
1212
+ } catch {
1213
+ }
1214
+ }
1215
+ return pages.sort((a, b) => a.route.localeCompare(b.route));
1216
+ }
1217
+ function remixFilePathToRoute(filePath) {
1218
+ let route = filePath.replace(/\.(tsx|ts|jsx|js)$/, "").replace(/\/index$/, "").replace(/^index$/, "");
1219
+ route = route.replace(/\._index$/, "");
1220
+ route = route.replace(/\$/g, "[").replace(/\./g, "/");
1221
+ route = route.replace(/\[([^/]+)/g, (_, name) => `[${name}]`);
1222
+ route = "/" + route.split(sep3).join("/");
1223
+ route = route.replace(/\/_[^/]+/g, "");
1224
+ return route || "/";
1225
+ }
1226
+ function extractRemixMeta(source) {
1227
+ const meta = createEmptyMetadata3();
1228
+ const metaMatch = source.match(/export\s+(?:const|function)\s+meta\s*=?\s*(?:\([^)]*\)\s*(?:=>)?\s*)?[\[({]/);
1229
+ if (!metaMatch || metaMatch.index === void 0) return meta;
1230
+ const startIdx = metaMatch.index;
1231
+ const block = source.substring(startIdx, Math.min(startIdx + 2e3, source.length));
1232
+ const titleMatch = block.match(/\{\s*title\s*:\s*["'`]([^"'`]+)["'`]/);
1233
+ if (titleMatch) meta.title = titleMatch[1];
1234
+ const descMatch = block.match(/name\s*:\s*["'`]description["'`]\s*,\s*content\s*:\s*["'`]([^"'`]+)["'`]/);
1235
+ if (descMatch) meta.description = descMatch[1];
1236
+ const ogTitleMatch = block.match(/property\s*:\s*["'`]og:title["'`]\s*,\s*content\s*:\s*["'`]([^"'`]+)["'`]/);
1237
+ if (ogTitleMatch) meta.ogTitle = ogTitleMatch[1];
1238
+ const ogDescMatch = block.match(/property\s*:\s*["'`]og:description["'`]\s*,\s*content\s*:\s*["'`]([^"'`]+)["'`]/);
1239
+ if (ogDescMatch) meta.ogDescription = ogDescMatch[1];
1240
+ const ogImageMatch = block.match(/property\s*:\s*["'`]og:image["'`]\s*,\s*content\s*:\s*["'`]([^"'`]+)["'`]/);
1241
+ if (ogImageMatch) meta.ogImage = ogImageMatch[1];
1242
+ if (!meta.title && /title\s*:\s*[a-zA-Z_$]/.test(block)) meta.title = "[detected]";
1243
+ return meta;
1244
+ }
1245
+ function extractMetaBody(source) {
1246
+ const arrowMatch = source.match(/export\s+(?:const|let|var)\s+meta\s*=\s*\([^)]*\)\s*(?::\s*[^=]*?)?\s*=>\s*/);
1247
+ if (arrowMatch && arrowMatch.index !== void 0) {
1248
+ const bodyStart = arrowMatch.index + arrowMatch[0].length;
1249
+ return source.substring(bodyStart, Math.min(bodyStart + 3e3, source.length));
1250
+ }
1251
+ const funcMatch = source.match(/export\s+function\s+meta\s*\([^)]*\)\s*(?::\s*[^{]*)?\s*\{/);
1252
+ if (funcMatch && funcMatch.index !== void 0) {
1253
+ const bodyStart = funcMatch.index + funcMatch[0].length;
1254
+ return source.substring(bodyStart, Math.min(bodyStart + 3e3, source.length));
1255
+ }
1256
+ return null;
1257
+ }
1258
+ function hasExport2(source, name) {
1259
+ const patterns = [
1260
+ new RegExp(`export\\s+(const|let|var)\\s+${name}\\b`),
1261
+ new RegExp(`export\\s+(async\\s+)?function\\s+${name}\\b`),
1262
+ new RegExp(`export\\s+\\{[^}]*\\b${name}\\b[^}]*\\}`)
1263
+ ];
1264
+ return patterns.some((p) => p.test(source));
1265
+ }
1266
+ function createEmptyMetadata3() {
1267
+ return {
1268
+ title: null,
1269
+ description: null,
1270
+ canonical: null,
1271
+ ogTitle: null,
1272
+ ogDescription: null,
1273
+ ogImage: null,
1274
+ ogType: null,
1275
+ twitterCard: null,
1276
+ twitterTitle: null,
1277
+ twitterDescription: null,
1278
+ robots: null,
1279
+ alternates: null,
1280
+ structuredData: null,
1281
+ viewport: null,
1282
+ favicon: null
1283
+ };
1284
+ }
1285
+ function mergeMetadata3(target, source) {
1286
+ for (const key of Object.keys(source)) {
1287
+ if (target[key] === null || target[key] === void 0) {
1288
+ target[key] = source[key];
1289
+ }
1290
+ }
1291
+ }
1292
+
1293
+ // src/scanner-astro.ts
1294
+ import { readFile as readFile8 } from "fs/promises";
1295
+ import { join as join8, sep as sep4 } from "path";
1296
+ import { glob as glob4 } from "glob";
1297
+ async function scanAstroPages(projectRoot, appDir) {
1298
+ const appDirFull = join8(projectRoot, appDir);
1299
+ const pageFiles = await glob4("**/*.{astro,md,mdx}", {
1300
+ cwd: appDirFull,
1301
+ ignore: ["**/node_modules/**", "**/_*/**", "**/components/**"]
1302
+ });
1303
+ const pages = [];
1304
+ for (const file of pageFiles) {
1305
+ const fullPath = join8(appDirFull, file);
1306
+ const content = await readFile8(fullPath, "utf-8");
1307
+ const route = astroFilePathToRoute(file);
1308
+ const page = {
1309
+ filePath: join8(appDir, file),
1310
+ route,
1311
+ hasMetadata: false,
1312
+ hasDynamicMetadata: false,
1313
+ isClientComponent: false,
1314
+ titleIsAbsolute: false,
1315
+ extractedMetadata: createEmptyMetadata4()
1316
+ };
1317
+ if (file.endsWith(".md") || file.endsWith(".mdx")) {
1318
+ page.extractedMetadata = extractMarkdownMeta(content);
1319
+ page.hasMetadata = page.extractedMetadata.title !== null || page.extractedMetadata.description !== null;
1320
+ } else {
1321
+ page.extractedMetadata = extractAstroMeta(content);
1322
+ page.hasMetadata = hasAstroSeoSetup(content);
1323
+ page.hasDynamicMetadata = /Astro\.props/.test(content) || /getStaticPaths/.test(content);
1324
+ }
1325
+ if (/application\/ld\+json/.test(content)) {
1326
+ page.extractedMetadata.structuredData = [{ "@context": "https://schema.org", "@type": "detected" }];
1327
+ }
1328
+ pages.push(page);
1329
+ }
1330
+ const layoutFiles = await glob4("**/layouts/**/*.astro", {
1331
+ cwd: join8(projectRoot, "src"),
1332
+ ignore: ["**/node_modules/**"]
1333
+ });
1334
+ const layoutNames = /* @__PURE__ */ new Set();
1335
+ for (const file of layoutFiles) {
1336
+ try {
1337
+ const content = await readFile8(join8(projectRoot, "src", file), "utf-8");
1338
+ if (hasAstroSeoSetup(content) || /Astro\.props/.test(content)) {
1339
+ const name = file.replace(/.*\//, "").replace(/\.astro$/, "");
1340
+ layoutNames.add(name);
1341
+ const layoutMeta = extractAstroMeta(content);
1342
+ const staticLayoutMeta = { ...layoutMeta };
1343
+ if (staticLayoutMeta.title === "[detected]") staticLayoutMeta.title = null;
1344
+ if (staticLayoutMeta.description === "[detected]") staticLayoutMeta.description = null;
1345
+ for (const page of pages) {
1346
+ mergeMetadata4(page.extractedMetadata, staticLayoutMeta);
1347
+ }
1348
+ }
1349
+ } catch {
1350
+ }
1351
+ }
1352
+ if (layoutNames.size > 0) {
1353
+ const layoutPattern = new RegExp(
1354
+ `<(?:${[...layoutNames].join("|")})\\s([^>]*)`,
1355
+ "g"
1356
+ );
1357
+ for (const page of pages) {
1358
+ const fullPath = join8(appDirFull, page.filePath.replace(appDir + "/", ""));
1359
+ try {
1360
+ const content = await readFile8(fullPath, "utf-8");
1361
+ const matches = content.matchAll(layoutPattern);
1362
+ for (const m of matches) {
1363
+ const attrs = m[1];
1364
+ if (!page.extractedMetadata.title) {
1365
+ const titleProp = attrs.match(/title=["']([^"']+)["']/);
1366
+ if (titleProp) page.extractedMetadata.title = titleProp[1];
1367
+ else if (/title=\{/.test(attrs)) page.extractedMetadata.title = "[detected]";
1368
+ }
1369
+ if (!page.extractedMetadata.description) {
1370
+ const descProp = attrs.match(/description=["']([^"']+)["']/);
1371
+ if (descProp) page.extractedMetadata.description = descProp[1];
1372
+ else if (/description=\{/.test(attrs)) page.extractedMetadata.description = "[detected]";
1373
+ }
1374
+ if (page.extractedMetadata.title || page.extractedMetadata.description) {
1375
+ page.hasMetadata = true;
1376
+ }
1377
+ }
1378
+ } catch {
1379
+ }
1380
+ }
1381
+ }
1382
+ return pages.sort((a, b) => a.route.localeCompare(b.route));
1383
+ }
1384
+ function astroFilePathToRoute(filePath) {
1385
+ let route = filePath.replace(/\.(astro|md|mdx)$/, "").replace(/\/index$/, "").replace(/^index$/, "");
1386
+ route = "/" + route.split(sep4).join("/");
1387
+ return route || "/";
1388
+ }
1389
+ function hasAstroSeoSetup(source) {
1390
+ const hasSeoMeta = /<meta\s[^>]*name=["']description["']/.test(source) || /<meta\s[^>]*property=["']og:/.test(source) || /<meta\s[^>]*name=["']robots["']/.test(source) || /<meta\s[^>]*name=["']twitter:/.test(source) || /<meta\s[^>]*content=["'][^"']*["'][^>]*name=["']description["']/.test(source) || /<meta\s[^>]*content=["'][^"']*["'][^>]*property=["']og:/.test(source);
1391
+ return /<title[\s>]/.test(source) || hasSeoMeta || /<SEO[\s/>]/.test(source) || // Common Astro SEO component
1392
+ /<BaseHead[\s/>]/.test(source) || // Common Astro pattern
1393
+ /@astrojs\/seo/.test(source) || /astro-seo/.test(source) || /Astro\.props\.title/.test(source);
1394
+ }
1395
+ function extractMetaTag(source, attr, name) {
1396
+ const r1 = new RegExp(`<meta\\s[^>]*${attr}=["']${name}["'][^>]*content=["']([^"']+)["']`, "i");
1397
+ const m1 = source.match(r1);
1398
+ if (m1) return m1[1];
1399
+ const r2 = new RegExp(`<meta\\s[^>]*content=["']([^"']+)["'][^>]*${attr}=["']${name}["']`, "i");
1400
+ const m2 = source.match(r2);
1401
+ if (m2) return m2[1];
1402
+ return null;
1403
+ }
1404
+ function hasMetaTagDynamic(source, attr, name) {
1405
+ const r1 = new RegExp(`<meta\\s[^>]*${attr}=["']${name}["'][^>]*content=\\{`);
1406
+ const r2 = new RegExp(`<meta\\s[^>]*content=\\{[^}]*\\}[^>]*${attr}=["']${name}["']`);
1407
+ return r1.test(source) || r2.test(source);
1408
+ }
1409
+ function extractAstroMeta(source) {
1410
+ const meta = createEmptyMetadata4();
1411
+ const titleTagMatch = source.match(/<title[^>]*>([^<{]+)<\/title>/);
1412
+ if (titleTagMatch) meta.title = titleTagMatch[1].trim();
1413
+ meta.description = extractMetaTag(source, "name", "description");
1414
+ meta.ogTitle = extractMetaTag(source, "property", "og:title");
1415
+ meta.ogDescription = extractMetaTag(source, "property", "og:description");
1416
+ meta.ogImage = extractMetaTag(source, "property", "og:image");
1417
+ meta.robots = extractMetaTag(source, "name", "robots");
1418
+ const canonicalMatch = source.match(/<link\s[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["']/) || source.match(/<link\s[^>]*href=["']([^"']+)["'][^>]*rel=["']canonical["']/);
1419
+ if (canonicalMatch) meta.canonical = canonicalMatch[1];
1420
+ if (!meta.title) {
1421
+ const titleContent = source.match(/<title[^>]*>([\s\S]*?)<\/title>/)?.[1] ?? "";
1422
+ if (/\{/.test(titleContent)) meta.title = "[detected]";
1423
+ }
1424
+ if (!meta.description && hasMetaTagDynamic(source, "name", "description")) meta.description = "[detected]";
1425
+ if (!meta.ogTitle && hasMetaTagDynamic(source, "property", "og:title")) meta.ogTitle = "[detected]";
1426
+ if (!meta.ogDescription && hasMetaTagDynamic(source, "property", "og:description")) meta.ogDescription = "[detected]";
1427
+ if (!meta.ogImage && hasMetaTagDynamic(source, "property", "og:image")) meta.ogImage = "[detected]";
1428
+ if (!meta.title) {
1429
+ const seoComponentMatch = source.match(/<(?:SEO|BaseHead|Head)\s[^>]*title=["']([^"']+)["']/);
1430
+ if (seoComponentMatch) meta.title = seoComponentMatch[1];
1431
+ }
1432
+ if (!meta.title) {
1433
+ if (/<(?:SEO|BaseHead|Head)\s[^>]*title=\{/.test(source)) meta.title = "[detected]";
1434
+ }
1435
+ if (!meta.description) {
1436
+ const seoDescMatch = source.match(/<(?:SEO|BaseHead|Head)\s[^>]*description=["']([^"']+)["']/);
1437
+ if (seoDescMatch) meta.description = seoDescMatch[1];
1438
+ else if (/<(?:SEO|BaseHead|Head)\s[^>]*description=\{/.test(source)) meta.description = "[detected]";
1439
+ }
1440
+ if (/application\/ld\+json/.test(source)) {
1441
+ meta.structuredData = [{ "@context": "https://schema.org", "@type": "detected" }];
1442
+ }
1443
+ return meta;
1444
+ }
1445
+ function extractMarkdownMeta(source) {
1446
+ const meta = createEmptyMetadata4();
1447
+ const fmMatch = source.match(/^---\s*\n([\s\S]*?)\n---/);
1448
+ if (!fmMatch) return meta;
1449
+ const fm = fmMatch[1];
1450
+ const titleMatch = fm.match(/^title\s*:\s*["']?(.+?)["']?\s*$/m);
1451
+ if (titleMatch) meta.title = titleMatch[1];
1452
+ const descMatch = fm.match(/^description\s*:\s*["']?(.+?)["']?\s*$/m);
1453
+ if (descMatch) meta.description = descMatch[1];
1454
+ const ogImageMatch = fm.match(/^(?:ogImage|image|og_image)\s*:\s*["']?(.+?)["']?\s*$/m);
1455
+ if (ogImageMatch) meta.ogImage = ogImageMatch[1];
1456
+ return meta;
1457
+ }
1458
+ function createEmptyMetadata4() {
1459
+ return {
1460
+ title: null,
1461
+ description: null,
1462
+ canonical: null,
1463
+ ogTitle: null,
1464
+ ogDescription: null,
1465
+ ogImage: null,
1466
+ ogType: null,
1467
+ twitterCard: null,
1468
+ twitterTitle: null,
1469
+ twitterDescription: null,
1470
+ robots: null,
1471
+ alternates: null,
1472
+ structuredData: null,
1473
+ viewport: null,
1474
+ favicon: null
1475
+ };
1476
+ }
1477
+ function mergeMetadata4(target, source) {
1478
+ for (const key of Object.keys(source)) {
1479
+ if (target[key] === null || target[key] === void 0) {
1480
+ target[key] = source[key];
1481
+ }
1482
+ }
1483
+ }
1484
+
1485
+ // src/scanner-sveltekit.ts
1486
+ import { readFile as readFile9 } from "fs/promises";
1487
+ import { join as join9, dirname as dirname5, sep as sep5 } from "path";
1488
+ import { glob as glob5 } from "glob";
1489
+ async function scanSvelteKitPages(projectRoot, appDir) {
1490
+ const appDirFull = join9(projectRoot, appDir);
1491
+ const pageFiles = await glob5("**/+page.svelte", {
1492
+ cwd: appDirFull,
1493
+ ignore: ["**/node_modules/**"]
1494
+ });
1495
+ const pages = [];
1496
+ for (const file of pageFiles) {
1497
+ const fullPath = join9(appDirFull, file);
1498
+ const content = await readFile9(fullPath, "utf-8");
1499
+ const route = svelteKitFilePathToRoute(file);
1500
+ const page = {
1501
+ filePath: join9(appDir, file),
1502
+ route,
1503
+ hasMetadata: false,
1504
+ hasDynamicMetadata: false,
1505
+ isClientComponent: false,
1506
+ titleIsAbsolute: false,
1507
+ extractedMetadata: createEmptyMetadata5()
1508
+ };
1509
+ const hasSvelteHead = /<svelte:head[\s>]/.test(content);
1510
+ page.hasMetadata = hasSvelteHead;
1511
+ if (hasSvelteHead) {
1512
+ page.extractedMetadata = extractSvelteHeadMeta(content);
1513
+ const headBlock = content.match(/<svelte:head[\s>]([\s\S]*?)<\/svelte:head>/)?.[1] ?? "";
1514
+ const headUsesData = /\bdata\./.test(headBlock) || /\$page/.test(headBlock) || /\{#each/.test(headBlock);
1515
+ page.hasDynamicMetadata = headUsesData;
1516
+ }
1517
+ if (/application\/ld\+json/.test(content)) {
1518
+ page.extractedMetadata.structuredData = [{ "@context": "https://schema.org", "@type": "detected" }];
1519
+ }
1520
+ pages.push(page);
1521
+ }
1522
+ const layoutFiles = await glob5("**/+layout.svelte", {
448
1523
  cwd: appDirFull,
449
- ignore: ["**/node_modules/**", "**/_*/**"]
1524
+ ignore: ["**/node_modules/**"]
450
1525
  });
451
1526
  const sortedLayouts = layoutFiles.sort((a, b) => {
452
- const depthA = a.split(sep).length;
453
- const depthB = b.split(sep).length;
1527
+ const depthA = a.split(sep5).length;
1528
+ const depthB = b.split(sep5).length;
454
1529
  return depthB - depthA;
455
1530
  });
456
1531
  for (const file of sortedLayouts) {
457
- const fullPath = join4(appDirFull, file);
458
- const content = await readFile4(fullPath, "utf-8");
459
- const route = filePathToRoute(file).replace(/\/layout$/, "") || "/";
460
- const hasMetadataExport = hasExport(content, "metadata") || hasExport(content, "generateMetadata");
461
- if (hasMetadataExport) {
462
- const layoutMeta = extractStaticMetadata(content);
463
- const templateMatch = content.match(/template\s*:\s*["'`]([^"'`]+)["'`]/);
464
- const titleTemplate = templateMatch?.[1] ?? null;
1532
+ try {
1533
+ const content = await readFile9(join9(appDirFull, file), "utf-8");
1534
+ if (!/<svelte:head[\s>]/.test(content)) continue;
1535
+ const layoutMeta = extractSvelteHeadMeta(content);
1536
+ const layoutRoute = svelteKitFilePathToRoute(file).replace(/\/\+layout$/, "") || "/";
465
1537
  for (const page of pages) {
466
- if (page.route.startsWith(route) || route === "/") {
467
- if (page.extractedMetadata.title && titleTemplate && !page.titleIsAbsolute) {
468
- page.extractedMetadata.title = titleTemplate.replace("%s", page.extractedMetadata.title);
469
- page.titleIsAbsolute = true;
470
- }
471
- mergeMetadata(page.extractedMetadata, layoutMeta);
472
- if (!page.hasMetadata) {
473
- page.hasMetadata = true;
474
- }
1538
+ if (page.route.startsWith(layoutRoute) || layoutRoute === "/") {
1539
+ mergeMetadata5(page.extractedMetadata, layoutMeta);
1540
+ if (!page.hasMetadata) page.hasMetadata = true;
475
1541
  }
476
1542
  }
1543
+ } catch {
477
1544
  }
478
1545
  }
479
1546
  return pages.sort((a, b) => a.route.localeCompare(b.route));
480
1547
  }
481
- function filePathToRoute(filePath) {
482
- const dir = dirname(filePath);
1548
+ function svelteKitFilePathToRoute(filePath) {
1549
+ const dir = dirname5(filePath);
483
1550
  if (dir === ".") return "/";
484
- const route = "/" + dir.split(sep).join("/");
485
- return route.replace(/\/\([^)]+\)/g, "") || "/";
486
- }
487
- function isClientComponent(source) {
488
- return /^[\s]*(['"])use client\1/.test(source);
489
- }
490
- function hasExport(source, name) {
491
- const patterns = [
492
- new RegExp(`export\\s+(const|let|var)\\s+${name}\\b`),
493
- new RegExp(`export\\s+(async\\s+)?function\\s+${name}\\b`),
494
- new RegExp(`export\\s+\\{[^}]*\\b${name}\\b[^}]*\\}`)
495
- ];
496
- return patterns.some((p) => p.test(source));
497
- }
498
- function findMetadataBlock(source) {
499
- const match = source.match(/export\s+(const|let|var)\s+metadata[\s:]/);
500
- if (!match || match.index === void 0) return null;
501
- const start = source.indexOf("{", match.index);
502
- if (start === -1) return null;
503
- let depth = 0;
504
- for (let i = start; i < source.length; i++) {
505
- if (source[i] === "{") depth++;
506
- else if (source[i] === "}") {
507
- depth--;
508
- if (depth === 0) return source.substring(start, i + 1);
509
- }
510
- }
511
- return null;
1551
+ let route = "/" + dir.split(sep5).join("/");
1552
+ route = route.replace(/\/\([^)]+\)/g, "") || "/";
1553
+ return route;
512
1554
  }
513
- function extractStaticMetadata(source) {
514
- const meta = createEmptyMetadata();
515
- const metaBlock = findMetadataBlock(source) ?? source;
516
- const absoluteMatch = metaBlock.match(
517
- /absolute\s*:\s*["'`]([^"'`]+)["'`]/
518
- );
519
- if (absoluteMatch) {
520
- meta.title = absoluteMatch[1];
521
- } else {
522
- const defaultMatch = metaBlock.match(
523
- /default\s*:\s*["'`]([^"'`]+)["'`]/
524
- );
525
- if (defaultMatch) {
526
- meta.title = defaultMatch[1];
527
- } else {
528
- const titleMatch = metaBlock.match(
529
- /(?:^|[,{\n])\s*title\s*:\s*["'`]([^"'`]+)["'`]/
530
- );
531
- if (titleMatch) {
532
- meta.title = titleMatch[1];
533
- }
534
- }
535
- }
536
- const descMatch = metaBlock.match(
537
- /(?:^|[,{\n])\s*description\s*:\s*\n?\s*["'`]([^"'`]+)["'`]/
538
- );
539
- if (descMatch) {
540
- meta.description = descMatch[1];
541
- }
542
- if (/openGraph\s*:\s*\{/.test(metaBlock)) {
543
- const ogTitleMatch = metaBlock.match(
544
- /openGraph\s*:\s*\{[^}]*title\s*:\s*["'`]([^"'`]+)["'`]/s
545
- );
546
- if (ogTitleMatch) meta.ogTitle = ogTitleMatch[1];
547
- const ogDescMatch = metaBlock.match(
548
- /openGraph\s*:\s*\{[^}]*description\s*:\s*["'`]([^"'`]+)["'`]/s
549
- );
550
- if (ogDescMatch) meta.ogDescription = ogDescMatch[1];
551
- if (/images\s*:\s*\[/.test(metaBlock)) {
552
- meta.ogImage = "[detected]";
553
- }
554
- }
555
- if (/twitter\s*:\s*\{/.test(metaBlock)) {
556
- const cardMatch = metaBlock.match(
557
- /card\s*:\s*["'`](summary|summary_large_image)["'`]/
558
- );
559
- if (cardMatch) meta.twitterCard = cardMatch[1];
560
- }
561
- if (/robots\s*:\s*\{/.test(metaBlock) || /robots\s*:\s*["'`]/.test(metaBlock)) {
562
- const robotsMatch = metaBlock.match(
563
- /robots\s*:\s*["'`]([^"'`]+)["'`]/
564
- );
565
- if (robotsMatch) meta.robots = robotsMatch[1];
566
- }
567
- if (/alternates\s*:\s*\{/.test(metaBlock)) {
568
- const canonicalMatch = metaBlock.match(
569
- /canonical\s*:\s*["'`]([^"'`]+)["'`]/
570
- );
571
- if (canonicalMatch) meta.canonical = canonicalMatch[1];
572
- if (/languages\s*:\s*\{/.test(metaBlock)) {
573
- const langs = {};
574
- const langMatches = metaBlock.matchAll(
575
- /["'`](\w{2}(?:-\w{2})?)["'`]\s*:\s*["'`]([^"'`]+)["'`]/g
576
- );
577
- for (const m of langMatches) {
578
- if (m[1] && m[2] && m[2].startsWith("http")) {
579
- langs[m[1]] = m[2];
580
- }
581
- }
582
- if (Object.keys(langs).length > 0) {
583
- meta.alternates = langs;
584
- }
585
- }
586
- }
587
- if (!meta.title && /(?:^|[,{\n])\s*title\s*:\s*(?:\{|[a-zA-Z_$])/.test(metaBlock)) {
588
- meta.title = "[detected]";
589
- }
590
- if (!meta.description && /(?:^|[,{\n])\s*description\s*:\s*[a-zA-Z_$]/.test(metaBlock)) {
591
- meta.description = "[detected]";
592
- }
593
- if (/openGraph\s*:\s*\{/.test(metaBlock)) {
594
- if (!meta.ogTitle) meta.ogTitle = "[detected]";
595
- if (!meta.ogDescription) meta.ogDescription = "[detected]";
596
- if (!meta.ogImage) meta.ogImage = "[detected]";
597
- }
598
- if (/twitter\s*:\s*\{/.test(metaBlock)) {
599
- if (!meta.twitterCard) meta.twitterCard = "[detected]";
600
- }
601
- if (/alternates\s*:\s*\{/.test(metaBlock) && !meta.canonical) {
602
- if (/canonical\s*:/.test(metaBlock)) meta.canonical = "[detected]";
603
- }
604
- if (/application\/ld\+json/.test(source) || /generateLD/.test(source) || /JsonLD/.test(source)) {
1555
+ function extractSvelteHeadMeta(source) {
1556
+ const meta = createEmptyMetadata5();
1557
+ const headMatch = source.match(/<svelte:head[\s>]([\s\S]*?)<\/svelte:head>/);
1558
+ if (!headMatch) return meta;
1559
+ const head = headMatch[1];
1560
+ const titleMatch = head.match(/<title[^>]*>([^<{]+)<\/title>/);
1561
+ if (titleMatch) meta.title = titleMatch[1].trim();
1562
+ if (!meta.title && /<title[^>]*>\{/.test(head)) meta.title = "[detected]";
1563
+ meta.description = extractMetaTag2(head, "name", "description");
1564
+ meta.ogTitle = extractMetaTag2(head, "property", "og:title");
1565
+ meta.ogDescription = extractMetaTag2(head, "property", "og:description");
1566
+ meta.ogImage = extractMetaTag2(head, "property", "og:image");
1567
+ meta.robots = extractMetaTag2(head, "name", "robots");
1568
+ const canonicalMatch = head.match(/<link\s[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["']/) || head.match(/<link\s[^>]*href=["']([^"']+)["'][^>]*rel=["']canonical["']/);
1569
+ if (canonicalMatch) meta.canonical = canonicalMatch[1];
1570
+ if (!meta.description && hasDynamicMeta(head, "name", "description")) meta.description = "[detected]";
1571
+ if (!meta.ogTitle && hasDynamicMeta(head, "property", "og:title")) meta.ogTitle = "[detected]";
1572
+ if (!meta.ogDescription && hasDynamicMeta(head, "property", "og:description")) meta.ogDescription = "[detected]";
1573
+ if (!meta.ogImage && hasDynamicMeta(head, "property", "og:image")) meta.ogImage = "[detected]";
1574
+ if (!meta.robots && hasDynamicMeta(head, "name", "robots")) meta.robots = "[detected]";
1575
+ if (/application\/ld\+json/.test(head)) {
605
1576
  meta.structuredData = [{ "@context": "https://schema.org", "@type": "detected" }];
606
1577
  }
607
- if (/viewport\s*[:=]/.test(source)) {
608
- meta.viewport = "detected";
609
- }
610
- if (/icons\s*:\s*\{/.test(metaBlock) || /favicon/.test(metaBlock)) {
611
- meta.favicon = "detected";
612
- }
613
1578
  return meta;
614
1579
  }
615
- function createEmptyMetadata() {
1580
+ function extractMetaTag2(html, attr, name) {
1581
+ const r1 = new RegExp(`<meta\\s[^>]*${attr}=["']${name}["'][^>]*content=["']([^"']+)["']`);
1582
+ const m1 = html.match(r1);
1583
+ if (m1) return m1[1];
1584
+ const r2 = new RegExp(`<meta\\s[^>]*content=["']([^"']+)["'][^>]*${attr}=["']${name}["']`);
1585
+ const m2 = html.match(r2);
1586
+ if (m2) return m2[1];
1587
+ return null;
1588
+ }
1589
+ function hasDynamicMeta(html, attr, name) {
1590
+ const r1 = new RegExp(`<meta\\s[^>]*${attr}=["']${name}["'][^>]*content=\\{`);
1591
+ const r2 = new RegExp(`<meta\\s[^>]*content=\\{[^}]*\\}[^>]*${attr}=["']${name}["']`);
1592
+ return r1.test(html) || r2.test(html);
1593
+ }
1594
+ function createEmptyMetadata5() {
616
1595
  return {
617
1596
  title: null,
618
1597
  description: null,
@@ -631,7 +1610,7 @@ function createEmptyMetadata() {
631
1610
  favicon: null
632
1611
  };
633
1612
  }
634
- function mergeMetadata(target, source) {
1613
+ function mergeMetadata5(target, source) {
635
1614
  for (const key of Object.keys(source)) {
636
1615
  if (target[key] === null || target[key] === void 0) {
637
1616
  target[key] = source[key];
@@ -639,23 +1618,22 @@ function mergeMetadata(target, source) {
639
1618
  }
640
1619
  }
641
1620
 
642
- // src/config.ts
643
- import { existsSync as existsSync4 } from "fs";
644
- import { readFile as readFile5 } from "fs/promises";
645
- import { join as join5 } from "path";
646
- var CONFIG_FILES = [".indxelrc.json", ".indxelrc", "indxel.config.json"];
647
- async function loadConfig(cwd) {
648
- for (const file of CONFIG_FILES) {
649
- const path = join5(cwd, file);
650
- if (existsSync4(path)) {
651
- try {
652
- const content = await readFile5(path, "utf-8");
653
- return JSON.parse(content);
654
- } catch {
655
- }
656
- }
1621
+ // src/scan.ts
1622
+ async function scanProject(framework, projectRoot, appDir) {
1623
+ switch (framework) {
1624
+ case "nextjs":
1625
+ return scanPages(projectRoot, appDir);
1626
+ case "nuxt":
1627
+ return scanNuxtPages(projectRoot, appDir);
1628
+ case "remix":
1629
+ return scanRemixPages(projectRoot, appDir);
1630
+ case "astro":
1631
+ return scanAstroPages(projectRoot, appDir);
1632
+ case "sveltekit":
1633
+ return scanSvelteKitPages(projectRoot, appDir);
1634
+ default:
1635
+ return [];
657
1636
  }
658
- return {};
659
1637
  }
660
1638
 
661
1639
  // src/formatter.ts
@@ -946,7 +1924,7 @@ function formatMetadataObject(meta) {
946
1924
  }
947
1925
 
948
1926
  // src/commands/check.ts
949
- var checkCommand = new Command2("check").description("Audit SEO metadata for all pages in your project").option("--cwd <path>", "Project directory", process.cwd()).option("--ci", "CI/CD mode \u2014 strict, exit 1 on any error", false).option("--diff", "Compare with previous check run", false).option("--json", "Output results as JSON", false).option("--strict", "Treat warnings as errors", false).option("--min-score <score>", "Minimum score to pass (0-100, default: exit on any error)").option("--fix", "Show suggested metadata code to fix errors", false).action(async (opts) => {
1927
+ var checkCommand = new Command2("check").description("Audit SEO metadata for all pages in your project").option("--cwd <path>", "Project directory", process.cwd()).option("--ci", "CI/CD mode \u2014 strict, exit 1 on any error", false).option("--diff", "Compare with previous check run", false).option("--json", "Output results as JSON", false).option("--strict", "Treat warnings as errors", false).option("--min-score <score>", "Minimum score to pass (0-100, default: exit on any error)").option("--fix", "Show suggested metadata code to fix errors", false).option("--push", "Push results to Indxel dashboard", false).option("--api-key <key>", "API key for --push (or set INDXEL_API_KEY env var)").action(async (opts) => {
950
1928
  const cwd = opts.cwd;
951
1929
  const isCI = opts.ci;
952
1930
  const isStrict = opts.strict || isCI;
@@ -957,33 +1935,45 @@ var checkCommand = new Command2("check").description("Audit SEO metadata for all
957
1935
  const minScore = opts.minScore ? parseInt(opts.minScore, 10) : config.minScore ?? null;
958
1936
  const spinner = ora2("Detecting project...").start();
959
1937
  const project = await detectProject(cwd);
960
- if (!project.isNextJs) {
961
- spinner.fail("Not a Next.js project");
1938
+ if (project.framework === "unknown") {
1939
+ spinner.fail("No supported framework detected");
962
1940
  if (!jsonOutput) {
963
- console.log(chalk4.dim(" Run this command from a Next.js project root."));
1941
+ console.log(chalk4.dim(" Supported: Next.js, Nuxt, Remix, Astro, SvelteKit."));
1942
+ console.log(chalk4.dim(" For any live site, use: npx indxel crawl <url>"));
964
1943
  }
965
1944
  process.exit(1);
966
1945
  }
967
1946
  if (!project.usesAppRouter) {
968
- spinner.fail("App Router not detected");
1947
+ spinner.fail(`No pages directory detected for ${frameworkLabel(project.framework)}`);
969
1948
  if (!jsonOutput) {
970
- console.log(chalk4.dim(" indxel requires Next.js App Router (src/app or app directory)."));
1949
+ const hints = {
1950
+ nextjs: "indxel requires Next.js App Router (src/app or app directory).",
1951
+ nuxt: "indxel requires a pages/ directory for Nuxt.",
1952
+ remix: "indxel requires an app/routes/ directory for Remix.",
1953
+ astro: "indxel requires a src/pages/ directory for Astro.",
1954
+ sveltekit: "indxel requires a src/routes/ directory for SvelteKit.",
1955
+ unknown: ""
1956
+ };
1957
+ console.log(chalk4.dim(` ${hints[project.framework]}`));
971
1958
  }
972
1959
  process.exit(1);
973
1960
  }
974
- spinner.text = "Scanning pages...";
975
- const allPages = await scanPages(cwd, project.appDir);
1961
+ const label = frameworkLabel(project.framework);
1962
+ const version = project.frameworkVersion ? ` ${project.frameworkVersion}` : "";
1963
+ spinner.succeed(`Detected ${label}${version}`);
1964
+ const scanSpinner = ora2("Scanning pages...").start();
1965
+ const allPages = await scanProject(project.framework, cwd, project.appDir);
976
1966
  if (allPages.length === 0) {
977
- spinner.fail("No pages found");
1967
+ scanSpinner.fail("No pages found");
978
1968
  if (!jsonOutput) {
979
- console.log(chalk4.dim(` No page.tsx/ts files found in ${project.appDir}/`));
1969
+ console.log(chalk4.dim(` No page files found in ${project.appDir}/`));
980
1970
  }
981
1971
  process.exit(1);
982
1972
  }
983
1973
  const ignoreRoutes = config.ignoreRoutes ?? [];
984
1974
  const pages = ignoreRoutes.length > 0 ? allPages.filter((p) => !ignoreRoutes.some((pattern) => matchRoute(p.route, pattern))) : allPages;
985
1975
  const ignoredCount = allPages.length - pages.length;
986
- spinner.succeed(`Found ${allPages.length} page${allPages.length > 1 ? "s" : ""}${ignoredCount > 0 ? ` (${ignoredCount} ignored)` : ""}`);
1976
+ scanSpinner.succeed(`Found ${allPages.length} page${allPages.length > 1 ? "s" : ""}${ignoredCount > 0 ? ` (${ignoredCount} ignored)` : ""}`);
987
1977
  const staticPages = pages.filter((p) => !p.hasDynamicMetadata);
988
1978
  const dynamicPages = pages.filter((p) => p.hasDynamicMetadata);
989
1979
  if (!jsonOutput) {
@@ -1039,6 +2029,70 @@ var checkCommand = new Command2("check").description("Audit SEO metadata for all
1039
2029
  }
1040
2030
  }
1041
2031
  }
2032
+ if (opts.push) {
2033
+ const apiKey = await resolveApiKey(opts.apiKey);
2034
+ if (!apiKey) {
2035
+ if (!jsonOutput) {
2036
+ console.log(chalk4.yellow(" \u26A0") + " To push results to your dashboard, link your project first:");
2037
+ console.log("");
2038
+ console.log(chalk4.bold(" npx indxel link"));
2039
+ console.log("");
2040
+ console.log(chalk4.dim(" Or use --api-key / set INDXEL_API_KEY."));
2041
+ console.log("");
2042
+ }
2043
+ } else {
2044
+ const pushSpinner = jsonOutput ? null : ora2("Pushing results to Indxel...").start();
2045
+ try {
2046
+ const pushUrl = process.env.INDXEL_API_URL || "https://indxel.com";
2047
+ const res = await fetch(`${pushUrl}/api/cli/push`, {
2048
+ method: "POST",
2049
+ headers: {
2050
+ "Content-Type": "application/json",
2051
+ Authorization: `Bearer ${apiKey}`
2052
+ },
2053
+ body: JSON.stringify({
2054
+ type: "check",
2055
+ check: {
2056
+ score: summary.averageScore,
2057
+ grade: summary.grade,
2058
+ totalPages: summary.totalPages,
2059
+ passedPages: summary.passedPages,
2060
+ criticalErrors: summary.criticalErrors,
2061
+ optionalErrors: summary.optionalErrors,
2062
+ pages: summary.results.map((r) => ({
2063
+ route: r.page.route,
2064
+ score: r.validation.score,
2065
+ errors: r.validation.errors.length,
2066
+ warnings: r.validation.warnings.length
2067
+ }))
2068
+ }
2069
+ })
2070
+ });
2071
+ if (res.ok) {
2072
+ const data = await res.json();
2073
+ if (pushSpinner) pushSpinner.succeed(`Pushed to dashboard \u2014 check ${data.checkId}`);
2074
+ if (data.usage && !jsonOutput) {
2075
+ const pct = Math.round(data.usage.used / data.usage.limit * 100);
2076
+ const usageColor = pct >= 80 ? chalk4.yellow : chalk4.dim;
2077
+ console.log(usageColor(` Usage: ${data.usage.used}/${data.usage.limit} checks this month (${pct}%)`));
2078
+ }
2079
+ } else {
2080
+ const data = await res.json().catch(() => ({}));
2081
+ if (pushSpinner) pushSpinner.fail(`Push failed: ${data.error || res.statusText}`);
2082
+ }
2083
+ } catch (err) {
2084
+ if (pushSpinner) pushSpinner.fail(`Push failed: ${err instanceof Error ? err.message : String(err)}`);
2085
+ }
2086
+ if (!jsonOutput) console.log("");
2087
+ }
2088
+ }
2089
+ if (!jsonOutput && !isCI) {
2090
+ if (!opts.push) {
2091
+ console.log(chalk4.dim(" Save to dashboard \u2192 ") + chalk4.bold("npx indxel check --push"));
2092
+ }
2093
+ console.log(chalk4.dim(" Guard deploys \u2192 ") + chalk4.bold("npx indxel init --hook"));
2094
+ console.log("");
2095
+ }
1042
2096
  if (minScore !== null) {
1043
2097
  if (summary.averageScore < minScore) {
1044
2098
  if (!jsonOutput) {
@@ -1077,11 +2131,25 @@ function scoreColor(score) {
1077
2131
  if (score >= 70) return chalk5.yellow;
1078
2132
  return chalk5.red;
1079
2133
  }
1080
- var crawlCommand = new Command3("crawl").description("Crawl a live site, audit every page, check sitemap, robots.txt, and assets").argument("<url>", "URL to start crawling (e.g., https://yoursite.com)").option("--max-pages <n>", "Maximum pages to crawl", "200").option("--max-depth <n>", "Maximum link depth", "5").option("--delay <ms>", "Delay between requests in ms", "200").option("--json", "Output results as JSON", false).option("--strict", "Treat warnings as errors", false).option("--skip-assets", "Skip asset verification", false).option("--skip-sitemap", "Skip sitemap check", false).option("--skip-robots", "Skip robots.txt check", false).option("--ignore <patterns>", "Comma-separated path patterns to exclude from analysis (e.g. /app/*,/admin/*)").option("--push", "Push results to Indxel dashboard", false).option("--api-key <key>", "API key for --push (or set INDXEL_API_KEY env var)").action(async (url, opts) => {
2134
+ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit every page, check sitemap, robots.txt, and assets").argument("[url]", "URL to start crawling (auto-detected from seo.config if omitted)").option("--max-pages <n>", "Maximum pages to crawl", "500").option("--max-depth <n>", "Maximum link depth", "5").option("--delay <ms>", "Delay between requests in ms", "200").option("--json", "Output results as JSON", false).option("--strict", "Treat warnings as errors", false).option("--skip-assets", "Skip asset verification", false).option("--skip-sitemap", "Skip sitemap check", false).option("--skip-robots", "Skip robots.txt check", false).option("--ignore <patterns>", "Comma-separated path patterns to exclude from analysis (e.g. /app/*,/admin/*)").option("--push", "Push results to Indxel dashboard", false).option("--api-key <key>", "API key for --push (or set INDXEL_API_KEY env var)").action(async (urlArg, opts) => {
1081
2135
  const jsonOutput = opts.json;
1082
- const maxPages = parseInt(opts.maxPages, 10);
1083
2136
  const maxDepth = parseInt(opts.maxDepth, 10);
1084
2137
  const delay4 = parseInt(opts.delay, 10);
2138
+ const maxPages = parseInt(opts.maxPages, 10);
2139
+ let url = urlArg;
2140
+ if (!url) {
2141
+ const detected = await resolveProjectUrl(process.cwd());
2142
+ if (detected) {
2143
+ url = detected;
2144
+ if (!jsonOutput) {
2145
+ console.log(chalk5.dim(` Using URL from project config: ${url}`));
2146
+ }
2147
+ } else {
2148
+ console.error(chalk5.red(" No URL provided and none found in seo.config or .indxelrc.json."));
2149
+ console.error(chalk5.dim(" Usage: npx indxel crawl [url]"));
2150
+ process.exit(1);
2151
+ }
2152
+ }
1085
2153
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
1086
2154
  url = `https://${url}`;
1087
2155
  }
@@ -1417,6 +2485,7 @@ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit e
1417
2485
  Authorization: `Bearer ${apiKey}`
1418
2486
  },
1419
2487
  body: JSON.stringify({
2488
+ url,
1420
2489
  crawl: crawlResult,
1421
2490
  robots: robotsResult,
1422
2491
  sitemap: sitemapComparison,
@@ -1436,6 +2505,11 @@ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit e
1436
2505
  if (!jsonOutput) console.log("");
1437
2506
  }
1438
2507
  }
2508
+ if (!opts.push && !jsonOutput) {
2509
+ console.log(chalk5.dim(" Save & compare crawls \u2192 ") + chalk5.bold("npx indxel crawl --push"));
2510
+ console.log(chalk5.dim(" Submit to Google \u2192 ") + chalk5.bold("npx indxel index"));
2511
+ console.log("");
2512
+ }
1439
2513
  if (crawlResult.totalErrors > 0) {
1440
2514
  process.exit(1);
1441
2515
  }
@@ -1446,8 +2520,20 @@ import { Command as Command4 } from "commander";
1446
2520
  import chalk6 from "chalk";
1447
2521
  import ora4 from "ora";
1448
2522
  import { researchKeywords, crawlSite as crawlSite2, analyzeContentGaps } from "indxel";
1449
- var keywordsCommand = new Command4("keywords").description("Research keyword opportunities and find content gaps").argument("<seed>", "Seed keyword or topic to research").option("--locale <locale>", "Language locale", "en").option("--country <country>", "Country code", "us").option("--site <url>", "Site URL to analyze content gaps against").option("--max-pages <n>", "Maximum pages to crawl for gap analysis", "30").option("--json", "Output results as JSON", false).action(async (seed, opts) => {
2523
+ var keywordsCommand = new Command4("keywords").description("Research keyword opportunities and find content gaps").argument("<seed>", "Seed keyword or topic to research").option("--locale <locale>", "Language locale", "en").option("--country <country>", "Country code", "us").option("--site <url>", "Site URL to analyze content gaps against").option("--max-pages <n>", "Maximum pages to crawl for gap analysis", "30").option("--api-key <key>", "Indxel API key (or set INDXEL_API_KEY / run npx indxel link)").option("--json", "Output results as JSON", false).action(async (seed, opts) => {
1450
2524
  const jsonOutput = opts.json;
2525
+ const apiKey = await resolveApiKey(opts.apiKey);
2526
+ if (!apiKey) {
2527
+ console.log("");
2528
+ console.log(chalk6.bold(" indxel keywords") + chalk6.dim(" \u2014 requires a free account"));
2529
+ console.log("");
2530
+ console.log(chalk6.dim(" Keyword research is free but requires a linked project."));
2531
+ console.log(chalk6.dim(" Create a free account in 30 seconds:"));
2532
+ console.log("");
2533
+ console.log(chalk6.bold(" npx indxel link"));
2534
+ console.log("");
2535
+ process.exit(1);
2536
+ }
1451
2537
  if (!jsonOutput) {
1452
2538
  console.log("");
1453
2539
  console.log(chalk6.bold(` indxel keywords`) + chalk6.dim(` \u2014 "${seed}"`));
@@ -1541,6 +2627,10 @@ var keywordsCommand = new Command4("keywords").description("Research keyword opp
1541
2627
  }
1542
2628
  }
1543
2629
  }
2630
+ if (!jsonOutput && !opts.site) {
2631
+ console.log(chalk6.dim(" Find content gaps \u2192 ") + chalk6.bold(`npx indxel keywords "${seed}" --site yoursite.com`));
2632
+ console.log("");
2633
+ }
1544
2634
  if (jsonOutput) {
1545
2635
  console.log(
1546
2636
  JSON.stringify(
@@ -1578,7 +2668,7 @@ async function checkPlan(apiKey) {
1578
2668
  function delay(ms) {
1579
2669
  return new Promise((r) => setTimeout(r, ms));
1580
2670
  }
1581
- var indexCommand = new Command5("index").description("Submit your pages to search engines and check indexation status").argument("<url>", "Site URL (e.g., https://yoursite.com)").option("--check", "Check which pages appear indexed (Pro+)", false).option("--indexnow-key <key>", "IndexNow key (auto-detected from .indxel/ if not specified)").option("--api-key <key>", "Indxel API key (required for --check and IndexNow submission)").option("--json", "Output results as JSON", false).action(async (url, opts) => {
2671
+ var indexCommand = new Command5("index").description("Submit your pages to search engines and check indexation status").argument("[url]", "Site URL (auto-detected from seo.config if omitted)").option("--check", "Check which pages appear indexed (Pro+)", false).option("--indexnow-key <key>", "IndexNow key (auto-detected from .indxel/ if not specified)").option("--api-key <key>", "Indxel API key (required for --check and IndexNow submission)").option("--json", "Output results as JSON", false).action(async (urlArg, opts) => {
1582
2672
  const jsonOutput = opts.json;
1583
2673
  const needsPaid = opts.check;
1584
2674
  function log(...args) {
@@ -1587,6 +2677,18 @@ var indexCommand = new Command5("index").description("Submit your pages to searc
1587
2677
  function spin(text) {
1588
2678
  return jsonOutput ? null : ora5(text).start();
1589
2679
  }
2680
+ let url = urlArg;
2681
+ if (!url) {
2682
+ const detected = await resolveProjectUrl(process.cwd());
2683
+ if (detected) {
2684
+ url = detected;
2685
+ log(chalk7.dim(` Using URL from project config: ${url}`));
2686
+ } else {
2687
+ console.error(chalk7.red(" No URL provided and none found in seo.config or .indxelrc.json."));
2688
+ console.error(chalk7.dim(" Usage: npx indxel index [url]"));
2689
+ process.exit(1);
2690
+ }
2691
+ }
1590
2692
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
1591
2693
  url = `https://${url}`;
1592
2694
  }
@@ -1826,7 +2928,29 @@ function scoreColor2(score) {
1826
2928
  function delay2(ms) {
1827
2929
  return new Promise((r) => setTimeout(r, ms));
1828
2930
  }
1829
- async function fetchPsi(url, strategy) {
2931
+ async function fetchPsi(url, strategy, apiKey) {
2932
+ if (apiKey) {
2933
+ const backendUrl = process.env.INDXEL_API_URL || "https://indxel.com";
2934
+ const res = await fetch(`${backendUrl}/api/cli/perf`, {
2935
+ method: "POST",
2936
+ headers: {
2937
+ "Content-Type": "application/json",
2938
+ Authorization: `Bearer ${apiKey}`
2939
+ },
2940
+ body: JSON.stringify({ url, strategy }),
2941
+ signal: AbortSignal.timeout(6e4)
2942
+ });
2943
+ if (res.ok) {
2944
+ return await res.json();
2945
+ }
2946
+ if (res.status === 401 || res.status === 403) {
2947
+ const data = await res.json().catch(() => ({ error: "Auth failed" }));
2948
+ throw new Error(data.error || `Backend returned ${res.status}`);
2949
+ }
2950
+ }
2951
+ return fetchPsiDirect(url, strategy);
2952
+ }
2953
+ async function fetchPsiDirect(url, strategy) {
1830
2954
  const params = new URLSearchParams({
1831
2955
  url,
1832
2956
  strategy,
@@ -1850,6 +2974,29 @@ function parsePsiResponse(data) {
1850
2974
  if (!lr) throw new Error("No lighthouseResult in PSI response");
1851
2975
  const perfScore = lr.categories?.performance?.score;
1852
2976
  if (perfScore == null) throw new Error("No performance score in PSI response");
2977
+ const diagnostics = [];
2978
+ const auditRefs = lr.categories?.performance?.auditRefs ?? [];
2979
+ const metricIds = /* @__PURE__ */ new Set([
2980
+ "largest-contentful-paint",
2981
+ "cumulative-layout-shift",
2982
+ "interaction-to-next-paint",
2983
+ "first-contentful-paint",
2984
+ "speed-index",
2985
+ "total-blocking-time"
2986
+ ]);
2987
+ for (const ref of auditRefs) {
2988
+ if (metricIds.has(ref.id)) continue;
2989
+ if (ref.group !== "diagnostics" && ref.group !== "load-opportunities" && ref.group !== "budgets") continue;
2990
+ const audit = lr.audits?.[ref.id];
2991
+ if (!audit || audit.score === 1 || audit.scoreDisplayMode === "notApplicable") continue;
2992
+ diagnostics.push({
2993
+ id: ref.id,
2994
+ title: audit.title ?? ref.id,
2995
+ displayValue: audit.displayValue || void 0,
2996
+ savingsMs: audit.details?.overallSavingsMs
2997
+ });
2998
+ }
2999
+ diagnostics.sort((a, b) => (b.savingsMs ?? 0) - (a.savingsMs ?? 0));
1853
3000
  return {
1854
3001
  performanceScore: Math.round(perfScore * 100),
1855
3002
  lcp: lr.audits?.["largest-contentful-paint"]?.numericValue ?? 0,
@@ -1857,7 +3004,8 @@ function parsePsiResponse(data) {
1857
3004
  inp: lr.audits?.["interaction-to-next-paint"]?.numericValue ?? 0,
1858
3005
  fcp: lr.audits?.["first-contentful-paint"]?.numericValue ?? 0,
1859
3006
  si: lr.audits?.["speed-index"]?.numericValue ?? 0,
1860
- tbt: lr.audits?.["total-blocking-time"]?.numericValue ?? 0
3007
+ tbt: lr.audits?.["total-blocking-time"]?.numericValue ?? 0,
3008
+ diagnostics
1861
3009
  };
1862
3010
  }
1863
3011
  function checkBudgets(metrics, budgets) {
@@ -1921,6 +3069,16 @@ function printPageResult(result) {
1921
3069
  ` ${formatMs(metrics.tbt).padEnd(9)} ${chalk8.dim("TBT Total Blocking Time")}`
1922
3070
  );
1923
3071
  console.log("");
3072
+ if (metrics.diagnostics.length > 0) {
3073
+ console.log(chalk8.bold(" Diagnostics"));
3074
+ for (const d of metrics.diagnostics) {
3075
+ const icon = d.savingsMs != null && d.savingsMs > 0 ? chalk8.yellow("\u26A1") : chalk8.dim("\u2139");
3076
+ const savings = d.savingsMs != null && d.savingsMs > 0 ? chalk8.yellow(` -${formatMs(d.savingsMs)}`) : "";
3077
+ const display = d.displayValue ? chalk8.dim(` (${d.displayValue})`) : "";
3078
+ console.log(` ${icon} ${d.title}${display}${savings}`);
3079
+ }
3080
+ console.log("");
3081
+ }
1924
3082
  }
1925
3083
  function printMultiPageSummary(results) {
1926
3084
  const valid = results.filter((r) => r.metrics);
@@ -1951,7 +3109,7 @@ function printMultiPageSummary(results) {
1951
3109
  );
1952
3110
  console.log("");
1953
3111
  }
1954
- var perfCommand = new Command6("perf").description("Test Core Web Vitals and performance via PageSpeed Insights").argument("<url>", "URL to test (e.g., https://yoursite.com)").option(
3112
+ var perfCommand = new Command6("perf").description("Test Core Web Vitals and performance via PageSpeed Insights").argument("[url]", "URL to test (auto-detected from seo.config if omitted)").option(
1955
3113
  "--strategy <strategy>",
1956
3114
  "Testing strategy: mobile or desktop",
1957
3115
  "mobile"
@@ -1959,16 +3117,32 @@ var perfCommand = new Command6("perf").description("Test Core Web Vitals and per
1959
3117
  "--pages <n>",
1960
3118
  "Test top N pages from sitemap (default: 1 = just the URL)",
1961
3119
  "1"
1962
- ).option("--json", "Output results as JSON", false).option("--budget-lcp <ms>", "Fail if LCP exceeds threshold (ms)").option("--budget-cls <score>", "Fail if CLS exceeds threshold").option("--budget-score <n>", "Fail if perf score below threshold").action(async (url, opts) => {
3120
+ ).option("--json", "Output results as JSON", false).option("--budget-lcp <ms>", "Fail if LCP exceeds threshold (ms)").option("--budget-cls <score>", "Fail if CLS exceeds threshold").option("--budget-score <n>", "Fail if perf score below threshold").option("--api-key <key>", "Indxel API key (uses our backend for PSI, or set INDXEL_API_KEY)").action(async (urlArg, opts) => {
1963
3121
  const jsonOutput = opts.json;
1964
3122
  const strategy = opts.strategy;
1965
3123
  const pageCount = parseInt(opts.pages, 10);
3124
+ const apiKey = await resolveApiKey(opts.apiKey);
1966
3125
  if (strategy !== "mobile" && strategy !== "desktop") {
1967
3126
  console.error(
1968
3127
  chalk8.red(" --strategy must be 'mobile' or 'desktop'")
1969
3128
  );
1970
3129
  process.exit(1);
1971
3130
  }
3131
+ let url = urlArg;
3132
+ if (!url) {
3133
+ const detected = await resolveProjectUrl(process.cwd());
3134
+ if (detected) {
3135
+ url = detected;
3136
+ if (!jsonOutput) {
3137
+ console.log(chalk8.dim(` Using URL from project config: ${url}`));
3138
+ console.log("");
3139
+ }
3140
+ } else {
3141
+ console.error(chalk8.red(" No URL provided and none found in seo.config or .indxelrc.json."));
3142
+ console.error(chalk8.dim(" Usage: npx indxel perf [url]"));
3143
+ process.exit(1);
3144
+ }
3145
+ }
1972
3146
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
1973
3147
  url = `https://${url}`;
1974
3148
  }
@@ -1999,7 +3173,7 @@ var perfCommand = new Command6("perf").description("Test Core Web Vitals and per
1999
3173
  urls.length > 1 ? `Testing ${i + 1}/${urls.length}: ${targetUrl}` : `Testing ${targetUrl} (${strategy})...`
2000
3174
  ).start();
2001
3175
  try {
2002
- const metrics = await fetchPsi(targetUrl, strategy);
3176
+ const metrics = await fetchPsi(targetUrl, strategy, apiKey);
2003
3177
  spinner?.stop();
2004
3178
  results.push({ url: targetUrl, strategy, metrics });
2005
3179
  if (!jsonOutput) {
@@ -2084,6 +3258,11 @@ var perfCommand = new Command6("perf").description("Test Core Web Vitals and per
2084
3258
  process.exit(1);
2085
3259
  }
2086
3260
  }
3261
+ if (!jsonOutput && !hasBudgets) {
3262
+ console.log(chalk8.dim(" Enforce budgets in CI \u2192 ") + chalk8.bold("npx indxel perf --budget-score 80 --budget-lcp 2500"));
3263
+ console.log(chalk8.dim(" Full SEO + perf audit \u2192 ") + chalk8.bold("npx indxel crawl"));
3264
+ console.log("");
3265
+ }
2087
3266
  });
2088
3267
 
2089
3268
  // src/commands/link.ts
@@ -2095,11 +3274,15 @@ function delay3(ms) {
2095
3274
  }
2096
3275
  async function openBrowser(url) {
2097
3276
  const { platform } = process;
2098
- const { exec } = await import("child_process");
2099
- const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
2100
- exec(`${cmd} ${url}`);
3277
+ const { execFile } = await import("child_process");
3278
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
3279
+ if (platform === "win32") {
3280
+ execFile(cmd, ["/c", "start", "", url]);
3281
+ } else {
3282
+ execFile(cmd, [url]);
3283
+ }
2101
3284
  }
2102
- var linkCommand = new Command7("link").description("Link this project to your Indxel dashboard for monitoring").option("--api-key <key>", "Link directly with an API key (skip browser flow)").action(async (opts) => {
3285
+ var linkCommand = new Command7("link").description("Link this project to your Indxel dashboard for monitoring").option("--api-key <key>", "Link directly with an account API key (skip browser flow)").action(async (opts) => {
2103
3286
  const apiUrl = process.env.INDXEL_API_URL || "https://indxel.com";
2104
3287
  const cwd = process.cwd();
2105
3288
  console.log("");
@@ -2116,7 +3299,7 @@ var linkCommand = new Command7("link").description("Link this project to your In
2116
3299
  return;
2117
3300
  }
2118
3301
  if (opts.apiKey) {
2119
- const spinner = ora7("Verifying API key...").start();
3302
+ const spinner = ora7("Fetching projects...").start();
2120
3303
  try {
2121
3304
  const res = await fetch(`${apiUrl}/api/projects/by-key`, {
2122
3305
  headers: { Authorization: `Bearer ${opts.apiKey}` },
@@ -2129,15 +3312,48 @@ var linkCommand = new Command7("link").description("Link this project to your In
2129
3312
  process.exit(1);
2130
3313
  }
2131
3314
  const body = await res.json();
2132
- const project = body.project;
2133
- spinner.succeed(`Linked to ${chalk9.bold(project.name)}`);
2134
- await saveProjectConfig(cwd, {
2135
- apiKey: opts.apiKey,
2136
- projectId: project.id,
2137
- projectName: project.name,
2138
- linkedAt: (/* @__PURE__ */ new Date()).toISOString()
2139
- });
2140
- await syncIndexNowKey(cwd, apiUrl, opts.apiKey, project.id);
3315
+ const projects = body.projects;
3316
+ if (projects.length === 0) {
3317
+ spinner.fail("No projects found. Create one at https://indxel.com/dashboard first.");
3318
+ console.log("");
3319
+ process.exit(1);
3320
+ }
3321
+ const detectedUrl = await resolveProjectUrl(cwd);
3322
+ let matched = detectedUrl ? projects.find((p) => normalizeUrl(p.url) === normalizeUrl(detectedUrl)) : null;
3323
+ if (!matched && projects.length === 1) {
3324
+ matched = projects[0];
3325
+ }
3326
+ if (matched) {
3327
+ spinner.succeed(`Linked to ${chalk9.bold(matched.name)}`);
3328
+ await saveProjectConfig(cwd, {
3329
+ apiKey: opts.apiKey,
3330
+ projectId: matched.id,
3331
+ projectName: matched.name,
3332
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString()
3333
+ });
3334
+ await syncIndexNowKey(cwd, apiUrl, opts.apiKey, matched.id);
3335
+ } else {
3336
+ spinner.stop();
3337
+ console.log(chalk9.bold(" Multiple projects found. Pick one:"));
3338
+ console.log("");
3339
+ projects.forEach((p, i) => {
3340
+ console.log(` ${chalk9.bold(`${i + 1}.`)} ${p.name} ${chalk9.dim(`(${p.url})`)}`);
3341
+ });
3342
+ console.log("");
3343
+ console.log(chalk9.dim(" Re-run with the project URL matching your codebase,"));
3344
+ console.log(chalk9.dim(" or add a seo.config.ts with your site URL."));
3345
+ console.log("");
3346
+ const project = projects[0];
3347
+ const linkSpinner = ora7(`Linking to ${project.name}...`).start();
3348
+ await saveProjectConfig(cwd, {
3349
+ apiKey: opts.apiKey,
3350
+ projectId: project.id,
3351
+ projectName: project.name,
3352
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString()
3353
+ });
3354
+ await syncIndexNowKey(cwd, apiUrl, opts.apiKey, project.id);
3355
+ linkSpinner.succeed(`Linked to ${chalk9.bold(project.name)}`);
3356
+ }
2141
3357
  console.log("");
2142
3358
  console.log(chalk9.dim(" Config saved to .indxel/config.json"));
2143
3359
  console.log(chalk9.dim(" You can now use ") + chalk9.bold("npx indxel crawl --push") + chalk9.dim(" without --api-key."));
@@ -2227,6 +3443,9 @@ var linkCommand = new Command7("link").description("Link this project to your In
2227
3443
  console.log("");
2228
3444
  process.exit(1);
2229
3445
  });
3446
+ function normalizeUrl(url) {
3447
+ return url.replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/+$/, "").toLowerCase();
3448
+ }
2230
3449
  async function syncIndexNowKey(cwd, apiUrl, apiKey, projectId) {
2231
3450
  const indexNowKey = await loadIndexNowKey(cwd);
2232
3451
  if (!indexNowKey) return;
@@ -2248,9 +3467,10 @@ async function syncIndexNowKey(cwd, apiUrl, apiKey, projectId) {
2248
3467
  }
2249
3468
 
2250
3469
  // src/index.ts
3470
+ var cliVersion = true ? "0.4.0" : "0.3.1";
2251
3471
  function createProgram() {
2252
3472
  const program2 = new Command8();
2253
- program2.name("indxel").description("Infrastructure SEO developer-first. ESLint pour le SEO.").version("0.1.0");
3473
+ program2.name("indxel").description("Infrastructure SEO developer-first. ESLint pour le SEO.").version(cliVersion);
2254
3474
  program2.addCommand(initCommand);
2255
3475
  program2.addCommand(checkCommand);
2256
3476
  program2.addCommand(crawlCommand);