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