indxel-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command6 } from "commander";
4
+ import { Command as Command8 } from "commander";
5
5
 
6
6
  // src/commands/init.ts
7
7
  import { Command } from "commander";
8
8
  import chalk from "chalk";
9
9
  import ora from "ora";
10
- import { existsSync as existsSync2 } from "fs";
11
- import { writeFile, mkdir, readFile as readFile2 } from "fs/promises";
12
- import { join as join2 } from "path";
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";
13
13
 
14
14
  // src/detect.ts
15
15
  import { existsSync } from "fs";
@@ -59,6 +59,105 @@ async function detectProject(cwd) {
59
59
  return info;
60
60
  }
61
61
 
62
+ // src/store.ts
63
+ import { existsSync as existsSync2 } from "fs";
64
+ import { readFile as readFile2, writeFile, mkdir } from "fs/promises";
65
+ import { join as join2 } from "path";
66
+ import { randomBytes } from "crypto";
67
+ var STORE_DIR = ".indxel";
68
+ var LAST_CHECK_FILE = "last-check.json";
69
+ var INDEXNOW_KEY_FILE = "indexnow-key.txt";
70
+ var CONFIG_FILE = "config.json";
71
+ async function saveCheckResult(cwd, summary) {
72
+ const storeDir = join2(cwd, STORE_DIR);
73
+ if (!existsSync2(storeDir)) {
74
+ await mkdir(storeDir, { recursive: true });
75
+ }
76
+ const stored = {
77
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
78
+ summary: {
79
+ ...summary,
80
+ // Serialize results with minimal data needed for diff
81
+ results: summary.results.map((r) => ({
82
+ page: {
83
+ filePath: r.page.filePath,
84
+ route: r.page.route,
85
+ hasMetadata: r.page.hasMetadata,
86
+ hasDynamicMetadata: r.page.hasDynamicMetadata,
87
+ isClientComponent: r.page.isClientComponent,
88
+ titleIsAbsolute: r.page.titleIsAbsolute,
89
+ extractedMetadata: r.page.extractedMetadata
90
+ },
91
+ validation: r.validation
92
+ }))
93
+ }
94
+ };
95
+ await writeFile(
96
+ join2(storeDir, LAST_CHECK_FILE),
97
+ JSON.stringify(stored, null, 2),
98
+ "utf-8"
99
+ );
100
+ }
101
+ async function loadPreviousCheck(cwd) {
102
+ const filePath = join2(cwd, STORE_DIR, LAST_CHECK_FILE);
103
+ if (!existsSync2(filePath)) {
104
+ return null;
105
+ }
106
+ try {
107
+ const data = await readFile2(filePath, "utf-8");
108
+ return JSON.parse(data);
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+ function generateIndexNowKey() {
114
+ return randomBytes(16).toString("hex");
115
+ }
116
+ async function saveIndexNowKey(cwd, key) {
117
+ const storeDir = join2(cwd, STORE_DIR);
118
+ if (!existsSync2(storeDir)) {
119
+ await mkdir(storeDir, { recursive: true });
120
+ }
121
+ await writeFile(join2(storeDir, INDEXNOW_KEY_FILE), key, "utf-8");
122
+ }
123
+ async function loadIndexNowKey(cwd) {
124
+ const filePath = join2(cwd, STORE_DIR, INDEXNOW_KEY_FILE);
125
+ if (!existsSync2(filePath)) return null;
126
+ try {
127
+ const key = (await readFile2(filePath, "utf-8")).trim();
128
+ return key || null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+ async function saveProjectConfig(cwd, config) {
134
+ const storeDir = join2(cwd, STORE_DIR);
135
+ if (!existsSync2(storeDir)) {
136
+ await mkdir(storeDir, { recursive: true });
137
+ }
138
+ await writeFile(
139
+ join2(storeDir, CONFIG_FILE),
140
+ JSON.stringify(config, null, 2),
141
+ "utf-8"
142
+ );
143
+ }
144
+ async function loadProjectConfig(cwd) {
145
+ const filePath = join2(cwd, STORE_DIR, CONFIG_FILE);
146
+ if (!existsSync2(filePath)) return null;
147
+ try {
148
+ const data = await readFile2(filePath, "utf-8");
149
+ return JSON.parse(data);
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+ async function resolveApiKey(explicit) {
155
+ if (explicit) return explicit;
156
+ if (process.env.INDXEL_API_KEY) return process.env.INDXEL_API_KEY;
157
+ const config = await loadProjectConfig(process.cwd());
158
+ return config?.apiKey ?? null;
159
+ }
160
+
62
161
  // src/templates.ts
63
162
  function seoConfigTemplate(isTypeScript) {
64
163
  if (isTypeScript) {
@@ -207,47 +306,47 @@ var initCommand = new Command("init").description("Initialize indxel in your Nex
207
306
  const ext = project.isTypeScript ? "ts" : "js";
208
307
  const filesCreated = [];
209
308
  if (!project.hasSeoConfig || opts.force) {
210
- const configPath = join2(cwd, `seo.config.${ext}`);
211
- await writeFile(configPath, seoConfigTemplate(project.isTypeScript), "utf-8");
309
+ const configPath = join3(cwd, `seo.config.${ext}`);
310
+ await writeFile2(configPath, seoConfigTemplate(project.isTypeScript), "utf-8");
212
311
  filesCreated.push(`seo.config.${ext}`);
213
312
  console.log(chalk.green(" \u2713") + ` Generated seo.config.${ext}`);
214
313
  } else {
215
314
  console.log(chalk.dim(` - seo.config.${ext} already exists (skip)`));
216
315
  }
217
316
  if (!project.hasSitemap || opts.force) {
218
- const sitemapPath = join2(cwd, project.appDir, `sitemap.${ext}`);
219
- await writeFile(sitemapPath, sitemapTemplate(project.isTypeScript), "utf-8");
317
+ const sitemapPath = join3(cwd, project.appDir, `sitemap.${ext}`);
318
+ await writeFile2(sitemapPath, sitemapTemplate(project.isTypeScript), "utf-8");
220
319
  filesCreated.push(`${project.appDir}/sitemap.${ext}`);
221
320
  console.log(chalk.green(" \u2713") + ` Generated ${project.appDir}/sitemap.${ext}`);
222
321
  } else {
223
322
  console.log(chalk.dim(` - sitemap already exists (skip)`));
224
323
  }
225
324
  if (!project.hasRobots || opts.force) {
226
- const robotsPath = join2(cwd, project.appDir, `robots.${ext}`);
227
- await writeFile(robotsPath, robotsTemplate(project.isTypeScript), "utf-8");
325
+ const robotsPath = join3(cwd, project.appDir, `robots.${ext}`);
326
+ await writeFile2(robotsPath, robotsTemplate(project.isTypeScript), "utf-8");
228
327
  filesCreated.push(`${project.appDir}/robots.${ext}`);
229
328
  console.log(chalk.green(" \u2713") + ` Generated ${project.appDir}/robots.${ext}`);
230
329
  } else {
231
330
  console.log(chalk.dim(` - robots already exists (skip)`));
232
331
  }
233
- const gitDir = join2(cwd, ".git");
234
- const hasGit = existsSync2(gitDir);
332
+ const gitDir = join3(cwd, ".git");
333
+ const hasGit = existsSync3(gitDir);
235
334
  if (opts.hook || opts.force) {
236
335
  if (!hasGit) {
237
336
  console.log(chalk.yellow(" \u26A0") + " No .git directory found \u2014 skip hook install");
238
337
  } else {
239
- const hooksDir = join2(gitDir, "hooks");
240
- const hookPath = join2(hooksDir, "pre-push");
241
- if (existsSync2(hookPath) && !opts.force) {
242
- const existing = await readFile2(hookPath, "utf-8");
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");
243
342
  if (existing.includes("indxel")) {
244
343
  console.log(chalk.dim(" - pre-push hook already installed (skip)"));
245
344
  } else {
246
345
  console.log(chalk.yellow(" \u26A0") + " pre-push hook already exists (use --force to overwrite)");
247
346
  }
248
347
  } else {
249
- await mkdir(hooksDir, { recursive: true });
250
- await writeFile(hookPath, PRE_PUSH_HOOK, { mode: 493 });
348
+ await mkdir2(hooksDir, { recursive: true });
349
+ await writeFile2(hookPath, PRE_PUSH_HOOK, { mode: 493 });
251
350
  filesCreated.push(".git/hooks/pre-push");
252
351
  console.log(chalk.green(" \u2713") + " Installed git pre-push hook");
253
352
  }
@@ -255,6 +354,31 @@ var initCommand = new Command("init").description("Initialize indxel in your Nex
255
354
  } else if (hasGit) {
256
355
  console.log(chalk.dim(" - Use --hook to install git pre-push guard"));
257
356
  }
357
+ const existingKey = await loadIndexNowKey(cwd);
358
+ if (!existingKey || opts.force) {
359
+ const key = generateIndexNowKey();
360
+ const publicDir = join3(cwd, "public");
361
+ if (!existsSync3(publicDir)) {
362
+ await mkdir2(publicDir, { recursive: true });
363
+ }
364
+ await writeFile2(join3(publicDir, `${key}.txt`), key, "utf-8");
365
+ await saveIndexNowKey(cwd, key);
366
+ filesCreated.push(`public/${key}.txt`);
367
+ console.log(chalk.green(" \u2713") + " IndexNow ready \u2014 Bing, Yandex & Naver will pick up your pages on deploy");
368
+ } else {
369
+ const keyFile = join3(cwd, "public", `${existingKey}.txt`);
370
+ if (existsSync3(keyFile)) {
371
+ console.log(chalk.dim(" - IndexNow already set up (skip)"));
372
+ } else {
373
+ const publicDir = join3(cwd, "public");
374
+ if (!existsSync3(publicDir)) {
375
+ await mkdir2(publicDir, { recursive: true });
376
+ }
377
+ await writeFile2(keyFile, existingKey, "utf-8");
378
+ filesCreated.push(`public/${existingKey}.txt`);
379
+ console.log(chalk.green(" \u2713") + " IndexNow key file restored");
380
+ }
381
+ }
258
382
  console.log("");
259
383
  if (filesCreated.length > 0) {
260
384
  console.log(
@@ -272,54 +396,78 @@ var initCommand = new Command("init").description("Initialize indxel in your Nex
272
396
  console.log(chalk.dim(" 3. Run ") + chalk.bold("npx indxel init --hook") + chalk.dim(" to guard git pushes"));
273
397
  }
274
398
  console.log("");
399
+ console.log(chalk.dim(" Want continuous monitoring?"));
400
+ console.log(chalk.dim(" Run ") + chalk.bold("npx indxel link") + chalk.dim(" to connect your dashboard."));
401
+ console.log("");
275
402
  });
276
403
 
277
404
  // src/commands/check.ts
278
405
  import { Command as Command2 } from "commander";
279
- import chalk3 from "chalk";
406
+ import chalk4 from "chalk";
280
407
  import ora2 from "ora";
281
408
  import { validateMetadata } from "indxel";
282
409
 
283
410
  // src/scanner.ts
284
- import { readFile as readFile3 } from "fs/promises";
285
- import { join as join3, dirname, sep } from "path";
411
+ import { readFile as readFile4 } from "fs/promises";
412
+ import { join as join4, dirname, sep } from "path";
286
413
  import { glob } from "glob";
287
414
  async function scanPages(projectRoot, appDir) {
288
- const appDirFull = join3(projectRoot, appDir);
415
+ const appDirFull = join4(projectRoot, appDir);
289
416
  const pageFiles = await glob("**/page.{tsx,ts,jsx,js}", {
290
417
  cwd: appDirFull,
291
418
  ignore: ["**/node_modules/**", "**/_*/**"]
292
419
  });
293
420
  const pages = [];
294
421
  for (const file of pageFiles) {
295
- const fullPath = join3(appDirFull, file);
296
- const content = await readFile3(fullPath, "utf-8");
422
+ const fullPath = join4(appDirFull, file);
423
+ const content = await readFile4(fullPath, "utf-8");
297
424
  const route = filePathToRoute(file);
425
+ const isClient = isClientComponent(content);
298
426
  const page = {
299
- filePath: join3(appDir, file),
427
+ filePath: join4(appDir, file),
300
428
  route,
301
429
  hasMetadata: false,
302
430
  hasDynamicMetadata: false,
431
+ isClientComponent: isClient,
432
+ titleIsAbsolute: false,
303
433
  extractedMetadata: createEmptyMetadata()
304
434
  };
305
435
  page.hasDynamicMetadata = hasExport(content, "generateMetadata");
306
436
  page.hasMetadata = page.hasDynamicMetadata || hasExport(content, "metadata");
307
- page.extractedMetadata = extractStaticMetadata(content);
437
+ if (page.hasDynamicMetadata) {
438
+ } else if (!isClient || page.hasMetadata) {
439
+ page.extractedMetadata = extractStaticMetadata(content);
440
+ const metaBlock = findMetadataBlock(content);
441
+ if (metaBlock && /absolute\s*:\s*["'`]/.test(metaBlock)) {
442
+ page.titleIsAbsolute = true;
443
+ }
444
+ }
308
445
  pages.push(page);
309
446
  }
310
447
  const layoutFiles = await glob("**/layout.{tsx,ts,jsx,js}", {
311
448
  cwd: appDirFull,
312
449
  ignore: ["**/node_modules/**", "**/_*/**"]
313
450
  });
314
- for (const file of layoutFiles) {
315
- const fullPath = join3(appDirFull, file);
316
- const content = await readFile3(fullPath, "utf-8");
451
+ const sortedLayouts = layoutFiles.sort((a, b) => {
452
+ const depthA = a.split(sep).length;
453
+ const depthB = b.split(sep).length;
454
+ return depthB - depthA;
455
+ });
456
+ for (const file of sortedLayouts) {
457
+ const fullPath = join4(appDirFull, file);
458
+ const content = await readFile4(fullPath, "utf-8");
317
459
  const route = filePathToRoute(file).replace(/\/layout$/, "") || "/";
318
460
  const hasMetadataExport = hasExport(content, "metadata") || hasExport(content, "generateMetadata");
319
461
  if (hasMetadataExport) {
320
462
  const layoutMeta = extractStaticMetadata(content);
463
+ const templateMatch = content.match(/template\s*:\s*["'`]([^"'`]+)["'`]/);
464
+ const titleTemplate = templateMatch?.[1] ?? null;
321
465
  for (const page of pages) {
322
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
+ }
323
471
  mergeMetadata(page.extractedMetadata, layoutMeta);
324
472
  if (!page.hasMetadata) {
325
473
  page.hasMetadata = true;
@@ -336,6 +484,9 @@ function filePathToRoute(filePath) {
336
484
  const route = "/" + dir.split(sep).join("/");
337
485
  return route.replace(/\/\([^)]+\)/g, "") || "/";
338
486
  }
487
+ function isClientComponent(source) {
488
+ return /^[\s]*(['"])use client\1/.test(source);
489
+ }
339
490
  function hasExport(source, name) {
340
491
  const patterns = [
341
492
  new RegExp(`export\\s+(const|let|var)\\s+${name}\\b`),
@@ -344,50 +495,94 @@ function hasExport(source, name) {
344
495
  ];
345
496
  return patterns.some((p) => p.test(source));
346
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;
512
+ }
347
513
  function extractStaticMetadata(source) {
348
514
  const meta = createEmptyMetadata();
349
- const titleMatch = source.match(
350
- /title\s*:\s*(?:["'`]([^"'`]+)["'`]|`([^`]*)`)/
515
+ const metaBlock = findMetadataBlock(source) ?? source;
516
+ const absoluteMatch = metaBlock.match(
517
+ /absolute\s*:\s*["'`]([^"'`]+)["'`]/
351
518
  );
352
- if (titleMatch) {
353
- meta.title = titleMatch[1] ?? titleMatch[2] ?? null;
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
+ }
354
535
  }
355
- const descMatch = source.match(
356
- /description\s*:\s*(?:["'`]([^"'`]+)["'`]|`([^`]*)`)/
536
+ const descMatch = metaBlock.match(
537
+ /(?:^|[,{\n])\s*description\s*:\s*\n?\s*["'`]([^"'`]+)["'`]/
357
538
  );
358
539
  if (descMatch) {
359
- meta.description = descMatch[1] ?? descMatch[2] ?? null;
540
+ meta.description = descMatch[1];
360
541
  }
361
- if (/openGraph\s*:\s*\{/.test(source)) {
362
- const ogTitleMatch = source.match(
542
+ if (/openGraph\s*:\s*\{/.test(metaBlock)) {
543
+ const ogTitleMatch = metaBlock.match(
363
544
  /openGraph\s*:\s*\{[^}]*title\s*:\s*["'`]([^"'`]+)["'`]/s
364
545
  );
365
546
  if (ogTitleMatch) meta.ogTitle = ogTitleMatch[1];
366
- const ogDescMatch = source.match(
547
+ const ogDescMatch = metaBlock.match(
367
548
  /openGraph\s*:\s*\{[^}]*description\s*:\s*["'`]([^"'`]+)["'`]/s
368
549
  );
369
550
  if (ogDescMatch) meta.ogDescription = ogDescMatch[1];
370
- if (/images\s*:\s*\[/.test(source)) {
551
+ if (/images\s*:\s*\[/.test(metaBlock)) {
371
552
  meta.ogImage = "[detected]";
372
553
  }
373
554
  }
374
- if (/twitter\s*:\s*\{/.test(source)) {
375
- const cardMatch = source.match(
555
+ if (/twitter\s*:\s*\{/.test(metaBlock)) {
556
+ const cardMatch = metaBlock.match(
376
557
  /card\s*:\s*["'`](summary|summary_large_image)["'`]/
377
558
  );
378
559
  if (cardMatch) meta.twitterCard = cardMatch[1];
379
560
  }
380
- if (/robots\s*:\s*\{/.test(source) || /robots\s*:\s*["'`]/.test(source)) {
381
- const robotsMatch = source.match(
561
+ if (/robots\s*:\s*\{/.test(metaBlock) || /robots\s*:\s*["'`]/.test(metaBlock)) {
562
+ const robotsMatch = metaBlock.match(
382
563
  /robots\s*:\s*["'`]([^"'`]+)["'`]/
383
564
  );
384
565
  if (robotsMatch) meta.robots = robotsMatch[1];
385
566
  }
386
- if (/alternates\s*:\s*\{/.test(source)) {
387
- const canonicalMatch = source.match(
567
+ if (/alternates\s*:\s*\{/.test(metaBlock)) {
568
+ const canonicalMatch = metaBlock.match(
388
569
  /canonical\s*:\s*["'`]([^"'`]+)["'`]/
389
570
  );
390
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
+ }
391
586
  }
392
587
  if (/application\/ld\+json/.test(source) || /generateLD/.test(source) || /JsonLD/.test(source)) {
393
588
  meta.structuredData = [{ "@context": "https://schema.org", "@type": "detected" }];
@@ -395,7 +590,7 @@ function extractStaticMetadata(source) {
395
590
  if (/viewport\s*[:=]/.test(source)) {
396
591
  meta.viewport = "detected";
397
592
  }
398
- if (/icons\s*:\s*\{/.test(source) || /favicon/.test(source)) {
593
+ if (/icons\s*:\s*\{/.test(metaBlock) || /favicon/.test(metaBlock)) {
399
594
  meta.favicon = "detected";
400
595
  }
401
596
  return meta;
@@ -427,15 +622,34 @@ function mergeMetadata(target, source) {
427
622
  }
428
623
  }
429
624
 
625
+ // src/config.ts
626
+ import { existsSync as existsSync4 } from "fs";
627
+ import { readFile as readFile5 } from "fs/promises";
628
+ import { join as join5 } from "path";
629
+ var CONFIG_FILES = [".indxelrc.json", ".indxelrc", "indxel.config.json"];
630
+ async function loadConfig(cwd) {
631
+ for (const file of CONFIG_FILES) {
632
+ const path = join5(cwd, file);
633
+ if (existsSync4(path)) {
634
+ try {
635
+ const content = await readFile5(path, "utf-8");
636
+ return JSON.parse(content);
637
+ } catch {
638
+ }
639
+ }
640
+ }
641
+ return {};
642
+ }
643
+
430
644
  // src/formatter.ts
431
645
  import chalk2 from "chalk";
432
646
  function formatPageResult(result) {
433
647
  const { page, validation } = result;
434
648
  const lines = [];
435
- const scoreColor = getScoreColor(validation.score);
649
+ const scoreColor3 = getScoreColor(validation.score);
436
650
  const icon = validation.errors.length > 0 ? chalk2.red("x") : chalk2.green("\u2713");
437
651
  lines.push(
438
- ` ${icon} ${chalk2.bold(page.route)} ${scoreColor(`${validation.score}/100`)}`
652
+ ` ${icon} ${chalk2.bold(page.route)} ${scoreColor3(`${validation.score}/100`)}`
439
653
  );
440
654
  for (const error of validation.errors) {
441
655
  lines.push(` ${chalk2.red("x")} ${error.message ?? error.name}`);
@@ -453,9 +667,9 @@ function formatSummary(summary) {
453
667
  lines.push("");
454
668
  lines.push(chalk2.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
455
669
  lines.push("");
456
- const scoreColor = getScoreColor(averageScore);
670
+ const scoreColor3 = getScoreColor(averageScore);
457
671
  lines.push(
458
- ` Score: ${scoreColor(chalk2.bold(`${averageScore}/100`))} (${summary.grade})`
672
+ ` Score: ${scoreColor3(chalk2.bold(`${averageScore}/100`))} (${summary.grade})`
459
673
  );
460
674
  const pagesColor = passedPages === totalPages ? chalk2.green : chalk2.yellow;
461
675
  lines.push(
@@ -466,10 +680,27 @@ function formatSummary(summary) {
466
680
  lines.push(
467
681
  chalk2.red(` ${criticalErrors} critical issue${criticalErrors > 1 ? "s" : ""}. Fix before deploying.`)
468
682
  );
683
+ if (summary.optionalErrors > 0) {
684
+ lines.push(
685
+ chalk2.yellow(` ${summary.optionalErrors} optional issue${summary.optionalErrors > 1 ? "s" : ""} (won't block CI).`)
686
+ );
687
+ }
688
+ } else if (summary.optionalErrors > 0) {
689
+ lines.push("");
690
+ lines.push(chalk2.yellow(` ${summary.optionalErrors} optional issue${summary.optionalErrors > 1 ? "s" : ""} (won't block CI).`));
469
691
  } else {
470
692
  lines.push("");
471
693
  lines.push(chalk2.green(" All pages pass. Ship it."));
472
694
  }
695
+ if (summary.skippedDynamic > 0) {
696
+ lines.push("");
697
+ lines.push(
698
+ chalk2.cyan(
699
+ ` ${summary.skippedDynamic} dynamic page${summary.skippedDynamic > 1 ? "s" : ""} skipped (generateMetadata).`
700
+ )
701
+ );
702
+ lines.push(chalk2.dim(" Run `indxel crawl <url>` for accurate scores on dynamic pages."));
703
+ }
473
704
  lines.push("");
474
705
  return lines.join("\n");
475
706
  }
@@ -481,6 +712,8 @@ function formatJSON(summary) {
481
712
  totalPages: summary.totalPages,
482
713
  passedPages: summary.passedPages,
483
714
  criticalErrors: summary.criticalErrors,
715
+ optionalErrors: summary.optionalErrors,
716
+ skippedDynamic: summary.skippedDynamic,
484
717
  pages: summary.results.map((r) => ({
485
718
  route: r.page.route,
486
719
  file: r.page.filePath,
@@ -571,114 +804,187 @@ function formatDiff(current, previous) {
571
804
  }
572
805
  return lines.join("\n");
573
806
  }
807
+ function formatSkippedPage(page) {
808
+ return ` ${chalk2.cyan("~")} ${chalk2.bold(page.route)} ${chalk2.dim("skipped \u2014 generateMetadata()")}`;
809
+ }
574
810
  function getScoreColor(score) {
575
811
  if (score >= 90) return chalk2.green;
576
812
  if (score >= 70) return chalk2.yellow;
577
813
  return chalk2.red;
578
814
  }
579
- function computeSummary(results) {
580
- const totalPages = results.length;
815
+ function computeSummary(results, skippedDynamic = 0) {
816
+ const totalPages = results.length + skippedDynamic;
581
817
  const passedPages = results.filter((r) => r.validation.errors.length === 0).length;
582
- const averageScore = totalPages > 0 ? Math.round(results.reduce((sum, r) => sum + r.validation.score, 0) / totalPages) : 0;
583
- const criticalErrors = results.reduce((sum, r) => sum + r.validation.errors.length, 0);
818
+ const averageScore = results.length > 0 ? Math.round(results.reduce((sum, r) => sum + r.validation.score, 0) / results.length) : 0;
819
+ let criticalErrors = 0;
820
+ let optionalErrors = 0;
821
+ for (const r of results) {
822
+ for (const e of r.validation.errors) {
823
+ if (e.severity === "critical") criticalErrors++;
824
+ else optionalErrors++;
825
+ }
826
+ }
584
827
  let grade;
585
828
  if (averageScore >= 90) grade = "A";
586
829
  else if (averageScore >= 80) grade = "B";
587
830
  else if (averageScore >= 70) grade = "C";
588
831
  else if (averageScore >= 60) grade = "D";
589
832
  else grade = "F";
590
- return { results, totalPages, passedPages, averageScore, grade, criticalErrors };
833
+ return { results, totalPages, passedPages, averageScore, grade, criticalErrors, optionalErrors, skippedDynamic };
591
834
  }
592
835
 
593
- // src/store.ts
594
- import { existsSync as existsSync3 } from "fs";
595
- import { readFile as readFile4, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
596
- import { join as join4 } from "path";
597
- var STORE_DIR = ".indxel";
598
- var LAST_CHECK_FILE = "last-check.json";
599
- async function saveCheckResult(cwd, summary) {
600
- const storeDir = join4(cwd, STORE_DIR);
601
- if (!existsSync3(storeDir)) {
602
- await mkdir2(storeDir, { recursive: true });
603
- }
604
- const stored = {
605
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
606
- summary: {
607
- ...summary,
608
- // Serialize results with minimal data needed for diff
609
- results: summary.results.map((r) => ({
610
- page: {
611
- filePath: r.page.filePath,
612
- route: r.page.route,
613
- hasMetadata: r.page.hasMetadata,
614
- hasDynamicMetadata: r.page.hasDynamicMetadata,
615
- extractedMetadata: r.page.extractedMetadata
616
- },
617
- validation: r.validation
618
- }))
836
+ // src/fixer.ts
837
+ import chalk3 from "chalk";
838
+ function generateFixSuggestions(results, baseUrl) {
839
+ const output = [];
840
+ const siteUrl = baseUrl ?? "https://yoursite.com";
841
+ for (const { page, validation } of results) {
842
+ if (validation.errors.length === 0 && validation.warnings.length === 0) continue;
843
+ const errorIds = new Set(validation.errors.map((e) => e.id));
844
+ const warnIds = new Set(validation.warnings.map((w) => w.id));
845
+ const allIds = /* @__PURE__ */ new Set([...errorIds, ...warnIds]);
846
+ const meta = {};
847
+ const extras = [];
848
+ if (allIds.has("title-present")) {
849
+ const suggestion = routeToTitle(page.route);
850
+ meta.title = { absolute: suggestion };
851
+ } else if (allIds.has("title-length")) {
852
+ const current = page.extractedMetadata.title ?? "";
853
+ const len = current.length;
854
+ if (len < 50) {
855
+ extras.push(` ${chalk3.dim("// Title is " + len + " chars \u2014 expand to 50-60")}`);
856
+ } else if (len > 60) {
857
+ extras.push(` ${chalk3.dim("// Title is " + len + " chars \u2014 shorten to 50-60")}`);
858
+ }
619
859
  }
620
- };
621
- await writeFile2(
622
- join4(storeDir, LAST_CHECK_FILE),
623
- JSON.stringify(stored, null, 2),
624
- "utf-8"
625
- );
626
- }
627
- async function loadPreviousCheck(cwd) {
628
- const filePath = join4(cwd, STORE_DIR, LAST_CHECK_FILE);
629
- if (!existsSync3(filePath)) {
630
- return null;
860
+ if (allIds.has("description-present")) {
861
+ meta.description = routeToDescription(page.route);
862
+ } else if (allIds.has("description-length")) {
863
+ const current = page.extractedMetadata.description ?? "";
864
+ const len = current.length;
865
+ if (len < 120) {
866
+ extras.push(` ${chalk3.dim("// Description is " + len + " chars \u2014 expand to 120-160")}`);
867
+ } else if (len > 160) {
868
+ extras.push(` ${chalk3.dim("// Description is " + len + " chars \u2014 shorten to 120-160")}`);
869
+ }
870
+ }
871
+ if (allIds.has("canonical-url")) {
872
+ const canonical = `${siteUrl}${page.route === "/" ? "" : page.route}`;
873
+ meta.alternates = { canonical };
874
+ }
875
+ if (allIds.has("og-image")) {
876
+ meta.openGraph = { images: [`${siteUrl}/og-image.png`] };
877
+ }
878
+ if (allIds.has("structured-data-present")) {
879
+ extras.push(` ${chalk3.dim("// Add JSON-LD structured data (WebPage, Article, FAQ...)")}`);
880
+ extras.push(` ${chalk3.dim("// See: https://developers.google.com/search/docs/appearance/structured-data")}`);
881
+ }
882
+ if (allIds.has("twitter-card")) {
883
+ meta.twitter = { card: "summary_large_image" };
884
+ }
885
+ if (Object.keys(meta).length === 0 && extras.length === 0) continue;
886
+ output.push(chalk3.bold(` ${chalk3.cyan(page.filePath)}`));
887
+ if (Object.keys(meta).length > 0) {
888
+ const code = formatMetadataObject(meta);
889
+ output.push(` ${chalk3.dim("Add/update in your page file:")}`);
890
+ output.push("");
891
+ for (const line of code.split("\n")) {
892
+ output.push(` ${chalk3.yellow(line)}`);
893
+ }
894
+ }
895
+ for (const extra of extras) {
896
+ output.push(extra);
897
+ }
898
+ output.push("");
631
899
  }
632
- try {
633
- const data = await readFile4(filePath, "utf-8");
634
- return JSON.parse(data);
635
- } catch {
636
- return null;
900
+ return output;
901
+ }
902
+ function routeToTitle(route) {
903
+ if (route === "/") return "Your Site \u2014 A brief description of what you do";
904
+ const segments = route.split("/").filter(Boolean);
905
+ const last = segments[segments.length - 1];
906
+ const name = last.replace(/\[.*?\]/g, "").replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim();
907
+ return `${name || "Page"} \u2014 Your Site`;
908
+ }
909
+ function routeToDescription(route) {
910
+ if (route === "/") {
911
+ return "A clear, compelling description of your site in 120-160 characters. Include your main value proposition and a call to action.";
912
+ }
913
+ const segments = route.split("/").filter(Boolean);
914
+ const last = segments[segments.length - 1];
915
+ const name = last.replace(/\[.*?\]/g, "").replace(/-/g, " ").trim();
916
+ return `Learn more about ${name || "this page"}. Add a compelling 120-160 character description with your key value proposition here.`;
917
+ }
918
+ function formatMetadataObject(meta) {
919
+ const lines = ["export const metadata: Metadata = {"];
920
+ for (const [key, value] of Object.entries(meta)) {
921
+ if (typeof value === "string") {
922
+ lines.push(` ${key}: "${value}",`);
923
+ } else if (typeof value === "object" && value !== null) {
924
+ lines.push(` ${key}: ${JSON.stringify(value, null, 2).replace(/\n/g, "\n ")},`);
925
+ }
637
926
  }
927
+ lines.push("};");
928
+ return lines.join("\n");
638
929
  }
639
930
 
640
931
  // src/commands/check.ts
641
- 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).action(async (opts) => {
932
+ 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) => {
642
933
  const cwd = opts.cwd;
643
934
  const isCI = opts.ci;
644
935
  const isStrict = opts.strict || isCI;
645
936
  const showDiff = opts.diff;
646
937
  const jsonOutput = opts.json;
938
+ const showFix = opts.fix;
939
+ const config = await loadConfig(cwd);
940
+ const minScore = opts.minScore ? parseInt(opts.minScore, 10) : config.minScore ?? null;
647
941
  const spinner = ora2("Detecting project...").start();
648
942
  const project = await detectProject(cwd);
649
943
  if (!project.isNextJs) {
650
944
  spinner.fail("Not a Next.js project");
651
945
  if (!jsonOutput) {
652
- console.log(chalk3.dim(" Run this command from a Next.js project root."));
946
+ console.log(chalk4.dim(" Run this command from a Next.js project root."));
653
947
  }
654
948
  process.exit(1);
655
949
  }
656
950
  if (!project.usesAppRouter) {
657
951
  spinner.fail("App Router not detected");
658
952
  if (!jsonOutput) {
659
- console.log(chalk3.dim(" indxel requires Next.js App Router (src/app or app directory)."));
953
+ console.log(chalk4.dim(" indxel requires Next.js App Router (src/app or app directory)."));
660
954
  }
661
955
  process.exit(1);
662
956
  }
663
957
  spinner.text = "Scanning pages...";
664
- const pages = await scanPages(cwd, project.appDir);
665
- if (pages.length === 0) {
958
+ const allPages = await scanPages(cwd, project.appDir);
959
+ if (allPages.length === 0) {
666
960
  spinner.fail("No pages found");
667
961
  if (!jsonOutput) {
668
- console.log(chalk3.dim(` No page.tsx/ts files found in ${project.appDir}/`));
962
+ console.log(chalk4.dim(` No page.tsx/ts files found in ${project.appDir}/`));
669
963
  }
670
964
  process.exit(1);
671
965
  }
672
- spinner.succeed(`Found ${pages.length} page${pages.length > 1 ? "s" : ""}`);
966
+ const ignoreRoutes = config.ignoreRoutes ?? [];
967
+ const pages = ignoreRoutes.length > 0 ? allPages.filter((p) => !ignoreRoutes.some((pattern) => matchRoute(p.route, pattern))) : allPages;
968
+ const ignoredCount = allPages.length - pages.length;
969
+ spinner.succeed(`Found ${allPages.length} page${allPages.length > 1 ? "s" : ""}${ignoredCount > 0 ? ` (${ignoredCount} ignored)` : ""}`);
970
+ const staticPages = pages.filter((p) => !p.hasDynamicMetadata);
971
+ const dynamicPages = pages.filter((p) => p.hasDynamicMetadata);
673
972
  if (!jsonOutput) {
674
973
  console.log("");
675
- console.log(chalk3.bold(` Checking ${pages.length} pages...`));
974
+ console.log(chalk4.bold(` Checking ${staticPages.length} page${staticPages.length !== 1 ? "s" : ""}...`));
975
+ if (dynamicPages.length > 0) {
976
+ console.log(chalk4.dim(` (${dynamicPages.length} dynamic page${dynamicPages.length !== 1 ? "s" : ""} skipped)`));
977
+ }
978
+ if (ignoredCount > 0) {
979
+ console.log(chalk4.dim(` (${ignoredCount} page${ignoredCount !== 1 ? "s" : ""} excluded by ignoreRoutes)`));
980
+ }
676
981
  console.log("");
677
982
  }
678
983
  const results = [];
679
- for (const page of pages) {
984
+ for (const page of staticPages) {
680
985
  const validation = validateMetadata(page.extractedMetadata, {
681
- strict: isStrict
986
+ strict: isStrict,
987
+ disabledRules: config.disabledRules
682
988
  });
683
989
  const result = { page, validation };
684
990
  results.push(result);
@@ -686,14 +992,20 @@ var checkCommand = new Command2("check").description("Audit SEO metadata for all
686
992
  console.log(formatPageResult(result));
687
993
  }
688
994
  }
689
- const summary = computeSummary(results);
995
+ if (!jsonOutput && dynamicPages.length > 0) {
996
+ console.log("");
997
+ for (const page of dynamicPages) {
998
+ console.log(formatSkippedPage(page));
999
+ }
1000
+ }
1001
+ const summary = computeSummary(results, dynamicPages.length);
690
1002
  await saveCheckResult(cwd, summary);
691
1003
  if (showDiff && !jsonOutput) {
692
1004
  const previous = await loadPreviousCheck(cwd);
693
1005
  if (previous) {
694
1006
  console.log(formatDiff(summary, previous.summary));
695
1007
  } else {
696
- console.log(chalk3.dim("\n No previous check found. Run again to see a diff.\n"));
1008
+ console.log(chalk4.dim("\n No previous check found. Run again to see a diff.\n"));
697
1009
  }
698
1010
  }
699
1011
  if (jsonOutput) {
@@ -701,14 +1013,39 @@ var checkCommand = new Command2("check").description("Audit SEO metadata for all
701
1013
  } else {
702
1014
  console.log(formatSummary(summary));
703
1015
  }
704
- if (summary.criticalErrors > 0) {
1016
+ if (showFix && !jsonOutput) {
1017
+ const fixes = generateFixSuggestions(results, config.baseUrl);
1018
+ if (fixes.length > 0) {
1019
+ console.log(chalk4.bold(" Suggested fixes:\n"));
1020
+ for (const fix of fixes) {
1021
+ console.log(fix);
1022
+ }
1023
+ }
1024
+ }
1025
+ if (minScore !== null) {
1026
+ if (summary.averageScore < minScore) {
1027
+ if (!jsonOutput) {
1028
+ console.log(
1029
+ chalk4.red(` Score ${summary.averageScore} is below minimum ${minScore}.`)
1030
+ );
1031
+ }
1032
+ process.exit(1);
1033
+ }
1034
+ } else if (summary.criticalErrors > 0) {
705
1035
  process.exit(1);
706
1036
  }
707
1037
  });
1038
+ function matchRoute(route, pattern) {
1039
+ if (pattern.endsWith("/*")) {
1040
+ const prefix = pattern.slice(0, -2);
1041
+ return route === prefix || route.startsWith(prefix + "/");
1042
+ }
1043
+ return route === pattern;
1044
+ }
708
1045
 
709
1046
  // src/commands/crawl.ts
710
1047
  import { Command as Command3 } from "commander";
711
- import chalk4 from "chalk";
1048
+ import chalk5 from "chalk";
712
1049
  import ora3 from "ora";
713
1050
  import {
714
1051
  crawlSite,
@@ -718,17 +1055,22 @@ import {
718
1055
  checkUrlsAgainstRobots,
719
1056
  verifyAssets
720
1057
  } from "indxel";
721
- 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", "50").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) => {
1058
+ function scoreColor(score) {
1059
+ if (score >= 90) return chalk5.green;
1060
+ if (score >= 70) return chalk5.yellow;
1061
+ return chalk5.red;
1062
+ }
1063
+ 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) => {
722
1064
  const jsonOutput = opts.json;
723
1065
  const maxPages = parseInt(opts.maxPages, 10);
724
1066
  const maxDepth = parseInt(opts.maxDepth, 10);
725
- const delay = parseInt(opts.delay, 10);
1067
+ const delay4 = parseInt(opts.delay, 10);
726
1068
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
727
1069
  url = `https://${url}`;
728
1070
  }
729
1071
  if (!jsonOutput) {
730
1072
  console.log("");
731
- console.log(chalk4.bold(` indxel crawl`) + chalk4.dim(` \u2014 ${url}`));
1073
+ console.log(chalk5.bold(` indxel crawl`) + chalk5.dim(` \u2014 ${url}`));
732
1074
  console.log("");
733
1075
  }
734
1076
  let robotsResult = null;
@@ -739,15 +1081,15 @@ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit e
739
1081
  if (robotsResult.found) {
740
1082
  robotsSpinner.succeed("robots.txt found");
741
1083
  for (const w of robotsResult.warnings) {
742
- console.log(chalk4.yellow(` \u26A0 ${w}`));
1084
+ console.log(chalk5.yellow(` \u26A0 ${w}`));
743
1085
  }
744
1086
  if (robotsResult.sitemapUrls.length > 0) {
745
- console.log(chalk4.dim(` Sitemap references: ${robotsResult.sitemapUrls.join(", ")}`));
1087
+ console.log(chalk5.dim(` Sitemap references: ${robotsResult.sitemapUrls.join(", ")}`));
746
1088
  }
747
1089
  } else {
748
1090
  robotsSpinner.warn("robots.txt not found");
749
1091
  for (const e of robotsResult.errors) {
750
- console.log(chalk4.dim(` ${e}`));
1092
+ console.log(chalk5.dim(` ${e}`));
751
1093
  }
752
1094
  }
753
1095
  console.log("");
@@ -759,7 +1101,7 @@ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit e
759
1101
  const crawlResult = await crawlSite(url, {
760
1102
  maxPages,
761
1103
  maxDepth,
762
- delay,
1104
+ delay: delay4,
763
1105
  strict: opts.strict,
764
1106
  ignorePatterns,
765
1107
  onPageCrawled: (page) => {
@@ -774,19 +1116,19 @@ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit e
774
1116
  console.log("");
775
1117
  for (const page of crawlResult.pages) {
776
1118
  if (page.error) {
777
- console.log(chalk4.red(` \u2717 ${page.url}`) + chalk4.dim(` \u2014 ${page.error}`));
1119
+ console.log(chalk5.red(` \u2717 ${page.url}`) + chalk5.dim(` \u2014 ${page.error}`));
778
1120
  continue;
779
1121
  }
780
- const scoreColor = page.validation.score >= 90 ? chalk4.green : page.validation.score >= 70 ? chalk4.yellow : chalk4.red;
781
- const icon = page.validation.errors.length > 0 ? chalk4.red("\u2717") : chalk4.green("\u2713");
1122
+ const pageColor = scoreColor(page.validation.score);
1123
+ const icon = page.validation.errors.length > 0 ? chalk5.red("\u2717") : chalk5.green("\u2713");
782
1124
  console.log(
783
- ` ${icon} ${page.url} ${scoreColor(`${page.validation.score}/100`)}`
1125
+ ` ${icon} ${page.url} ${pageColor(`${page.validation.score}/100`)}`
784
1126
  );
785
1127
  for (const error of page.validation.errors) {
786
- console.log(chalk4.red(` \u2717 ${error.message ?? error.description}`));
1128
+ console.log(chalk5.red(` \u2717 ${error.message ?? error.description}`));
787
1129
  }
788
1130
  for (const warning of page.validation.warnings) {
789
- console.log(chalk4.yellow(` \u26A0 ${warning.message ?? warning.description}`));
1131
+ console.log(chalk5.yellow(` \u26A0 ${warning.message ?? warning.description}`));
790
1132
  }
791
1133
  }
792
1134
  console.log("");
@@ -804,24 +1146,26 @@ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit e
804
1146
  crawledUrls
805
1147
  );
806
1148
  if (sitemapComparison.inCrawlOnly.length > 0) {
807
- console.log(chalk4.yellow(` \u26A0 ${sitemapComparison.inCrawlOnly.length} crawled pages missing from sitemap:`));
1149
+ console.log(chalk5.yellow(` \u26A0 ${sitemapComparison.inCrawlOnly.length} crawled pages missing from sitemap:`));
808
1150
  for (const u of sitemapComparison.inCrawlOnly.slice(0, 10)) {
809
- console.log(chalk4.dim(` - ${u}`));
1151
+ console.log(chalk5.dim(` - ${u}`));
810
1152
  }
811
1153
  }
812
1154
  if (sitemapComparison.inSitemapOnly.length > 0) {
813
- console.log(chalk4.yellow(` \u26A0 ${sitemapComparison.inSitemapOnly.length} sitemap URLs not reachable:`));
1155
+ const hitLimit = crawlResult.totalPages >= maxPages;
1156
+ const label = hitLimit ? `${sitemapComparison.inSitemapOnly.length} sitemap URLs not crawled (limit: ${maxPages} \u2014 use --max-pages to increase)` : `${sitemapComparison.inSitemapOnly.length} sitemap URLs not reachable`;
1157
+ console.log(chalk5.yellow(` \u26A0 ${label}:`));
814
1158
  for (const u of sitemapComparison.inSitemapOnly.slice(0, 10)) {
815
- console.log(chalk4.dim(` - ${u}`));
1159
+ console.log(chalk5.dim(` - ${u}`));
816
1160
  }
817
1161
  }
818
1162
  if (sitemapComparison.issues.length === 0) {
819
- console.log(chalk4.green(` \u2713 Sitemap matches crawled pages`));
1163
+ console.log(chalk5.green(` \u2713 Sitemap matches crawled pages`));
820
1164
  }
821
1165
  } else {
822
1166
  sitemapSpinner.warn("sitemap.xml not found");
823
1167
  for (const e of sitemapResult.errors) {
824
- console.log(chalk4.dim(` ${e}`));
1168
+ console.log(chalk5.dim(` ${e}`));
825
1169
  }
826
1170
  }
827
1171
  console.log("");
@@ -836,9 +1180,9 @@ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit e
836
1180
  );
837
1181
  const blocked = robotsBlockedPages.filter((c) => c.blocked);
838
1182
  if (!jsonOutput && blocked.length > 0) {
839
- console.log(chalk4.yellow(` \u26A0 ${blocked.length} crawled pages are blocked by robots.txt:`));
1183
+ console.log(chalk5.yellow(` \u26A0 ${blocked.length} crawled pages are blocked by robots.txt:`));
840
1184
  for (const b of blocked) {
841
- console.log(chalk4.dim(` - ${b.path} (${b.blockedBy})`));
1185
+ console.log(chalk5.dim(` - ${b.path} (${b.blockedBy})`));
842
1186
  }
843
1187
  console.log("");
844
1188
  }
@@ -854,16 +1198,16 @@ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit e
854
1198
  const warningAssets = assetResult.checks.filter((c) => c.warning);
855
1199
  for (const asset of brokenAssets) {
856
1200
  console.log(
857
- chalk4.red(` \u2717 ${asset.type}`) + chalk4.dim(` ${asset.url}`) + chalk4.red(` \u2014 ${asset.error ?? `HTTP ${asset.status}`}`)
1201
+ chalk5.red(` \u2717 ${asset.type}`) + chalk5.dim(` ${asset.url}`) + chalk5.red(` \u2014 ${asset.error ?? `HTTP ${asset.status}`}`)
858
1202
  );
859
1203
  }
860
1204
  for (const asset of warningAssets) {
861
1205
  console.log(
862
- chalk4.yellow(` \u26A0 ${asset.type}`) + chalk4.dim(` ${asset.url}`) + chalk4.yellow(` \u2014 ${asset.warning}`)
1206
+ chalk5.yellow(` \u26A0 ${asset.type}`) + chalk5.dim(` ${asset.url}`) + chalk5.yellow(` \u2014 ${asset.warning}`)
863
1207
  );
864
1208
  }
865
1209
  if (brokenAssets.length === 0 && warningAssets.length === 0) {
866
- console.log(chalk4.green(` \u2713 All assets respond correctly`));
1210
+ console.log(chalk5.green(` \u2713 All assets respond correctly`));
867
1211
  }
868
1212
  console.log("");
869
1213
  }
@@ -871,107 +1215,136 @@ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit e
871
1215
  if (!jsonOutput) {
872
1216
  const a = crawlResult.analysis;
873
1217
  if (a.duplicateTitles.length > 0) {
874
- console.log(chalk4.bold("- Duplicate titles"));
1218
+ console.log(chalk5.bold("- Duplicate titles"));
875
1219
  for (const dup of a.duplicateTitles.slice(0, 5)) {
876
- console.log(chalk4.red(` \u2717 "${dup.title.length > 60 ? dup.title.slice(0, 57) + "..." : dup.title}"`) + chalk4.dim(` (${dup.urls.length} pages)`));
877
- for (const u of dup.urls.slice(0, 3)) console.log(chalk4.dim(` ${u}`));
878
- if (dup.urls.length > 3) console.log(chalk4.dim(` ...and ${dup.urls.length - 3} more`));
1220
+ console.log(chalk5.red(` \u2717 "${dup.title.length > 60 ? dup.title.slice(0, 57) + "..." : dup.title}"`) + chalk5.dim(` (${dup.urls.length} pages)`));
1221
+ for (const u of dup.urls.slice(0, 3)) console.log(chalk5.dim(` ${u}`));
1222
+ if (dup.urls.length > 3) console.log(chalk5.dim(` ...and ${dup.urls.length - 3} more`));
879
1223
  }
880
- if (a.duplicateTitles.length > 5) console.log(chalk4.dim(` ...and ${a.duplicateTitles.length - 5} more groups`));
1224
+ if (a.duplicateTitles.length > 5) console.log(chalk5.dim(` ...and ${a.duplicateTitles.length - 5} more groups`));
881
1225
  console.log("");
882
1226
  }
883
1227
  if (a.duplicateDescriptions.length > 0) {
884
- console.log(chalk4.bold("- Duplicate descriptions"));
1228
+ console.log(chalk5.bold("- Duplicate descriptions"));
885
1229
  for (const dup of a.duplicateDescriptions.slice(0, 5)) {
886
1230
  const desc = dup.description.length > 60 ? dup.description.slice(0, 57) + "..." : dup.description;
887
- console.log(chalk4.red(` \u2717 "${desc}"`) + chalk4.dim(` (${dup.urls.length} pages)`));
888
- for (const u of dup.urls.slice(0, 3)) console.log(chalk4.dim(` ${u}`));
889
- if (dup.urls.length > 3) console.log(chalk4.dim(` ...and ${dup.urls.length - 3} more`));
1231
+ console.log(chalk5.red(` \u2717 "${desc}"`) + chalk5.dim(` (${dup.urls.length} pages)`));
1232
+ for (const u of dup.urls.slice(0, 3)) console.log(chalk5.dim(` ${u}`));
1233
+ if (dup.urls.length > 3) console.log(chalk5.dim(` ...and ${dup.urls.length - 3} more`));
890
1234
  }
891
- if (a.duplicateDescriptions.length > 5) console.log(chalk4.dim(` ...and ${a.duplicateDescriptions.length - 5} more groups`));
1235
+ if (a.duplicateDescriptions.length > 5) console.log(chalk5.dim(` ...and ${a.duplicateDescriptions.length - 5} more groups`));
892
1236
  console.log("");
893
1237
  }
894
1238
  if (a.h1Issues.length > 0) {
895
1239
  const missing = a.h1Issues.filter((h) => h.issue === "missing");
896
1240
  const multiple = a.h1Issues.filter((h) => h.issue === "multiple");
897
- console.log(chalk4.bold("- H1 heading issues"));
1241
+ console.log(chalk5.bold("- H1 heading issues"));
898
1242
  if (missing.length > 0) {
899
- console.log(chalk4.red(` \u2717 ${missing.length} pages missing H1`));
900
- for (const h of missing.slice(0, 5)) console.log(chalk4.dim(` ${h.url}`));
901
- if (missing.length > 5) console.log(chalk4.dim(` ...and ${missing.length - 5} more`));
1243
+ console.log(chalk5.red(` \u2717 ${missing.length} pages missing H1`));
1244
+ for (const h of missing.slice(0, 5)) console.log(chalk5.dim(` ${h.url}`));
1245
+ if (missing.length > 5) console.log(chalk5.dim(` ...and ${missing.length - 5} more`));
902
1246
  }
903
1247
  if (multiple.length > 0) {
904
- console.log(chalk4.yellow(` \u26A0 ${multiple.length} pages with multiple H1s`));
905
- for (const h of multiple.slice(0, 5)) console.log(chalk4.dim(` ${h.url} (${h.count} H1s)`));
906
- if (multiple.length > 5) console.log(chalk4.dim(` ...and ${multiple.length - 5} more`));
1248
+ console.log(chalk5.yellow(` \u26A0 ${multiple.length} pages with multiple H1s`));
1249
+ for (const h of multiple.slice(0, 5)) console.log(chalk5.dim(` ${h.url} (${h.count} H1s)`));
1250
+ if (multiple.length > 5) console.log(chalk5.dim(` ...and ${multiple.length - 5} more`));
907
1251
  }
908
1252
  console.log("");
909
1253
  }
910
1254
  if (a.brokenInternalLinks.length > 0) {
911
- console.log(chalk4.bold("- Broken internal links"));
1255
+ console.log(chalk5.bold("- Broken internal links"));
912
1256
  for (const bl of a.brokenInternalLinks.slice(0, 10)) {
913
- console.log(chalk4.red(` \u2717 ${bl.to}`) + chalk4.dim(` \u2190 linked from ${bl.from} (${bl.status})`));
1257
+ console.log(chalk5.red(` \u2717 ${bl.to}`) + chalk5.dim(` \u2190 linked from ${bl.from} (${bl.status})`));
914
1258
  }
915
- if (a.brokenInternalLinks.length > 10) console.log(chalk4.dim(` ...and ${a.brokenInternalLinks.length - 10} more`));
1259
+ if (a.brokenInternalLinks.length > 10) console.log(chalk5.dim(` ...and ${a.brokenInternalLinks.length - 10} more`));
1260
+ console.log("");
1261
+ }
1262
+ if (a.brokenExternalLinks.length > 0) {
1263
+ console.log(chalk5.bold("- Broken external links"));
1264
+ for (const bl of a.brokenExternalLinks.slice(0, 10)) {
1265
+ console.log(chalk5.red(` \u2717 ${bl.to}`) + chalk5.dim(` \u2190 linked from ${bl.from} (${bl.status})`));
1266
+ }
1267
+ if (a.brokenExternalLinks.length > 10) console.log(chalk5.dim(` ...and ${a.brokenExternalLinks.length - 10} more`));
916
1268
  console.log("");
917
1269
  }
918
1270
  if (a.redirects.length > 0) {
919
- console.log(chalk4.bold("- Redirect chains"));
1271
+ console.log(chalk5.bold("- Redirect chains"));
920
1272
  for (const r of a.redirects.slice(0, 10)) {
921
- console.log(chalk4.yellow(` \u26A0 ${r.url}`));
922
- for (const step of r.chain) console.log(chalk4.dim(` ${step}`));
1273
+ console.log(chalk5.yellow(` \u26A0 ${r.url}`));
1274
+ for (const step of r.chain) console.log(chalk5.dim(` ${step}`));
923
1275
  }
924
- if (a.redirects.length > 10) console.log(chalk4.dim(` ...and ${a.redirects.length - 10} more`));
1276
+ if (a.redirects.length > 10) console.log(chalk5.dim(` ...and ${a.redirects.length - 10} more`));
925
1277
  console.log("");
926
1278
  }
927
1279
  if (a.thinContentPages.length > 0) {
928
1280
  const realThin = a.thinContentPages.filter((tc) => !tc.isAppPage);
929
1281
  const appThin = a.thinContentPages.filter((tc) => tc.isAppPage);
930
1282
  if (realThin.length > 0) {
931
- console.log(chalk4.bold("- Thin content") + chalk4.dim(" (< 200 words)"));
1283
+ console.log(chalk5.bold("- Thin content") + chalk5.dim(" (< 200 words)"));
932
1284
  for (const tc of realThin.slice(0, 10)) {
933
- console.log(chalk4.yellow(` \u26A0 ${tc.url}`) + chalk4.dim(` \u2014 ${tc.wordCount} words`));
1285
+ console.log(chalk5.yellow(` \u26A0 ${tc.url}`) + chalk5.dim(` \u2014 ${tc.wordCount} words`));
934
1286
  }
935
- if (realThin.length > 10) console.log(chalk4.dim(` ...and ${realThin.length - 10} more`));
1287
+ if (realThin.length > 10) console.log(chalk5.dim(` ...and ${realThin.length - 10} more`));
936
1288
  console.log("");
937
1289
  }
938
1290
  if (appThin.length > 0) {
939
- console.log(chalk4.bold("- App/wizard pages") + chalk4.dim(" (client-rendered, low word count expected)"));
1291
+ console.log(chalk5.bold("- App/wizard pages") + chalk5.dim(" (client-rendered, low word count expected)"));
940
1292
  for (const tc of appThin.slice(0, 5)) {
941
- console.log(chalk4.dim(` \u2139 ${tc.url} \u2014 ${tc.wordCount} words`));
1293
+ console.log(chalk5.dim(` \u2139 ${tc.url} \u2014 ${tc.wordCount} words`));
942
1294
  }
943
- if (appThin.length > 5) console.log(chalk4.dim(` ...and ${appThin.length - 5} more`));
1295
+ if (appThin.length > 5) console.log(chalk5.dim(` ...and ${appThin.length - 5} more`));
944
1296
  console.log("");
945
1297
  }
946
1298
  }
947
1299
  if (a.orphanPages.length > 0) {
948
- console.log(chalk4.bold("- Orphan pages") + chalk4.dim(" (0 internal links pointing to them)"));
949
- for (const o of a.orphanPages.slice(0, 10)) console.log(chalk4.yellow(` \u26A0 ${o}`));
950
- if (a.orphanPages.length > 10) console.log(chalk4.dim(` ...and ${a.orphanPages.length - 10} more`));
1300
+ console.log(chalk5.bold("- Orphan pages") + chalk5.dim(" (0 internal links pointing to them)"));
1301
+ for (const o of a.orphanPages.slice(0, 10)) console.log(chalk5.yellow(` \u26A0 ${o}`));
1302
+ if (a.orphanPages.length > 10) console.log(chalk5.dim(` ...and ${a.orphanPages.length - 10} more`));
951
1303
  console.log("");
952
1304
  }
953
1305
  if (a.slowestPages.length > 0 && a.slowestPages[0].responseTimeMs > 1e3) {
954
- console.log(chalk4.bold("- Slowest pages"));
1306
+ console.log(chalk5.bold("- Slowest pages"));
955
1307
  for (const sp of a.slowestPages.filter((p) => p.responseTimeMs > 1e3).slice(0, 5)) {
956
- const color = sp.responseTimeMs > 3e3 ? chalk4.red : chalk4.yellow;
957
- console.log(color(` \u26A0 ${sp.url}`) + chalk4.dim(` \u2014 ${(sp.responseTimeMs / 1e3).toFixed(1)}s`));
1308
+ const color = sp.responseTimeMs > 3e3 ? chalk5.red : chalk5.yellow;
1309
+ console.log(color(` \u26A0 ${sp.url}`) + chalk5.dim(` \u2014 ${(sp.responseTimeMs / 1e3).toFixed(1)}s`));
958
1310
  }
959
1311
  console.log("");
960
1312
  }
961
1313
  if (a.structuredDataSummary.length > 0) {
962
- console.log(chalk4.bold("- Structured data (JSON-LD)"));
1314
+ console.log(chalk5.bold("- Structured data (JSON-LD)"));
963
1315
  for (const sd of a.structuredDataSummary) {
964
- console.log(chalk4.green(` \u2713 ${sd.type}`) + chalk4.dim(` \u2014 ${sd.count} page${sd.count > 1 ? "s" : ""}`));
1316
+ console.log(chalk5.green(` \u2713 ${sd.type}`) + chalk5.dim(` \u2014 ${sd.count} page${sd.count > 1 ? "s" : ""}`));
965
1317
  }
966
1318
  const pagesWithSD = crawlResult.pages.filter((p) => !p.error && p.structuredDataTypes.length > 0).length;
967
1319
  const pagesWithout = crawlResult.pages.filter((p) => !p.error).length - pagesWithSD;
968
1320
  if (pagesWithout > 0) {
969
- console.log(chalk4.yellow(` \u26A0 ${pagesWithout} pages without any structured data`));
1321
+ console.log(chalk5.yellow(` \u26A0 ${pagesWithout} pages without any structured data`));
970
1322
  }
971
1323
  console.log("");
972
1324
  } else {
973
- console.log(chalk4.bold("- Structured data (JSON-LD)"));
974
- console.log(chalk4.red(` \u2717 No structured data found on any page`));
1325
+ console.log(chalk5.bold("- Structured data (JSON-LD)"));
1326
+ console.log(chalk5.red(` \u2717 No structured data found on any page`));
1327
+ console.log("");
1328
+ }
1329
+ if (a.imageAltIssues.length > 0) {
1330
+ console.log(chalk5.bold("- Image alt text"));
1331
+ for (const img of a.imageAltIssues.slice(0, 10)) {
1332
+ const color = img.missingAlt / img.total >= 0.5 ? chalk5.red : chalk5.yellow;
1333
+ const icon = img.missingAlt / img.total >= 0.5 ? "\u2717" : "\u26A0";
1334
+ console.log(color(` ${icon} ${img.url}`) + chalk5.dim(` \u2014 ${img.missingAlt}/${img.total} images missing alt`));
1335
+ }
1336
+ if (a.imageAltIssues.length > 10) console.log(chalk5.dim(` ...and ${a.imageAltIssues.length - 10} more`));
1337
+ console.log("");
1338
+ }
1339
+ if (a.brokenImages.length > 0) {
1340
+ console.log(chalk5.bold("- Broken images"));
1341
+ for (const img of a.brokenImages.slice(0, 10)) {
1342
+ const src = img.src.length > 80 ? img.src.slice(0, 77) + "..." : img.src;
1343
+ console.log(chalk5.red(` \u2717 ${src}`) + chalk5.dim(` (${img.status}) \u2014 on ${img.pages.length} page${img.pages.length > 1 ? "s" : ""}`));
1344
+ for (const page of img.pages.slice(0, 3)) console.log(chalk5.dim(` ${page}`));
1345
+ if (img.pages.length > 3) console.log(chalk5.dim(` ...and ${img.pages.length - 3} more`));
1346
+ }
1347
+ if (a.brokenImages.length > 10) console.log(chalk5.dim(` ...and ${a.brokenImages.length - 10} more`));
975
1348
  console.log("");
976
1349
  }
977
1350
  }
@@ -989,33 +1362,37 @@ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit e
989
1362
  )
990
1363
  );
991
1364
  } else {
992
- const scoreColor = crawlResult.averageScore >= 90 ? chalk4.green : crawlResult.averageScore >= 70 ? chalk4.yellow : chalk4.red;
993
- console.log(chalk4.bold(" \u2500\u2500\u2500 Summary \u2500\u2500\u2500"));
1365
+ const summaryColor = scoreColor(crawlResult.averageScore);
1366
+ console.log(chalk5.bold(" \u2500\u2500\u2500 Summary \u2500\u2500\u2500"));
994
1367
  console.log("");
995
- console.log(` Pages crawled: ${chalk4.bold(String(crawlResult.totalPages))}`);
996
- console.log(` Average score: ${scoreColor(chalk4.bold(`${crawlResult.averageScore}/100`))} (${crawlResult.grade})`);
997
- console.log(` Errors: ${crawlResult.totalErrors > 0 ? chalk4.red(String(crawlResult.totalErrors)) : chalk4.green("0")}`);
998
- console.log(` Warnings: ${crawlResult.totalWarnings > 0 ? chalk4.yellow(String(crawlResult.totalWarnings)) : chalk4.green("0")}`);
1368
+ const hitLimit = crawlResult.totalPages >= maxPages;
1369
+ console.log(` Pages crawled: ${chalk5.bold(String(crawlResult.totalPages))}${hitLimit ? chalk5.dim(` (limit: ${maxPages} \u2014 use --max-pages to crawl more)`) : ""}`);
1370
+ console.log(` Average score: ${summaryColor(chalk5.bold(`${crawlResult.averageScore}/100`))} (${crawlResult.grade})`);
1371
+ console.log(` Errors: ${crawlResult.totalErrors > 0 ? chalk5.red(String(crawlResult.totalErrors)) : chalk5.green("0")}`);
1372
+ console.log(` Warnings: ${crawlResult.totalWarnings > 0 ? chalk5.yellow(String(crawlResult.totalWarnings)) : chalk5.green("0")}`);
999
1373
  if (assetResult) {
1000
- console.log(` Broken assets: ${assetResult.totalBroken > 0 ? chalk4.red(String(assetResult.totalBroken)) : chalk4.green("0")}`);
1374
+ console.log(` Broken assets: ${assetResult.totalBroken > 0 ? chalk5.red(String(assetResult.totalBroken)) : chalk5.green("0")}`);
1001
1375
  }
1002
1376
  if (crawlResult.skippedUrls.length > 0) {
1003
- console.log(chalk4.dim(` Skipped: ${crawlResult.skippedUrls.length} URLs (over limit)`));
1377
+ console.log(chalk5.dim(` Skipped: ${crawlResult.skippedUrls.length} URLs (over limit)`));
1004
1378
  }
1005
1379
  console.log("");
1006
1380
  }
1007
1381
  if (opts.push) {
1008
- const apiKey = opts.apiKey || process.env.INDXEL_API_KEY;
1382
+ const apiKey = await resolveApiKey(opts.apiKey);
1009
1383
  if (!apiKey) {
1010
1384
  if (!jsonOutput) {
1011
- console.log(chalk4.red(" \u2717 --push requires an API key. Use --api-key or set INDXEL_API_KEY."));
1012
- console.log(chalk4.dim(" Get your key at https://indxel.com/dashboard/settings"));
1385
+ console.log(chalk5.yellow(" \u26A0") + " To push results to your dashboard, link your project first:");
1386
+ console.log("");
1387
+ console.log(chalk5.bold(" npx indxel link"));
1388
+ console.log("");
1389
+ console.log(chalk5.dim(" Or use --api-key / set INDXEL_API_KEY."));
1013
1390
  console.log("");
1014
1391
  }
1015
1392
  } else {
1016
1393
  const pushSpinner = jsonOutput ? null : ora3("Pushing results to Indxel...").start();
1017
1394
  try {
1018
- const pushUrl = process.env.INDXEL_API_URL || "https://www.indxel.com";
1395
+ const pushUrl = process.env.INDXEL_API_URL || "https://indxel.com";
1019
1396
  const res = await fetch(`${pushUrl}/api/cli/push`, {
1020
1397
  method: "POST",
1021
1398
  headers: {
@@ -1049,14 +1426,14 @@ var crawlCommand = new Command3("crawl").description("Crawl a live site, audit e
1049
1426
 
1050
1427
  // src/commands/keywords.ts
1051
1428
  import { Command as Command4 } from "commander";
1052
- import chalk5 from "chalk";
1429
+ import chalk6 from "chalk";
1053
1430
  import ora4 from "ora";
1054
1431
  import { researchKeywords, crawlSite as crawlSite2, analyzeContentGaps } from "indxel";
1055
1432
  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) => {
1056
1433
  const jsonOutput = opts.json;
1057
1434
  if (!jsonOutput) {
1058
1435
  console.log("");
1059
- console.log(chalk5.bold(` indxel keywords`) + chalk5.dim(` \u2014 "${seed}"`));
1436
+ console.log(chalk6.bold(` indxel keywords`) + chalk6.dim(` \u2014 "${seed}"`));
1060
1437
  console.log("");
1061
1438
  }
1062
1439
  const kwSpinner = jsonOutput ? null : ora4("Researching keywords...").start();
@@ -1068,26 +1445,26 @@ var keywordsCommand = new Command4("keywords").description("Research keyword opp
1068
1445
  kwSpinner.succeed(`Found ${kwResult.totalKeywords} keywords`);
1069
1446
  console.log("");
1070
1447
  if (kwResult.suggestions.length > 0) {
1071
- console.log(chalk5.bold(` Direct suggestions (${kwResult.suggestions.length})`));
1448
+ console.log(chalk6.bold(` Direct suggestions (${kwResult.suggestions.length})`));
1072
1449
  for (const s of kwResult.suggestions) {
1073
- console.log(` ${chalk5.hex("#F4A261")(s.keyword)}`);
1450
+ console.log(` ${chalk6.hex("#F4A261")(s.keyword)}`);
1074
1451
  }
1075
1452
  console.log("");
1076
1453
  }
1077
1454
  if (kwResult.questions.length > 0) {
1078
- console.log(chalk5.bold(` Questions (${kwResult.questions.length})`));
1455
+ console.log(chalk6.bold(` Questions (${kwResult.questions.length})`));
1079
1456
  for (const q of kwResult.questions.slice(0, 20)) {
1080
- console.log(` ${chalk5.cyan("?")} ${q.keyword}`);
1457
+ console.log(` ${chalk6.cyan("?")} ${q.keyword}`);
1081
1458
  }
1082
1459
  console.log("");
1083
1460
  }
1084
1461
  if (kwResult.longTail.length > 0) {
1085
- console.log(chalk5.bold(` Long-tail (${kwResult.longTail.length})`));
1462
+ console.log(chalk6.bold(` Long-tail (${kwResult.longTail.length})`));
1086
1463
  for (const lt of kwResult.longTail.slice(0, 20)) {
1087
- console.log(chalk5.dim(` ${lt.keyword}`));
1464
+ console.log(chalk6.dim(` ${lt.keyword}`));
1088
1465
  }
1089
1466
  if (kwResult.longTail.length > 20) {
1090
- console.log(chalk5.dim(` ... and ${kwResult.longTail.length - 20} more`));
1467
+ console.log(chalk6.dim(` ... and ${kwResult.longTail.length - 20} more`));
1091
1468
  }
1092
1469
  console.log("");
1093
1470
  }
@@ -1116,33 +1493,33 @@ var keywordsCommand = new Command4("keywords").description("Research keyword opp
1116
1493
  if (!jsonOutput) {
1117
1494
  console.log("");
1118
1495
  console.log(
1119
- chalk5.bold(` Content coverage: `) + `${gapResult.totalCovered}/${gapResult.totalKeywords} keywords (${gapResult.coveragePercent}%)`
1496
+ chalk6.bold(` Content coverage: `) + `${gapResult.totalCovered}/${gapResult.totalKeywords} keywords (${gapResult.coveragePercent}%)`
1120
1497
  );
1121
1498
  console.log("");
1122
1499
  if (gapResult.gaps.length > 0) {
1123
1500
  const highGaps = gapResult.gaps.filter((g) => g.relevance === "high");
1124
1501
  const medGaps = gapResult.gaps.filter((g) => g.relevance === "medium");
1125
1502
  if (highGaps.length > 0) {
1126
- console.log(chalk5.bold.red(` High priority gaps (${highGaps.length})`));
1503
+ console.log(chalk6.bold.red(` High priority gaps (${highGaps.length})`));
1127
1504
  for (const gap of highGaps.slice(0, 15)) {
1128
1505
  console.log(
1129
- chalk5.red(` \u2717 `) + `"${gap.keyword}" \u2192 ` + chalk5.dim(`${gap.suggestedType} at ${gap.suggestedPath}`)
1506
+ chalk6.red(` \u2717 `) + `"${gap.keyword}" \u2192 ` + chalk6.dim(`${gap.suggestedType} at ${gap.suggestedPath}`)
1130
1507
  );
1131
1508
  }
1132
1509
  console.log("");
1133
1510
  }
1134
1511
  if (medGaps.length > 0) {
1135
- console.log(chalk5.bold.yellow(` Medium priority gaps (${medGaps.length})`));
1512
+ console.log(chalk6.bold.yellow(` Medium priority gaps (${medGaps.length})`));
1136
1513
  for (const gap of medGaps.slice(0, 10)) {
1137
1514
  console.log(
1138
- chalk5.yellow(` \u26A0 `) + `"${gap.keyword}" \u2192 ` + chalk5.dim(`${gap.suggestedType} at ${gap.suggestedPath}`)
1515
+ chalk6.yellow(` \u26A0 `) + `"${gap.keyword}" \u2192 ` + chalk6.dim(`${gap.suggestedType} at ${gap.suggestedPath}`)
1139
1516
  );
1140
1517
  }
1141
1518
  console.log("");
1142
1519
  }
1143
1520
  }
1144
1521
  if (gapResult.gaps.length === 0) {
1145
- console.log(chalk5.green(` \u2713 All keyword opportunities are covered`));
1522
+ console.log(chalk6.green(` \u2713 All keyword opportunities are covered`));
1146
1523
  console.log("");
1147
1524
  }
1148
1525
  }
@@ -1160,12 +1537,14 @@ var keywordsCommand = new Command4("keywords").description("Research keyword opp
1160
1537
 
1161
1538
  // src/commands/index.ts
1162
1539
  import { Command as Command5 } from "commander";
1163
- import chalk6 from "chalk";
1540
+ import chalk7 from "chalk";
1164
1541
  import ora5 from "ora";
1165
1542
  import { fetchSitemap as fetchSitemap2, fetchRobots as fetchRobots2 } from "indxel";
1543
+
1544
+ // src/auth.ts
1166
1545
  async function checkPlan(apiKey) {
1167
1546
  try {
1168
- const apiUrl = process.env.INDXEL_API_URL || "https://www.indxel.com";
1547
+ const apiUrl = process.env.INDXEL_API_URL || "https://indxel.com";
1169
1548
  const res = await fetch(`${apiUrl}/api/cli/plan`, {
1170
1549
  headers: { Authorization: `Bearer ${apiKey}` },
1171
1550
  signal: AbortSignal.timeout(1e4)
@@ -1177,9 +1556,20 @@ async function checkPlan(apiKey) {
1177
1556
  return null;
1178
1557
  }
1179
1558
  }
1180
- 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 API key for Bing/Yandex/DuckDuckGo submission (Pro+)").option("--api-key <key>", "Indxel API key (required for --check and --indexnow-key)").option("--json", "Output results as JSON", false).action(async (url, opts) => {
1559
+
1560
+ // src/commands/index.ts
1561
+ function delay(ms) {
1562
+ return new Promise((r) => setTimeout(r, ms));
1563
+ }
1564
+ 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) => {
1181
1565
  const jsonOutput = opts.json;
1182
- const needsPaid = opts.check || opts.indexnowKey;
1566
+ const needsPaid = opts.check;
1567
+ function log(...args) {
1568
+ if (!jsonOutput) console.log(...args);
1569
+ }
1570
+ function spin(text) {
1571
+ return jsonOutput ? null : ora5(text).start();
1572
+ }
1183
1573
  if (!url.startsWith("http://") && !url.startsWith("https://")) {
1184
1574
  url = `https://${url}`;
1185
1575
  }
@@ -1187,60 +1577,50 @@ var indexCommand = new Command5("index").description("Submit your pages to searc
1187
1577
  try {
1188
1578
  baseUrl = new URL(url);
1189
1579
  } catch {
1190
- console.error(chalk6.red(" Invalid URL."));
1580
+ console.error(chalk7.red(" Invalid URL."));
1191
1581
  process.exit(1);
1192
1582
  }
1193
1583
  const origin = baseUrl.origin;
1194
1584
  const host = baseUrl.hostname;
1195
- if (!jsonOutput) {
1196
- console.log("");
1197
- console.log(chalk6.bold(" indxel index") + chalk6.dim(` \u2014 ${origin}`));
1198
- console.log("");
1199
- }
1585
+ log("");
1586
+ log(chalk7.bold(" indxel index") + chalk7.dim(` \u2014 ${origin}`));
1587
+ log("");
1200
1588
  if (needsPaid) {
1201
- const apiKey = opts.apiKey || process.env.INDXEL_API_KEY;
1589
+ const apiKey = await resolveApiKey(opts.apiKey);
1202
1590
  if (!apiKey) {
1203
- if (!jsonOutput) {
1204
- console.log(chalk6.red(" \u2717 --check and --indexnow-key require an API key (Pro plan)."));
1205
- console.log(chalk6.dim(" Use --api-key or set INDXEL_API_KEY."));
1206
- console.log(chalk6.dim(" Get your key at https://indxel.com/dashboard/settings"));
1207
- console.log("");
1208
- }
1591
+ log(chalk7.red(" \u2717 --check requires a linked project (Plus plan)."));
1592
+ log(chalk7.dim(" Run: ") + chalk7.bold("npx indxel link"));
1593
+ log(chalk7.dim(" Or use --api-key / set INDXEL_API_KEY."));
1594
+ log("");
1209
1595
  process.exit(1);
1210
1596
  }
1211
1597
  const plan = await checkPlan(apiKey);
1212
1598
  if (!plan) {
1213
- if (!jsonOutput) {
1214
- console.log(chalk6.red(" \u2717 Invalid API key."));
1215
- console.log("");
1216
- }
1599
+ log(chalk7.red(" \u2717 Invalid API key."));
1600
+ log("");
1217
1601
  process.exit(1);
1218
1602
  }
1219
1603
  if (plan === "FREE") {
1220
- if (!jsonOutput) {
1221
- console.log(chalk6.red(" \u2717 IndexNow submission and indexation check require a Pro plan."));
1222
- console.log(chalk6.dim(" Upgrade at https://indxel.com/pricing"));
1223
- console.log("");
1224
- }
1604
+ log(chalk7.red(" \u2717 Indexation check requires a Pro plan."));
1605
+ log(chalk7.dim(" Upgrade at https://indxel.com/pricing"));
1606
+ log("");
1225
1607
  process.exit(1);
1226
1608
  }
1227
1609
  }
1228
- const sitemapSpinner = jsonOutput ? null : ora5("Fetching sitemap...").start();
1610
+ const sitemapSpinner = spin("Fetching sitemap...");
1229
1611
  const sitemapResult = await fetchSitemap2(origin);
1230
1612
  const sitemapUrl = `${origin}/sitemap.xml`;
1231
1613
  if (!sitemapResult.found || sitemapResult.urls.length === 0) {
1232
- if (sitemapSpinner) sitemapSpinner.fail("No sitemap found");
1233
- if (!jsonOutput) {
1234
- console.log(chalk6.dim(" Create a sitemap first: ") + chalk6.bold("npx indxel init"));
1235
- console.log("");
1236
- }
1614
+ sitemapSpinner?.fail("No sitemap found");
1615
+ log(chalk7.dim(" Create a sitemap first: ") + chalk7.bold("npx indxel init"));
1616
+ log("");
1237
1617
  if (jsonOutput) {
1238
1618
  console.log(JSON.stringify({ error: "No sitemap found" }, null, 2));
1239
1619
  }
1240
1620
  process.exit(1);
1241
1621
  }
1242
- if (sitemapSpinner) sitemapSpinner.succeed(`Found sitemap \u2014 ${sitemapResult.urls.length} URLs`);
1243
- const robotsSpinner = jsonOutput ? null : ora5("Checking robots.txt...").start();
1622
+ sitemapSpinner?.succeed(`Found sitemap \u2014 ${sitemapResult.urls.length} URLs`);
1623
+ const robotsSpinner = spin("Checking robots.txt...");
1244
1624
  const robotsResult = await fetchRobots2(origin);
1245
1625
  let sitemapInRobots = false;
1246
1626
  if (robotsResult.found) {
@@ -1248,26 +1628,22 @@ var indexCommand = new Command5("index").description("Submit your pages to searc
1248
1628
  (s) => s.toLowerCase().includes("sitemap")
1249
1629
  );
1250
1630
  if (sitemapInRobots) {
1251
- if (robotsSpinner) robotsSpinner.succeed("robots.txt references sitemap");
1631
+ robotsSpinner?.succeed("robots.txt references sitemap");
1252
1632
  } else {
1253
- if (robotsSpinner) robotsSpinner.warn("robots.txt found but doesn't reference sitemap");
1254
- if (!jsonOutput) {
1255
- console.log(chalk6.dim(` Add this to your robots.txt:`));
1256
- console.log(chalk6.dim(` Sitemap: ${sitemapUrl}`));
1257
- }
1633
+ robotsSpinner?.warn("robots.txt found but doesn't reference sitemap");
1634
+ log(chalk7.dim(` Add this to your robots.txt:`));
1635
+ log(chalk7.dim(` Sitemap: ${sitemapUrl}`));
1258
1636
  }
1259
1637
  } else {
1260
- if (robotsSpinner) robotsSpinner.warn("No robots.txt found");
1261
- if (!jsonOutput) {
1262
- console.log(chalk6.dim(" Create one with: ") + chalk6.bold("npx indxel init"));
1263
- }
1638
+ robotsSpinner?.warn("No robots.txt found");
1639
+ log(chalk7.dim(" Create one with: ") + chalk7.bold("npx indxel init"));
1264
1640
  }
1265
- if (!jsonOutput) console.log("");
1266
- const indexNowKey = opts.indexnowKey || process.env.INDEXNOW_KEY;
1267
- let indexNowResult = [];
1641
+ log("");
1642
+ const indexNowKey = opts.indexnowKey || process.env.INDEXNOW_KEY || await loadIndexNowKey(process.cwd());
1643
+ const indexNowResult = [];
1268
1644
  if (indexNowKey) {
1269
1645
  const urls = sitemapResult.urls.map((u) => u.loc);
1270
- const indexNowSpinner = jsonOutput ? null : ora5("Submitting via IndexNow...").start();
1646
+ const indexNowSpinner = spin("Submitting via IndexNow...");
1271
1647
  const indexNowEngines = [
1272
1648
  { name: "Bing/Yandex", endpoint: "https://api.indexnow.org/indexnow" }
1273
1649
  ];
@@ -1285,39 +1661,32 @@ var indexCommand = new Command5("index").description("Submit your pages to searc
1285
1661
  }),
1286
1662
  signal: AbortSignal.timeout(15e3)
1287
1663
  });
1288
- if (res.ok || res.status === 200 || res.status === 202) {
1664
+ if (res.ok || res.status === 202) {
1289
1665
  indexNowResult.push({ submitted: true, engine: engine.name, status: res.status });
1290
- if (indexNowSpinner) indexNowSpinner.succeed(`IndexNow \u2014 ${urls.length} URLs submitted to ${engine.name}`);
1666
+ indexNowSpinner?.succeed(`IndexNow \u2014 ${urls.length} URLs submitted to ${engine.name}`);
1291
1667
  } else {
1292
1668
  indexNowResult.push({ submitted: false, engine: engine.name, status: res.status });
1293
- if (indexNowSpinner) indexNowSpinner.warn(`IndexNow \u2014 ${engine.name} returned HTTP ${res.status}`);
1669
+ indexNowSpinner?.warn(`IndexNow \u2014 ${engine.name} returned HTTP ${res.status}`);
1294
1670
  }
1295
1671
  } catch (err) {
1296
1672
  indexNowResult.push({ submitted: false, engine: engine.name });
1297
- if (indexNowSpinner) indexNowSpinner.fail(`IndexNow \u2014 ${err instanceof Error ? err.message : "failed"}`);
1673
+ indexNowSpinner?.fail(`IndexNow \u2014 ${err instanceof Error ? err.message : "failed"}`);
1298
1674
  }
1299
1675
  }
1300
1676
  } else {
1301
- if (!jsonOutput) {
1302
- console.log(chalk6.bold(" IndexNow") + chalk6.dim(" (Bing, Yandex, DuckDuckGo)"));
1303
- console.log(chalk6.dim(" Get instant indexing by setting up IndexNow:"));
1304
- console.log(chalk6.dim(" 1. Generate a key at ") + chalk6.underline("https://www.bing.com/indexnow"));
1305
- console.log(chalk6.dim(` 2. Host the key file at ${origin}/{key}.txt`));
1306
- console.log(chalk6.dim(" 3. Run: ") + chalk6.bold(`npx indxel index ${host} --indexnow-key YOUR_KEY`));
1307
- console.log("");
1308
- }
1309
- }
1310
- if (!jsonOutput) {
1311
- console.log(chalk6.bold(" Google Search Console"));
1312
- console.log(chalk6.dim(" Google requires manual setup via Search Console:"));
1313
- console.log(chalk6.dim(" 1. Go to ") + chalk6.underline("https://search.google.com/search-console"));
1314
- console.log(chalk6.dim(` 2. Add & verify ${host}`));
1315
- console.log(chalk6.dim(" 3. Submit your sitemap: Sitemaps > Add > sitemap.xml"));
1316
- console.log("");
1317
- }
1677
+ log(chalk7.bold(" IndexNow") + chalk7.dim(" (Bing, Yandex, DuckDuckGo)"));
1678
+ log(chalk7.dim(" Not configured. Run ") + chalk7.bold("npx indxel init") + chalk7.dim(" to set it up automatically."));
1679
+ log("");
1680
+ }
1681
+ log(chalk7.bold(" Google Search Console"));
1682
+ log(chalk7.dim(" Google requires manual setup via Search Console:"));
1683
+ log(chalk7.dim(" 1. Go to ") + chalk7.underline("https://search.google.com/search-console"));
1684
+ log(chalk7.dim(` 2. Add & verify ${host}`));
1685
+ log(chalk7.dim(" 3. Submit your sitemap: Sitemaps > Add > sitemap.xml"));
1686
+ log("");
1318
1687
  let indexationResults = null;
1319
1688
  if (opts.check) {
1320
- const checkSpinner = jsonOutput ? null : ora5("Checking indexation status...").start();
1689
+ const checkSpinner = spin("Checking indexation status...");
1321
1690
  indexationResults = [];
1322
1691
  const urls = sitemapResult.urls.map((u) => u.loc);
1323
1692
  let indexedCount = 0;
@@ -1342,24 +1711,23 @@ var indexCommand = new Command5("index").description("Submit your pages to searc
1342
1711
  } catch {
1343
1712
  indexationResults.push({ url: pageUrl, indexed: false });
1344
1713
  }
1345
- await new Promise((r) => setTimeout(r, 300));
1346
- }
1347
- if (checkSpinner) {
1348
- checkSpinner.succeed(`Indexation: ${indexedCount}/${urls.length} pages found in Google cache`);
1714
+ await delay(300);
1349
1715
  }
1716
+ checkSpinner?.succeed(`Indexation: ${indexedCount}/${urls.length} pages found in Google cache`);
1350
1717
  if (!jsonOutput) {
1351
1718
  console.log("");
1352
1719
  const notIndexed = indexationResults.filter((r) => !r.indexed);
1353
1720
  if (notIndexed.length > 0) {
1354
- console.log(chalk6.bold(` Not indexed (${notIndexed.length})`));
1721
+ console.log(chalk7.bold(` Not indexed (${notIndexed.length})`));
1355
1722
  for (const r of notIndexed.slice(0, 20)) {
1356
- console.log(chalk6.red(" \u2717 ") + r.url);
1723
+ console.log(chalk7.red(" \u2717 ") + r.url);
1724
+ }
1725
+ if (notIndexed.length > 20) {
1726
+ console.log(chalk7.dim(` ... and ${notIndexed.length - 20} more`));
1357
1727
  }
1358
- if (notIndexed.length > 20) console.log(chalk6.dim(` ... and ${notIndexed.length - 20} more`));
1359
1728
  console.log("");
1360
- }
1361
- if (notIndexed.length === 0) {
1362
- console.log(chalk6.green(" \u2713 All pages appear indexed"));
1729
+ } else {
1730
+ console.log(chalk7.green(" \u2713 All pages appear indexed"));
1363
1731
  console.log("");
1364
1732
  }
1365
1733
  }
@@ -1372,16 +1740,25 @@ var indexCommand = new Command5("index").description("Submit your pages to searc
1372
1740
  indexation: indexationResults
1373
1741
  }, null, 2));
1374
1742
  } else {
1375
- console.log(chalk6.bold(" \u2500\u2500\u2500 Summary \u2500\u2500\u2500"));
1743
+ console.log(chalk7.bold(" \u2500\u2500\u2500 Summary \u2500\u2500\u2500"));
1376
1744
  console.log("");
1377
1745
  console.log(` Sitemap: ${sitemapResult.urls.length} URLs`);
1378
- console.log(` robots.txt: ${robotsResult.found ? sitemapInRobots ? chalk6.green("\u2713 references sitemap") : chalk6.yellow("\u26A0 missing sitemap ref") : chalk6.red("\u2717 not found")}`);
1746
+ let robotsStatus;
1747
+ if (!robotsResult.found) {
1748
+ robotsStatus = chalk7.red("\u2717 not found");
1749
+ } else if (sitemapInRobots) {
1750
+ robotsStatus = chalk7.green("\u2713 references sitemap");
1751
+ } else {
1752
+ robotsStatus = chalk7.yellow("\u26A0 missing sitemap ref");
1753
+ }
1754
+ console.log(` robots.txt: ${robotsStatus}`);
1379
1755
  if (indexNowResult.length > 0) {
1380
1756
  for (const r of indexNowResult) {
1381
- console.log(` IndexNow: ${r.submitted ? chalk6.green("\u2713 submitted") : chalk6.red("\u2717 failed")} (${r.engine})`);
1757
+ const status = r.submitted ? chalk7.green("\u2713 submitted") : chalk7.red("\u2717 failed");
1758
+ console.log(` IndexNow: ${status} (${r.engine})`);
1382
1759
  }
1383
1760
  } else {
1384
- console.log(` IndexNow: ${chalk6.dim("not configured (use --indexnow-key)")}`);
1761
+ console.log(` IndexNow: ${chalk7.dim("not configured (run npx indxel init)")}`);
1385
1762
  }
1386
1763
  if (indexationResults) {
1387
1764
  const indexedCount = indexationResults.filter((r) => r.indexed).length;
@@ -1391,15 +1768,477 @@ var indexCommand = new Command5("index").description("Submit your pages to searc
1391
1768
  }
1392
1769
  });
1393
1770
 
1771
+ // src/commands/perf.ts
1772
+ import { Command as Command6 } from "commander";
1773
+ import chalk8 from "chalk";
1774
+ import ora6 from "ora";
1775
+ import { fetchSitemap as fetchSitemap3 } from "indxel";
1776
+ var THRESHOLDS = {
1777
+ lcp: { good: 2500, poor: 4e3 },
1778
+ cls: { good: 0.1, poor: 0.25 },
1779
+ inp: { good: 200, poor: 500 }
1780
+ };
1781
+ function ratingFor(metric, value) {
1782
+ const t = THRESHOLDS[metric];
1783
+ if (value <= t.good) return "good";
1784
+ if (value <= t.poor) return "needs-work";
1785
+ return "poor";
1786
+ }
1787
+ function colorForRating(rating) {
1788
+ if (rating === "good") return chalk8.green;
1789
+ if (rating === "needs-work") return chalk8.yellow;
1790
+ return chalk8.red;
1791
+ }
1792
+ function iconForRating(rating) {
1793
+ if (rating === "good") return chalk8.green("\u2713");
1794
+ if (rating === "needs-work") return chalk8.yellow("\u26A0");
1795
+ return chalk8.red("\u2717");
1796
+ }
1797
+ function formatMs(ms) {
1798
+ if (ms >= 1e3) return `${(ms / 1e3).toFixed(1)}s`;
1799
+ return `${Math.round(ms)}ms`;
1800
+ }
1801
+ function formatCls(value) {
1802
+ return value.toFixed(2);
1803
+ }
1804
+ function scoreColor2(score) {
1805
+ if (score >= 90) return chalk8.green;
1806
+ if (score >= 50) return chalk8.yellow;
1807
+ return chalk8.red;
1808
+ }
1809
+ function delay2(ms) {
1810
+ return new Promise((r) => setTimeout(r, ms));
1811
+ }
1812
+ async function fetchPsi(url, strategy) {
1813
+ const params = new URLSearchParams({
1814
+ url,
1815
+ strategy,
1816
+ category: "performance"
1817
+ });
1818
+ const res = await fetch(
1819
+ `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?${params}`,
1820
+ { signal: AbortSignal.timeout(6e4) }
1821
+ );
1822
+ if (!res.ok) {
1823
+ const body = await res.text().catch(() => "");
1824
+ throw new Error(`PSI API returned HTTP ${res.status}: ${body.slice(0, 200)}`);
1825
+ }
1826
+ const data = await res.json();
1827
+ return parsePsiResponse(data);
1828
+ }
1829
+ function parsePsiResponse(data) {
1830
+ const lr = data.lighthouseResult;
1831
+ if (!lr) throw new Error("No lighthouseResult in PSI response");
1832
+ const perfScore = lr.categories?.performance?.score;
1833
+ if (perfScore == null) throw new Error("No performance score in PSI response");
1834
+ return {
1835
+ performanceScore: Math.round(perfScore * 100),
1836
+ lcp: lr.audits?.["largest-contentful-paint"]?.numericValue ?? 0,
1837
+ cls: lr.audits?.["cumulative-layout-shift"]?.numericValue ?? 0,
1838
+ inp: lr.audits?.["interaction-to-next-paint"]?.numericValue ?? 0,
1839
+ fcp: lr.audits?.["first-contentful-paint"]?.numericValue ?? 0,
1840
+ si: lr.audits?.["speed-index"]?.numericValue ?? 0,
1841
+ tbt: lr.audits?.["total-blocking-time"]?.numericValue ?? 0
1842
+ };
1843
+ }
1844
+ function checkBudgets(metrics, budgets) {
1845
+ const failures = [];
1846
+ if (budgets.score != null && metrics.performanceScore < budgets.score) {
1847
+ failures.push(
1848
+ `Performance score ${metrics.performanceScore} is below budget ${budgets.score}`
1849
+ );
1850
+ }
1851
+ if (budgets.lcp != null && metrics.lcp > budgets.lcp) {
1852
+ failures.push(
1853
+ `LCP ${formatMs(metrics.lcp)} exceeds budget ${formatMs(budgets.lcp)}`
1854
+ );
1855
+ }
1856
+ if (budgets.cls != null && metrics.cls > budgets.cls) {
1857
+ failures.push(
1858
+ `CLS ${formatCls(metrics.cls)} exceeds budget ${formatCls(budgets.cls)}`
1859
+ );
1860
+ }
1861
+ return failures;
1862
+ }
1863
+ function printPageResult(result) {
1864
+ const { url, strategy, metrics, error } = result;
1865
+ console.log(
1866
+ chalk8.bold(" indxel perf") + chalk8.dim(` \u2014 ${url} (${strategy})`)
1867
+ );
1868
+ console.log("");
1869
+ if (error || !metrics) {
1870
+ console.log(chalk8.red(` \u2717 ${error ?? "Unknown error"}`));
1871
+ console.log("");
1872
+ return;
1873
+ }
1874
+ const sc = scoreColor2(metrics.performanceScore);
1875
+ const perfIcon = metrics.performanceScore >= 90 ? chalk8.green("\u2713") : metrics.performanceScore >= 50 ? chalk8.yellow("\u26A0") : chalk8.red("\u2717");
1876
+ console.log(
1877
+ ` ${perfIcon} ${chalk8.bold("Performance")} ${sc(chalk8.bold(`${metrics.performanceScore}/100`))}`
1878
+ );
1879
+ console.log("");
1880
+ console.log(chalk8.bold(" Core Web Vitals"));
1881
+ const lcpRating = ratingFor("lcp", metrics.lcp);
1882
+ const clsRating = ratingFor("cls", metrics.cls);
1883
+ const inpRating = ratingFor("inp", metrics.inp);
1884
+ console.log(
1885
+ ` ${iconForRating(lcpRating)} ${colorForRating(lcpRating)(formatMs(metrics.lcp).padEnd(9))} ${chalk8.dim("LCP Largest Contentful Paint")}`
1886
+ );
1887
+ console.log(
1888
+ ` ${iconForRating(clsRating)} ${colorForRating(clsRating)(formatCls(metrics.cls).padEnd(9))} ${chalk8.dim("CLS Cumulative Layout Shift")}`
1889
+ );
1890
+ console.log(
1891
+ ` ${iconForRating(inpRating)} ${colorForRating(inpRating)(formatMs(metrics.inp).padEnd(9))} ${chalk8.dim("INP Interaction to Next Paint")}`
1892
+ );
1893
+ console.log("");
1894
+ console.log(chalk8.bold(" Other metrics"));
1895
+ console.log(
1896
+ ` ${formatMs(metrics.fcp).padEnd(9)} ${chalk8.dim("FCP First Contentful Paint")}`
1897
+ );
1898
+ console.log(
1899
+ ` ${formatMs(metrics.si).padEnd(9)} ${chalk8.dim("SI Speed Index")}`
1900
+ );
1901
+ console.log(
1902
+ ` ${formatMs(metrics.tbt).padEnd(9)} ${chalk8.dim("TBT Total Blocking Time")}`
1903
+ );
1904
+ console.log("");
1905
+ }
1906
+ function printMultiPageSummary(results) {
1907
+ const valid = results.filter((r) => r.metrics);
1908
+ if (valid.length === 0) return;
1909
+ const avgScore = Math.round(
1910
+ valid.reduce((s, r) => s + r.metrics.performanceScore, 0) / valid.length
1911
+ );
1912
+ const worstLcp = Math.max(...valid.map((r) => r.metrics.lcp));
1913
+ const worstCls = Math.max(...valid.map((r) => r.metrics.cls));
1914
+ const worstInp = Math.max(...valid.map((r) => r.metrics.inp));
1915
+ console.log(chalk8.bold(" \u2500\u2500\u2500 Summary \u2500\u2500\u2500"));
1916
+ console.log("");
1917
+ console.log(` Pages tested: ${chalk8.bold(String(results.length))}`);
1918
+ console.log(
1919
+ ` Avg score: ${scoreColor2(avgScore)(chalk8.bold(`${avgScore}/100`))}`
1920
+ );
1921
+ const lcpR = ratingFor("lcp", worstLcp);
1922
+ const clsR = ratingFor("cls", worstCls);
1923
+ const inpR = ratingFor("inp", worstInp);
1924
+ console.log(
1925
+ ` Worst LCP: ${colorForRating(lcpR)(formatMs(worstLcp))}`
1926
+ );
1927
+ console.log(
1928
+ ` Worst CLS: ${colorForRating(clsR)(formatCls(worstCls))}`
1929
+ );
1930
+ console.log(
1931
+ ` Worst INP: ${colorForRating(inpR)(formatMs(worstInp))}`
1932
+ );
1933
+ console.log("");
1934
+ }
1935
+ 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(
1936
+ "--strategy <strategy>",
1937
+ "Testing strategy: mobile or desktop",
1938
+ "mobile"
1939
+ ).option(
1940
+ "--pages <n>",
1941
+ "Test top N pages from sitemap (default: 1 = just the URL)",
1942
+ "1"
1943
+ ).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) => {
1944
+ const jsonOutput = opts.json;
1945
+ const strategy = opts.strategy;
1946
+ const pageCount = parseInt(opts.pages, 10);
1947
+ if (strategy !== "mobile" && strategy !== "desktop") {
1948
+ console.error(
1949
+ chalk8.red(" --strategy must be 'mobile' or 'desktop'")
1950
+ );
1951
+ process.exit(1);
1952
+ }
1953
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
1954
+ url = `https://${url}`;
1955
+ }
1956
+ try {
1957
+ new URL(url);
1958
+ } catch {
1959
+ console.error(chalk8.red(" Invalid URL."));
1960
+ process.exit(1);
1961
+ }
1962
+ let urls = [url];
1963
+ if (pageCount > 1) {
1964
+ const sitemapSpinner = jsonOutput ? null : ora6("Fetching sitemap...").start();
1965
+ const sitemap = await fetchSitemap3(url);
1966
+ if (sitemap.found && sitemap.urls.length > 0) {
1967
+ urls = sitemap.urls.slice(0, pageCount).map((u) => u.loc);
1968
+ sitemapSpinner?.succeed(
1969
+ `Found ${sitemap.urls.length} URLs, testing top ${urls.length}`
1970
+ );
1971
+ } else {
1972
+ sitemapSpinner?.warn("No sitemap found, testing single URL");
1973
+ }
1974
+ if (!jsonOutput) console.log("");
1975
+ }
1976
+ const results = [];
1977
+ for (let i = 0; i < urls.length; i++) {
1978
+ const targetUrl = urls[i];
1979
+ const spinner = jsonOutput ? null : ora6(
1980
+ urls.length > 1 ? `Testing ${i + 1}/${urls.length}: ${targetUrl}` : `Testing ${targetUrl} (${strategy})...`
1981
+ ).start();
1982
+ try {
1983
+ const metrics = await fetchPsi(targetUrl, strategy);
1984
+ spinner?.stop();
1985
+ results.push({ url: targetUrl, strategy, metrics });
1986
+ if (!jsonOutput) {
1987
+ printPageResult({ url: targetUrl, strategy, metrics });
1988
+ }
1989
+ } catch (err) {
1990
+ const errMsg = err instanceof Error ? err.message : String(err);
1991
+ spinner?.fail(`Failed: ${targetUrl}`);
1992
+ results.push({ url: targetUrl, strategy, metrics: null, error: errMsg });
1993
+ if (!jsonOutput) {
1994
+ console.log(chalk8.red(` ${errMsg}`));
1995
+ console.log("");
1996
+ }
1997
+ }
1998
+ if (i < urls.length - 1) {
1999
+ await delay2(2e3);
2000
+ }
2001
+ }
2002
+ if (!jsonOutput && results.length > 1) {
2003
+ printMultiPageSummary(results);
2004
+ }
2005
+ if (jsonOutput) {
2006
+ const output = results.length === 1 ? {
2007
+ url: results[0].url,
2008
+ strategy: results[0].strategy,
2009
+ ...results[0].metrics ?? {},
2010
+ error: results[0].error ?? void 0
2011
+ } : {
2012
+ strategy,
2013
+ pages: results.map((r) => ({
2014
+ url: r.url,
2015
+ ...r.metrics ?? {},
2016
+ error: r.error ?? void 0
2017
+ })),
2018
+ summary: (() => {
2019
+ const valid = results.filter((r) => r.metrics);
2020
+ if (valid.length === 0) return null;
2021
+ return {
2022
+ avgScore: Math.round(
2023
+ valid.reduce(
2024
+ (s, r) => s + r.metrics.performanceScore,
2025
+ 0
2026
+ ) / valid.length
2027
+ ),
2028
+ worstLcp: Math.max(
2029
+ ...valid.map((r) => r.metrics.lcp)
2030
+ ),
2031
+ worstCls: Math.max(
2032
+ ...valid.map((r) => r.metrics.cls)
2033
+ ),
2034
+ worstInp: Math.max(
2035
+ ...valid.map((r) => r.metrics.inp)
2036
+ )
2037
+ };
2038
+ })()
2039
+ };
2040
+ console.log(JSON.stringify(output, null, 2));
2041
+ }
2042
+ const budgets = {
2043
+ lcp: opts.budgetLcp ? parseFloat(opts.budgetLcp) : void 0,
2044
+ cls: opts.budgetCls ? parseFloat(opts.budgetCls) : void 0,
2045
+ score: opts.budgetScore ? parseInt(opts.budgetScore, 10) : void 0
2046
+ };
2047
+ const hasBudgets = budgets.lcp != null || budgets.cls != null || budgets.score != null;
2048
+ if (hasBudgets) {
2049
+ const allFailures = [];
2050
+ for (const r of results) {
2051
+ if (!r.metrics) continue;
2052
+ const failures = checkBudgets(r.metrics, budgets);
2053
+ if (failures.length > 0) {
2054
+ allFailures.push(...failures.map((f) => `${r.url}: ${f}`));
2055
+ }
2056
+ }
2057
+ if (allFailures.length > 0) {
2058
+ if (!jsonOutput) {
2059
+ console.log(chalk8.red(chalk8.bold(" Budget exceeded:")));
2060
+ for (const f of allFailures) {
2061
+ console.log(chalk8.red(` \u2717 ${f}`));
2062
+ }
2063
+ console.log("");
2064
+ }
2065
+ process.exit(1);
2066
+ }
2067
+ }
2068
+ });
2069
+
2070
+ // src/commands/link.ts
2071
+ import { Command as Command7 } from "commander";
2072
+ import chalk9 from "chalk";
2073
+ import ora7 from "ora";
2074
+ function delay3(ms) {
2075
+ return new Promise((r) => setTimeout(r, ms));
2076
+ }
2077
+ async function openBrowser(url) {
2078
+ const { platform } = process;
2079
+ const { exec } = await import("child_process");
2080
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
2081
+ exec(`${cmd} ${url}`);
2082
+ }
2083
+ 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) => {
2084
+ const apiUrl = process.env.INDXEL_API_URL || "https://indxel.com";
2085
+ const cwd = process.cwd();
2086
+ console.log("");
2087
+ console.log(chalk9.bold(" indxel link"));
2088
+ console.log("");
2089
+ const existing = await loadProjectConfig(cwd);
2090
+ if (existing) {
2091
+ console.log(chalk9.green(" \u2713") + ` Already linked to ${chalk9.bold(existing.projectName)}`);
2092
+ console.log(chalk9.dim(` Project ID: ${existing.projectId}`));
2093
+ console.log(chalk9.dim(` Linked at: ${existing.linkedAt}`));
2094
+ console.log("");
2095
+ console.log(chalk9.dim(" To re-link, delete .indxel/config.json and run again."));
2096
+ console.log("");
2097
+ return;
2098
+ }
2099
+ if (opts.apiKey) {
2100
+ const spinner = ora7("Verifying API key...").start();
2101
+ try {
2102
+ const res = await fetch(`${apiUrl}/api/projects/by-key`, {
2103
+ headers: { Authorization: `Bearer ${opts.apiKey}` },
2104
+ signal: AbortSignal.timeout(1e4)
2105
+ });
2106
+ if (!res.ok) {
2107
+ spinner.fail("Invalid API key.");
2108
+ console.log(chalk9.dim(" Check your key at https://indxel.com/dashboard/settings"));
2109
+ console.log("");
2110
+ process.exit(1);
2111
+ }
2112
+ const body = await res.json();
2113
+ const project = body.project;
2114
+ spinner.succeed(`Linked to ${chalk9.bold(project.name)}`);
2115
+ await saveProjectConfig(cwd, {
2116
+ apiKey: opts.apiKey,
2117
+ projectId: project.id,
2118
+ projectName: project.name,
2119
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString()
2120
+ });
2121
+ await syncIndexNowKey(cwd, apiUrl, opts.apiKey, project.id);
2122
+ console.log("");
2123
+ console.log(chalk9.dim(" Config saved to .indxel/config.json"));
2124
+ console.log(chalk9.dim(" You can now use ") + chalk9.bold("npx indxel crawl --push") + chalk9.dim(" without --api-key."));
2125
+ console.log("");
2126
+ return;
2127
+ } catch (err) {
2128
+ spinner.fail(err instanceof Error ? err.message : "Connection failed");
2129
+ console.log("");
2130
+ process.exit(1);
2131
+ }
2132
+ }
2133
+ const initSpinner = ora7("Starting device flow...").start();
2134
+ let deviceCode;
2135
+ let userCode;
2136
+ try {
2137
+ const res = await fetch(`${apiUrl}/api/cli/auth`, {
2138
+ method: "POST",
2139
+ headers: { "Content-Type": "application/json" },
2140
+ signal: AbortSignal.timeout(1e4)
2141
+ });
2142
+ if (!res.ok) {
2143
+ initSpinner.fail("Could not start device flow.");
2144
+ console.log(chalk9.dim(" You can also link directly: ") + chalk9.bold("npx indxel link --api-key <your-key>"));
2145
+ console.log("");
2146
+ process.exit(1);
2147
+ }
2148
+ const data = await res.json();
2149
+ deviceCode = data.deviceCode;
2150
+ userCode = data.userCode;
2151
+ initSpinner.stop();
2152
+ } catch (err) {
2153
+ initSpinner.fail(err instanceof Error ? err.message : "Connection failed");
2154
+ console.log(chalk9.dim(" You can also link directly: ") + chalk9.bold("npx indxel link --api-key <your-key>"));
2155
+ console.log("");
2156
+ process.exit(1);
2157
+ return;
2158
+ }
2159
+ const connectUrl = `${apiUrl}/cli/connect?code=${userCode}`;
2160
+ console.log(chalk9.bold(" Open this URL in your browser:"));
2161
+ console.log("");
2162
+ console.log(` ${chalk9.underline(connectUrl)}`);
2163
+ console.log("");
2164
+ console.log(` Your code: ${chalk9.bold.cyan(userCode)}`);
2165
+ console.log("");
2166
+ try {
2167
+ await openBrowser(connectUrl);
2168
+ console.log(chalk9.dim(" Browser opened automatically."));
2169
+ } catch {
2170
+ }
2171
+ const pollSpinner = ora7("Waiting for authorization...").start();
2172
+ const maxWait = 5 * 60 * 1e3;
2173
+ const pollInterval = 2e3;
2174
+ const startTime = Date.now();
2175
+ while (Date.now() - startTime < maxWait) {
2176
+ await delay3(pollInterval);
2177
+ try {
2178
+ const res = await fetch(`${apiUrl}/api/cli/auth?code=${deviceCode}`, {
2179
+ signal: AbortSignal.timeout(1e4)
2180
+ });
2181
+ if (res.status === 202) {
2182
+ continue;
2183
+ }
2184
+ if (res.ok) {
2185
+ const data = await res.json();
2186
+ pollSpinner.succeed(`Linked to ${chalk9.bold(data.projectName)}`);
2187
+ await saveProjectConfig(cwd, {
2188
+ apiKey: data.apiKey,
2189
+ projectId: data.projectId,
2190
+ projectName: data.projectName,
2191
+ linkedAt: (/* @__PURE__ */ new Date()).toISOString()
2192
+ });
2193
+ await syncIndexNowKey(cwd, apiUrl, data.apiKey, data.projectId);
2194
+ console.log("");
2195
+ console.log(chalk9.dim(" Config saved to .indxel/config.json"));
2196
+ console.log(chalk9.dim(" You can now use ") + chalk9.bold("npx indxel crawl --push") + chalk9.dim(" without --api-key."));
2197
+ console.log("");
2198
+ return;
2199
+ }
2200
+ pollSpinner.fail("Authorization failed.");
2201
+ console.log("");
2202
+ process.exit(1);
2203
+ } catch {
2204
+ }
2205
+ }
2206
+ pollSpinner.fail("Timed out waiting for authorization (5 minutes).");
2207
+ console.log(chalk9.dim(" Try again: ") + chalk9.bold("npx indxel link"));
2208
+ console.log("");
2209
+ process.exit(1);
2210
+ });
2211
+ async function syncIndexNowKey(cwd, apiUrl, apiKey, projectId) {
2212
+ const indexNowKey = await loadIndexNowKey(cwd);
2213
+ if (!indexNowKey) return;
2214
+ try {
2215
+ const res = await fetch(`${apiUrl}/api/projects/${projectId}/indexation/setup`, {
2216
+ method: "POST",
2217
+ headers: {
2218
+ "Content-Type": "application/json",
2219
+ Authorization: `Bearer ${apiKey}`
2220
+ },
2221
+ body: JSON.stringify({ action: "sync", key: indexNowKey }),
2222
+ signal: AbortSignal.timeout(1e4)
2223
+ });
2224
+ if (res.ok) {
2225
+ console.log(chalk9.green(" \u2713") + " IndexNow key synced to dashboard");
2226
+ }
2227
+ } catch {
2228
+ }
2229
+ }
2230
+
1394
2231
  // src/index.ts
1395
2232
  function createProgram() {
1396
- const program2 = new Command6();
2233
+ const program2 = new Command8();
1397
2234
  program2.name("indxel").description("Infrastructure SEO developer-first. ESLint pour le SEO.").version("0.1.0");
1398
2235
  program2.addCommand(initCommand);
1399
2236
  program2.addCommand(checkCommand);
1400
2237
  program2.addCommand(crawlCommand);
1401
2238
  program2.addCommand(keywordsCommand);
1402
2239
  program2.addCommand(indexCommand);
2240
+ program2.addCommand(perfCommand);
2241
+ program2.addCommand(linkCommand);
1403
2242
  return program2;
1404
2243
  }
1405
2244