indxel-cli 0.1.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 ADDED
@@ -0,0 +1,1136 @@
1
+ // src/index.ts
2
+ import { Command as Command5 } from "commander";
3
+
4
+ // src/commands/init.ts
5
+ import { Command } from "commander";
6
+ import chalk from "chalk";
7
+ import ora from "ora";
8
+ import { writeFile } from "fs/promises";
9
+ import { join as join2 } from "path";
10
+
11
+ // src/detect.ts
12
+ import { existsSync } from "fs";
13
+ import { readFile } from "fs/promises";
14
+ import { join } from "path";
15
+ async function detectProject(cwd) {
16
+ const info = {
17
+ root: cwd,
18
+ isNextJs: false,
19
+ usesAppRouter: false,
20
+ appDir: "app",
21
+ isTypeScript: false,
22
+ hasSeoConfig: false,
23
+ hasSitemap: false,
24
+ hasRobots: false
25
+ };
26
+ const nextConfigs = [
27
+ "next.config.ts",
28
+ "next.config.js",
29
+ "next.config.mjs",
30
+ "next.config.cjs"
31
+ ];
32
+ info.isNextJs = nextConfigs.some((f) => existsSync(join(cwd, f)));
33
+ const pkgPath = join(cwd, "package.json");
34
+ if (existsSync(pkgPath)) {
35
+ try {
36
+ const pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
37
+ const nextDep = pkg.dependencies?.next ?? pkg.devDependencies?.next;
38
+ if (nextDep) {
39
+ info.isNextJs = true;
40
+ info.nextVersion = nextDep.replace(/[\^~>=<]/g, "").trim();
41
+ }
42
+ } catch {
43
+ }
44
+ }
45
+ info.isTypeScript = existsSync(join(cwd, "tsconfig.json")) || existsSync(join(cwd, "tsconfig.ts"));
46
+ if (existsSync(join(cwd, "src", "app"))) {
47
+ info.usesAppRouter = true;
48
+ info.appDir = "src/app";
49
+ } else if (existsSync(join(cwd, "app"))) {
50
+ info.usesAppRouter = true;
51
+ info.appDir = "app";
52
+ }
53
+ info.hasSeoConfig = existsSync(join(cwd, "seo.config.ts")) || existsSync(join(cwd, "seo.config.js"));
54
+ info.hasSitemap = existsSync(join(cwd, info.appDir, "sitemap.ts")) || existsSync(join(cwd, info.appDir, "sitemap.js")) || existsSync(join(cwd, info.appDir, "sitemap.xml"));
55
+ info.hasRobots = existsSync(join(cwd, info.appDir, "robots.ts")) || existsSync(join(cwd, info.appDir, "robots.js")) || existsSync(join(cwd, info.appDir, "robots.txt"));
56
+ return info;
57
+ }
58
+
59
+ // src/templates.ts
60
+ function seoConfigTemplate(isTypeScript) {
61
+ if (isTypeScript) {
62
+ return `import { defineSEO } from 'indxel'
63
+
64
+ export default defineSEO({
65
+ siteName: 'My Site',
66
+ siteUrl: 'https://example.com',
67
+ titleTemplate: '%s | My Site',
68
+ defaultDescription: 'A short description of your site for search engines.',
69
+ defaultOGImage: '/og-image.png',
70
+ locale: 'en_US',
71
+ // twitter: {
72
+ // handle: '@yourhandle',
73
+ // cardType: 'summary_large_image',
74
+ // },
75
+ // organization: {
76
+ // name: 'My Company',
77
+ // logo: '/logo.png',
78
+ // url: 'https://example.com',
79
+ // },
80
+ })
81
+ `;
82
+ }
83
+ return `const { defineSEO } = require('indxel')
84
+
85
+ module.exports = defineSEO({
86
+ siteName: 'My Site',
87
+ siteUrl: 'https://example.com',
88
+ titleTemplate: '%s | My Site',
89
+ defaultDescription: 'A short description of your site for search engines.',
90
+ defaultOGImage: '/og-image.png',
91
+ locale: 'en_US',
92
+ })
93
+ `;
94
+ }
95
+ function sitemapTemplate(isTypeScript) {
96
+ if (isTypeScript) {
97
+ return `import type { MetadataRoute } from 'next'
98
+
99
+ export default function sitemap(): MetadataRoute.Sitemap {
100
+ const baseUrl = 'https://example.com'
101
+
102
+ return [
103
+ {
104
+ url: baseUrl,
105
+ lastModified: new Date(),
106
+ changeFrequency: 'weekly',
107
+ priority: 1,
108
+ },
109
+ // Add more pages here or generate dynamically:
110
+ //
111
+ // const posts = await getPosts()
112
+ // return posts.map(post => ({
113
+ // url: \`\${baseUrl}/blog/\${post.slug}\`,
114
+ // lastModified: post.updatedAt,
115
+ // changeFrequency: 'monthly',
116
+ // priority: 0.7,
117
+ // }))
118
+ ]
119
+ }
120
+ `;
121
+ }
122
+ return `/** @returns {import('next').MetadataRoute.Sitemap} */
123
+ export default function sitemap() {
124
+ const baseUrl = 'https://example.com'
125
+
126
+ return [
127
+ {
128
+ url: baseUrl,
129
+ lastModified: new Date(),
130
+ changeFrequency: 'weekly',
131
+ priority: 1,
132
+ },
133
+ ]
134
+ }
135
+ `;
136
+ }
137
+ function robotsTemplate(isTypeScript) {
138
+ if (isTypeScript) {
139
+ return `import type { MetadataRoute } from 'next'
140
+
141
+ export default function robots(): MetadataRoute.Robots {
142
+ const baseUrl = 'https://example.com'
143
+
144
+ return {
145
+ rules: [
146
+ {
147
+ userAgent: '*',
148
+ allow: '/',
149
+ disallow: ['/api/', '/private/'],
150
+ },
151
+ ],
152
+ sitemap: \`\${baseUrl}/sitemap.xml\`,
153
+ }
154
+ }
155
+ `;
156
+ }
157
+ return `/** @returns {import('next').MetadataRoute.Robots} */
158
+ export default function robots() {
159
+ const baseUrl = 'https://example.com'
160
+
161
+ return {
162
+ rules: [
163
+ {
164
+ userAgent: '*',
165
+ allow: '/',
166
+ disallow: ['/api/', '/private/'],
167
+ },
168
+ ],
169
+ sitemap: \`\${baseUrl}/sitemap.xml\`,
170
+ }
171
+ }
172
+ `;
173
+ }
174
+
175
+ // 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) => {
177
+ const cwd = opts.cwd;
178
+ const spinner = ora("Detecting project...").start();
179
+ const project = await detectProject(cwd);
180
+ if (!project.isNextJs) {
181
+ spinner.fail("Not a Next.js project");
182
+ console.log(
183
+ chalk.dim(" indxel currently supports Next.js projects only.")
184
+ );
185
+ console.log(
186
+ chalk.dim(" Make sure you're in a directory with a next.config file.")
187
+ );
188
+ process.exit(1);
189
+ }
190
+ spinner.succeed(
191
+ `Detected Next.js ${project.nextVersion ?? ""} (${project.usesAppRouter ? "App Router" : "Pages Router"})`
192
+ );
193
+ const ext = project.isTypeScript ? "ts" : "js";
194
+ const filesCreated = [];
195
+ if (!project.hasSeoConfig || opts.force) {
196
+ const configPath = join2(cwd, `seo.config.${ext}`);
197
+ await writeFile(configPath, seoConfigTemplate(project.isTypeScript), "utf-8");
198
+ filesCreated.push(`seo.config.${ext}`);
199
+ console.log(chalk.green(" \u2713") + ` Generated seo.config.${ext}`);
200
+ } else {
201
+ console.log(chalk.dim(` - seo.config.${ext} already exists (skip)`));
202
+ }
203
+ if (!project.hasSitemap || opts.force) {
204
+ const sitemapPath = join2(cwd, project.appDir, `sitemap.${ext}`);
205
+ await writeFile(sitemapPath, sitemapTemplate(project.isTypeScript), "utf-8");
206
+ filesCreated.push(`${project.appDir}/sitemap.${ext}`);
207
+ console.log(chalk.green(" \u2713") + ` Generated ${project.appDir}/sitemap.${ext}`);
208
+ } else {
209
+ console.log(chalk.dim(` - sitemap already exists (skip)`));
210
+ }
211
+ if (!project.hasRobots || opts.force) {
212
+ const robotsPath = join2(cwd, project.appDir, `robots.${ext}`);
213
+ await writeFile(robotsPath, robotsTemplate(project.isTypeScript), "utf-8");
214
+ filesCreated.push(`${project.appDir}/robots.${ext}`);
215
+ console.log(chalk.green(" \u2713") + ` Generated ${project.appDir}/robots.${ext}`);
216
+ } else {
217
+ console.log(chalk.dim(` - robots already exists (skip)`));
218
+ }
219
+ console.log("");
220
+ if (filesCreated.length > 0) {
221
+ console.log(
222
+ chalk.bold(` ${filesCreated.length} file${filesCreated.length > 1 ? "s" : ""} created.`)
223
+ );
224
+ } else {
225
+ console.log(chalk.dim(" Nothing to create \u2014 all files already exist."));
226
+ console.log(chalk.dim(" Use --force to overwrite."));
227
+ }
228
+ console.log("");
229
+ console.log(chalk.dim(" Next steps:"));
230
+ console.log(chalk.dim(` 1. Edit seo.config.${ext} with your site details`));
231
+ console.log(chalk.dim(" 2. Run ") + chalk.bold("npx indxel check") + chalk.dim(" to audit your pages"));
232
+ console.log("");
233
+ });
234
+
235
+ // src/commands/check.ts
236
+ import { Command as Command2 } from "commander";
237
+ import chalk3 from "chalk";
238
+ import ora2 from "ora";
239
+ import { validateMetadata } from "indxel";
240
+
241
+ // src/scanner.ts
242
+ import { readFile as readFile2 } from "fs/promises";
243
+ import { join as join3, dirname, sep } from "path";
244
+ import { glob } from "glob";
245
+ async function scanPages(projectRoot, appDir) {
246
+ const appDirFull = join3(projectRoot, appDir);
247
+ const pageFiles = await glob("**/page.{tsx,ts,jsx,js}", {
248
+ cwd: appDirFull,
249
+ ignore: ["**/node_modules/**", "**/_*/**"]
250
+ });
251
+ const pages = [];
252
+ for (const file of pageFiles) {
253
+ const fullPath = join3(appDirFull, file);
254
+ const content = await readFile2(fullPath, "utf-8");
255
+ const route = filePathToRoute(file);
256
+ const page = {
257
+ filePath: join3(appDir, file),
258
+ route,
259
+ hasMetadata: false,
260
+ hasDynamicMetadata: false,
261
+ extractedMetadata: createEmptyMetadata()
262
+ };
263
+ page.hasDynamicMetadata = hasExport(content, "generateMetadata");
264
+ page.hasMetadata = page.hasDynamicMetadata || hasExport(content, "metadata");
265
+ page.extractedMetadata = extractStaticMetadata(content);
266
+ pages.push(page);
267
+ }
268
+ const layoutFiles = await glob("**/layout.{tsx,ts,jsx,js}", {
269
+ cwd: appDirFull,
270
+ ignore: ["**/node_modules/**", "**/_*/**"]
271
+ });
272
+ for (const file of layoutFiles) {
273
+ const fullPath = join3(appDirFull, file);
274
+ const content = await readFile2(fullPath, "utf-8");
275
+ const route = filePathToRoute(file).replace(/\/layout$/, "") || "/";
276
+ const hasMetadataExport = hasExport(content, "metadata") || hasExport(content, "generateMetadata");
277
+ if (hasMetadataExport) {
278
+ const layoutMeta = extractStaticMetadata(content);
279
+ for (const page of pages) {
280
+ if (page.route.startsWith(route) || route === "/") {
281
+ mergeMetadata(page.extractedMetadata, layoutMeta);
282
+ if (!page.hasMetadata) {
283
+ page.hasMetadata = true;
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+ return pages.sort((a, b) => a.route.localeCompare(b.route));
290
+ }
291
+ function filePathToRoute(filePath) {
292
+ const dir = dirname(filePath);
293
+ if (dir === ".") return "/";
294
+ const route = "/" + dir.split(sep).join("/");
295
+ return route.replace(/\/\([^)]+\)/g, "") || "/";
296
+ }
297
+ function hasExport(source, name) {
298
+ const patterns = [
299
+ new RegExp(`export\\s+(const|let|var)\\s+${name}\\b`),
300
+ new RegExp(`export\\s+(async\\s+)?function\\s+${name}\\b`),
301
+ new RegExp(`export\\s+\\{[^}]*\\b${name}\\b[^}]*\\}`)
302
+ ];
303
+ return patterns.some((p) => p.test(source));
304
+ }
305
+ function extractStaticMetadata(source) {
306
+ const meta = createEmptyMetadata();
307
+ const titleMatch = source.match(
308
+ /title\s*:\s*(?:["'`]([^"'`]+)["'`]|`([^`]*)`)/
309
+ );
310
+ if (titleMatch) {
311
+ meta.title = titleMatch[1] ?? titleMatch[2] ?? null;
312
+ }
313
+ const descMatch = source.match(
314
+ /description\s*:\s*(?:["'`]([^"'`]+)["'`]|`([^`]*)`)/
315
+ );
316
+ if (descMatch) {
317
+ meta.description = descMatch[1] ?? descMatch[2] ?? null;
318
+ }
319
+ if (/openGraph\s*:\s*\{/.test(source)) {
320
+ const ogTitleMatch = source.match(
321
+ /openGraph\s*:\s*\{[^}]*title\s*:\s*["'`]([^"'`]+)["'`]/s
322
+ );
323
+ if (ogTitleMatch) meta.ogTitle = ogTitleMatch[1];
324
+ const ogDescMatch = source.match(
325
+ /openGraph\s*:\s*\{[^}]*description\s*:\s*["'`]([^"'`]+)["'`]/s
326
+ );
327
+ if (ogDescMatch) meta.ogDescription = ogDescMatch[1];
328
+ if (/images\s*:\s*\[/.test(source)) {
329
+ meta.ogImage = "[detected]";
330
+ }
331
+ }
332
+ if (/twitter\s*:\s*\{/.test(source)) {
333
+ const cardMatch = source.match(
334
+ /card\s*:\s*["'`](summary|summary_large_image)["'`]/
335
+ );
336
+ if (cardMatch) meta.twitterCard = cardMatch[1];
337
+ }
338
+ if (/robots\s*:\s*\{/.test(source) || /robots\s*:\s*["'`]/.test(source)) {
339
+ const robotsMatch = source.match(
340
+ /robots\s*:\s*["'`]([^"'`]+)["'`]/
341
+ );
342
+ if (robotsMatch) meta.robots = robotsMatch[1];
343
+ }
344
+ if (/alternates\s*:\s*\{/.test(source)) {
345
+ const canonicalMatch = source.match(
346
+ /canonical\s*:\s*["'`]([^"'`]+)["'`]/
347
+ );
348
+ if (canonicalMatch) meta.canonical = canonicalMatch[1];
349
+ }
350
+ if (/application\/ld\+json/.test(source) || /generateLD/.test(source) || /JsonLD/.test(source)) {
351
+ meta.structuredData = [{ "@context": "https://schema.org", "@type": "detected" }];
352
+ }
353
+ if (/viewport\s*[:=]/.test(source)) {
354
+ meta.viewport = "detected";
355
+ }
356
+ if (/icons\s*:\s*\{/.test(source) || /favicon/.test(source)) {
357
+ meta.favicon = "detected";
358
+ }
359
+ return meta;
360
+ }
361
+ function createEmptyMetadata() {
362
+ return {
363
+ title: null,
364
+ description: null,
365
+ canonical: null,
366
+ ogTitle: null,
367
+ ogDescription: null,
368
+ ogImage: null,
369
+ ogType: null,
370
+ twitterCard: null,
371
+ twitterTitle: null,
372
+ twitterDescription: null,
373
+ robots: null,
374
+ alternates: null,
375
+ structuredData: null,
376
+ viewport: null,
377
+ favicon: null
378
+ };
379
+ }
380
+ function mergeMetadata(target, source) {
381
+ for (const key of Object.keys(source)) {
382
+ if (target[key] === null || target[key] === void 0) {
383
+ target[key] = source[key];
384
+ }
385
+ }
386
+ }
387
+
388
+ // src/formatter.ts
389
+ import chalk2 from "chalk";
390
+ function formatPageResult(result) {
391
+ const { page, validation } = result;
392
+ const lines = [];
393
+ const scoreColor = getScoreColor(validation.score);
394
+ const icon = validation.errors.length > 0 ? chalk2.red("x") : chalk2.green("\u2713");
395
+ lines.push(
396
+ ` ${icon} ${chalk2.bold(page.route)} ${scoreColor(`${validation.score}/100`)}`
397
+ );
398
+ for (const error of validation.errors) {
399
+ lines.push(` ${chalk2.red("x")} ${error.message ?? error.name}`);
400
+ }
401
+ if (validation.errors.length > 0) {
402
+ for (const warning of validation.warnings) {
403
+ lines.push(` ${chalk2.yellow("!")} ${warning.message ?? warning.name}`);
404
+ }
405
+ }
406
+ return lines.join("\n");
407
+ }
408
+ function formatSummary(summary) {
409
+ const lines = [];
410
+ const { totalPages, passedPages, averageScore, criticalErrors } = summary;
411
+ lines.push("");
412
+ 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
+ lines.push("");
414
+ const scoreColor = getScoreColor(averageScore);
415
+ lines.push(
416
+ ` Score: ${scoreColor(chalk2.bold(`${averageScore}/100`))} (${summary.grade})`
417
+ );
418
+ const pagesColor = passedPages === totalPages ? chalk2.green : chalk2.yellow;
419
+ lines.push(
420
+ ` Pages: ${pagesColor(`${passedPages}/${totalPages}`)} pass SEO validation`
421
+ );
422
+ if (criticalErrors > 0) {
423
+ lines.push("");
424
+ lines.push(
425
+ chalk2.red(` ${criticalErrors} critical issue${criticalErrors > 1 ? "s" : ""}. Fix before deploying.`)
426
+ );
427
+ } else {
428
+ lines.push("");
429
+ lines.push(chalk2.green(" All pages pass. Ship it."));
430
+ }
431
+ lines.push("");
432
+ return lines.join("\n");
433
+ }
434
+ function formatJSON(summary) {
435
+ return JSON.stringify(
436
+ {
437
+ score: summary.averageScore,
438
+ grade: summary.grade,
439
+ totalPages: summary.totalPages,
440
+ passedPages: summary.passedPages,
441
+ criticalErrors: summary.criticalErrors,
442
+ pages: summary.results.map((r) => ({
443
+ route: r.page.route,
444
+ file: r.page.filePath,
445
+ score: r.validation.score,
446
+ grade: r.validation.grade,
447
+ errors: r.validation.errors.map((e) => ({
448
+ id: e.id,
449
+ message: e.message
450
+ })),
451
+ warnings: r.validation.warnings.map((w) => ({
452
+ id: w.id,
453
+ message: w.message
454
+ }))
455
+ }))
456
+ },
457
+ null,
458
+ 2
459
+ );
460
+ }
461
+ function formatDiff(current, previous) {
462
+ const lines = [];
463
+ lines.push("");
464
+ lines.push(chalk2.bold(" SEO Diff:"));
465
+ lines.push("");
466
+ const scoreDelta = current.averageScore - previous.averageScore;
467
+ const scoreArrow = scoreDelta > 0 ? chalk2.green(`+${scoreDelta}`) : scoreDelta < 0 ? chalk2.red(`${scoreDelta}`) : chalk2.dim("0");
468
+ lines.push(
469
+ ` Score: ${previous.averageScore} -> ${current.averageScore} (${scoreArrow})`
470
+ );
471
+ lines.push("");
472
+ const prevMap = new Map(previous.results.map((r) => [r.page.route, r]));
473
+ const currMap = new Map(current.results.map((r) => [r.page.route, r]));
474
+ const regressions = [];
475
+ for (const [route, curr] of currMap) {
476
+ const prev = prevMap.get(route);
477
+ if (!prev) continue;
478
+ if (curr.validation.score < prev.validation.score) {
479
+ regressions.push(
480
+ ` ${chalk2.red("-")} ${route} ${prev.validation.score} -> ${curr.validation.score}`
481
+ );
482
+ }
483
+ }
484
+ const improvements = [];
485
+ for (const [route, curr] of currMap) {
486
+ const prev = prevMap.get(route);
487
+ if (!prev) continue;
488
+ if (curr.validation.score > prev.validation.score) {
489
+ improvements.push(
490
+ ` ${chalk2.green("+")} ${route} ${prev.validation.score} -> ${curr.validation.score}`
491
+ );
492
+ }
493
+ }
494
+ const newPages = [];
495
+ for (const route of currMap.keys()) {
496
+ if (!prevMap.has(route)) {
497
+ newPages.push(` ${chalk2.blue("+")} ${route} ${chalk2.dim("[new]")}`);
498
+ }
499
+ }
500
+ const removed = [];
501
+ for (const route of prevMap.keys()) {
502
+ if (!currMap.has(route)) {
503
+ removed.push(` ${chalk2.dim("-")} ${route} ${chalk2.dim("[removed]")}`);
504
+ }
505
+ }
506
+ if (regressions.length > 0) {
507
+ lines.push(chalk2.red(` REGRESSIONS (${regressions.length}):`));
508
+ lines.push(...regressions);
509
+ lines.push("");
510
+ }
511
+ if (improvements.length > 0) {
512
+ lines.push(chalk2.green(` IMPROVEMENTS (${improvements.length}):`));
513
+ lines.push(...improvements);
514
+ lines.push("");
515
+ }
516
+ if (newPages.length > 0) {
517
+ lines.push(chalk2.blue(` NEW PAGES (${newPages.length}):`));
518
+ lines.push(...newPages);
519
+ lines.push("");
520
+ }
521
+ if (removed.length > 0) {
522
+ lines.push(chalk2.dim(` REMOVED (${removed.length}):`));
523
+ lines.push(...removed);
524
+ lines.push("");
525
+ }
526
+ if (regressions.length === 0 && improvements.length === 0 && newPages.length === 0 && removed.length === 0) {
527
+ lines.push(chalk2.dim(" No changes detected."));
528
+ lines.push("");
529
+ }
530
+ return lines.join("\n");
531
+ }
532
+ function getScoreColor(score) {
533
+ if (score >= 90) return chalk2.green;
534
+ if (score >= 70) return chalk2.yellow;
535
+ return chalk2.red;
536
+ }
537
+ function computeSummary(results) {
538
+ const totalPages = results.length;
539
+ 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);
542
+ let grade;
543
+ if (averageScore >= 90) grade = "A";
544
+ else if (averageScore >= 80) grade = "B";
545
+ else if (averageScore >= 70) grade = "C";
546
+ else if (averageScore >= 60) grade = "D";
547
+ else grade = "F";
548
+ return { results, totalPages, passedPages, averageScore, grade, criticalErrors };
549
+ }
550
+
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
+ }))
577
+ }
578
+ };
579
+ await writeFile2(
580
+ join4(storeDir, LAST_CHECK_FILE),
581
+ JSON.stringify(stored, null, 2),
582
+ "utf-8"
583
+ );
584
+ }
585
+ async function loadPreviousCheck(cwd) {
586
+ const filePath = join4(cwd, STORE_DIR, LAST_CHECK_FILE);
587
+ if (!existsSync2(filePath)) {
588
+ return null;
589
+ }
590
+ try {
591
+ const data = await readFile3(filePath, "utf-8");
592
+ return JSON.parse(data);
593
+ } catch {
594
+ return null;
595
+ }
596
+ }
597
+
598
+ // 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) => {
600
+ const cwd = opts.cwd;
601
+ const isCI = opts.ci;
602
+ const isStrict = opts.strict || isCI;
603
+ const showDiff = opts.diff;
604
+ const jsonOutput = opts.json;
605
+ const spinner = ora2("Detecting project...").start();
606
+ const project = await detectProject(cwd);
607
+ if (!project.isNextJs) {
608
+ spinner.fail("Not a Next.js project");
609
+ if (!jsonOutput) {
610
+ console.log(chalk3.dim(" Run this command from a Next.js project root."));
611
+ }
612
+ process.exit(1);
613
+ }
614
+ if (!project.usesAppRouter) {
615
+ spinner.fail("App Router not detected");
616
+ if (!jsonOutput) {
617
+ console.log(chalk3.dim(" indxel requires Next.js App Router (src/app or app directory)."));
618
+ }
619
+ process.exit(1);
620
+ }
621
+ spinner.text = "Scanning pages...";
622
+ const pages = await scanPages(cwd, project.appDir);
623
+ if (pages.length === 0) {
624
+ spinner.fail("No pages found");
625
+ if (!jsonOutput) {
626
+ console.log(chalk3.dim(` No page.tsx/ts files found in ${project.appDir}/`));
627
+ }
628
+ process.exit(1);
629
+ }
630
+ spinner.succeed(`Found ${pages.length} page${pages.length > 1 ? "s" : ""}`);
631
+ if (!jsonOutput) {
632
+ console.log("");
633
+ console.log(chalk3.bold(` Checking ${pages.length} pages...`));
634
+ console.log("");
635
+ }
636
+ const results = [];
637
+ for (const page of pages) {
638
+ const validation = validateMetadata(page.extractedMetadata, {
639
+ strict: isStrict
640
+ });
641
+ const result = { page, validation };
642
+ results.push(result);
643
+ if (!jsonOutput) {
644
+ console.log(formatPageResult(result));
645
+ }
646
+ }
647
+ const summary = computeSummary(results);
648
+ await saveCheckResult(cwd, summary);
649
+ if (showDiff && !jsonOutput) {
650
+ const previous = await loadPreviousCheck(cwd);
651
+ if (previous) {
652
+ console.log(formatDiff(summary, previous.summary));
653
+ } else {
654
+ console.log(chalk3.dim("\n No previous check found. Run again to see a diff.\n"));
655
+ }
656
+ }
657
+ if (jsonOutput) {
658
+ console.log(formatJSON(summary));
659
+ } else {
660
+ console.log(formatSummary(summary));
661
+ }
662
+ if (summary.criticalErrors > 0) {
663
+ process.exit(1);
664
+ }
665
+ });
666
+
667
+ // src/commands/crawl.ts
668
+ import { Command as Command3 } from "commander";
669
+ import chalk4 from "chalk";
670
+ import ora3 from "ora";
671
+ import {
672
+ crawlSite,
673
+ fetchSitemap,
674
+ compareSitemap,
675
+ fetchRobots,
676
+ checkUrlsAgainstRobots,
677
+ verifyAssets
678
+ } 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) => {
680
+ const jsonOutput = opts.json;
681
+ const maxPages = parseInt(opts.maxPages, 10);
682
+ const maxDepth = parseInt(opts.maxDepth, 10);
683
+ const delay = parseInt(opts.delay, 10);
684
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
685
+ url = `https://${url}`;
686
+ }
687
+ if (!jsonOutput) {
688
+ console.log("");
689
+ console.log(chalk4.bold(` indxel crawl`) + chalk4.dim(` \u2014 ${url}`));
690
+ console.log("");
691
+ }
692
+ let robotsResult = null;
693
+ if (!opts.skipRobots) {
694
+ const robotsSpinner = jsonOutput ? null : ora3("Checking robots.txt...").start();
695
+ robotsResult = await fetchRobots(url);
696
+ if (!jsonOutput) {
697
+ if (robotsResult.found) {
698
+ robotsSpinner.succeed("robots.txt found");
699
+ for (const w of robotsResult.warnings) {
700
+ console.log(chalk4.yellow(` \u26A0 ${w}`));
701
+ }
702
+ if (robotsResult.sitemapUrls.length > 0) {
703
+ console.log(chalk4.dim(` Sitemap references: ${robotsResult.sitemapUrls.join(", ")}`));
704
+ }
705
+ } else {
706
+ robotsSpinner.warn("robots.txt not found");
707
+ for (const e of robotsResult.errors) {
708
+ console.log(chalk4.dim(` ${e}`));
709
+ }
710
+ }
711
+ console.log("");
712
+ }
713
+ }
714
+ const crawlSpinner = jsonOutput ? null : ora3("Crawling...").start();
715
+ let crawledCount = 0;
716
+ const ignorePatterns = opts.ignore ? opts.ignore.split(",").map((p) => p.trim()).filter(Boolean) : [];
717
+ const crawlResult = await crawlSite(url, {
718
+ maxPages,
719
+ maxDepth,
720
+ delay,
721
+ strict: opts.strict,
722
+ ignorePatterns,
723
+ onPageCrawled: (page) => {
724
+ crawledCount++;
725
+ if (crawlSpinner) {
726
+ crawlSpinner.text = `Crawling... ${crawledCount} pages (current: ${page.url})`;
727
+ }
728
+ }
729
+ });
730
+ if (!jsonOutput) {
731
+ crawlSpinner.succeed(`Crawled ${crawlResult.totalPages} pages in ${(crawlResult.durationMs / 1e3).toFixed(1)}s`);
732
+ console.log("");
733
+ for (const page of crawlResult.pages) {
734
+ if (page.error) {
735
+ console.log(chalk4.red(` \u2717 ${page.url}`) + chalk4.dim(` \u2014 ${page.error}`));
736
+ continue;
737
+ }
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");
740
+ console.log(
741
+ ` ${icon} ${page.url} ${scoreColor(`${page.validation.score}/100`)}`
742
+ );
743
+ for (const error of page.validation.errors) {
744
+ console.log(chalk4.red(` \u2717 ${error.message ?? error.description}`));
745
+ }
746
+ for (const warning of page.validation.warnings) {
747
+ console.log(chalk4.yellow(` \u26A0 ${warning.message ?? warning.description}`));
748
+ }
749
+ }
750
+ console.log("");
751
+ }
752
+ let sitemapComparison = null;
753
+ if (!opts.skipSitemap) {
754
+ const sitemapSpinner = jsonOutput ? null : ora3("Checking sitemap.xml...").start();
755
+ const sitemapResult = await fetchSitemap(url);
756
+ if (!jsonOutput) {
757
+ if (sitemapResult.found) {
758
+ sitemapSpinner.succeed(`sitemap.xml found \u2014 ${sitemapResult.urls.length} URLs`);
759
+ const crawledUrls = crawlResult.pages.filter((p) => !p.error).map((p) => p.url);
760
+ sitemapComparison = compareSitemap(
761
+ sitemapResult.urls.map((u) => u.loc),
762
+ crawledUrls
763
+ );
764
+ if (sitemapComparison.inCrawlOnly.length > 0) {
765
+ console.log(chalk4.yellow(` \u26A0 ${sitemapComparison.inCrawlOnly.length} crawled pages missing from sitemap:`));
766
+ for (const u of sitemapComparison.inCrawlOnly.slice(0, 10)) {
767
+ console.log(chalk4.dim(` - ${u}`));
768
+ }
769
+ }
770
+ if (sitemapComparison.inSitemapOnly.length > 0) {
771
+ console.log(chalk4.yellow(` \u26A0 ${sitemapComparison.inSitemapOnly.length} sitemap URLs not reachable:`));
772
+ for (const u of sitemapComparison.inSitemapOnly.slice(0, 10)) {
773
+ console.log(chalk4.dim(` - ${u}`));
774
+ }
775
+ }
776
+ if (sitemapComparison.issues.length === 0) {
777
+ console.log(chalk4.green(` \u2713 Sitemap matches crawled pages`));
778
+ }
779
+ } else {
780
+ sitemapSpinner.warn("sitemap.xml not found");
781
+ for (const e of sitemapResult.errors) {
782
+ console.log(chalk4.dim(` ${e}`));
783
+ }
784
+ }
785
+ console.log("");
786
+ }
787
+ }
788
+ let robotsBlockedPages = null;
789
+ if (robotsResult?.found && robotsResult.directives.length > 0) {
790
+ const crawledUrls = crawlResult.pages.filter((p) => !p.error).map((p) => p.url);
791
+ robotsBlockedPages = checkUrlsAgainstRobots(
792
+ robotsResult.directives,
793
+ crawledUrls
794
+ );
795
+ const blocked = robotsBlockedPages.filter((c) => c.blocked);
796
+ if (!jsonOutput && blocked.length > 0) {
797
+ console.log(chalk4.yellow(` \u26A0 ${blocked.length} crawled pages are blocked by robots.txt:`));
798
+ for (const b of blocked) {
799
+ console.log(chalk4.dim(` - ${b.path} (${b.blockedBy})`));
800
+ }
801
+ console.log("");
802
+ }
803
+ }
804
+ let assetResult = null;
805
+ if (!opts.skipAssets) {
806
+ const assetSpinner = jsonOutput ? null : ora3("Verifying assets (og:image, favicon, ...)...").start();
807
+ const pagesForAssetCheck = crawlResult.pages.filter((p) => !p.error).map((p) => ({ url: p.url, metadata: p.metadata }));
808
+ assetResult = await verifyAssets(pagesForAssetCheck);
809
+ if (!jsonOutput) {
810
+ assetSpinner.succeed(`Verified ${assetResult.totalChecked} assets`);
811
+ const brokenAssets = assetResult.checks.filter((c) => !c.ok);
812
+ const warningAssets = assetResult.checks.filter((c) => c.warning);
813
+ for (const asset of brokenAssets) {
814
+ console.log(
815
+ chalk4.red(` \u2717 ${asset.type}`) + chalk4.dim(` ${asset.url}`) + chalk4.red(` \u2014 ${asset.error ?? `HTTP ${asset.status}`}`)
816
+ );
817
+ }
818
+ for (const asset of warningAssets) {
819
+ console.log(
820
+ chalk4.yellow(` \u26A0 ${asset.type}`) + chalk4.dim(` ${asset.url}`) + chalk4.yellow(` \u2014 ${asset.warning}`)
821
+ );
822
+ }
823
+ if (brokenAssets.length === 0 && warningAssets.length === 0) {
824
+ console.log(chalk4.green(` \u2713 All assets respond correctly`));
825
+ }
826
+ console.log("");
827
+ }
828
+ }
829
+ if (!jsonOutput) {
830
+ const a = crawlResult.analysis;
831
+ if (a.duplicateTitles.length > 0) {
832
+ console.log(chalk4.bold("- Duplicate titles"));
833
+ 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`));
837
+ }
838
+ if (a.duplicateTitles.length > 5) console.log(chalk4.dim(` ...and ${a.duplicateTitles.length - 5} more groups`));
839
+ console.log("");
840
+ }
841
+ if (a.duplicateDescriptions.length > 0) {
842
+ console.log(chalk4.bold("- Duplicate descriptions"));
843
+ for (const dup of a.duplicateDescriptions.slice(0, 5)) {
844
+ 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`));
848
+ }
849
+ if (a.duplicateDescriptions.length > 5) console.log(chalk4.dim(` ...and ${a.duplicateDescriptions.length - 5} more groups`));
850
+ console.log("");
851
+ }
852
+ if (a.h1Issues.length > 0) {
853
+ const missing = a.h1Issues.filter((h) => h.issue === "missing");
854
+ const multiple = a.h1Issues.filter((h) => h.issue === "multiple");
855
+ console.log(chalk4.bold("- H1 heading issues"));
856
+ 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`));
860
+ }
861
+ 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`));
865
+ }
866
+ console.log("");
867
+ }
868
+ if (a.brokenInternalLinks.length > 0) {
869
+ console.log(chalk4.bold("- Broken internal links"));
870
+ 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})`));
872
+ }
873
+ if (a.brokenInternalLinks.length > 10) console.log(chalk4.dim(` ...and ${a.brokenInternalLinks.length - 10} more`));
874
+ console.log("");
875
+ }
876
+ if (a.redirects.length > 0) {
877
+ console.log(chalk4.bold("- Redirect chains"));
878
+ 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}`));
881
+ }
882
+ if (a.redirects.length > 10) console.log(chalk4.dim(` ...and ${a.redirects.length - 10} more`));
883
+ console.log("");
884
+ }
885
+ if (a.thinContentPages.length > 0) {
886
+ const realThin = a.thinContentPages.filter((tc) => !tc.isAppPage);
887
+ const appThin = a.thinContentPages.filter((tc) => tc.isAppPage);
888
+ if (realThin.length > 0) {
889
+ console.log(chalk4.bold("- Thin content") + chalk4.dim(" (< 200 words)"));
890
+ for (const tc of realThin.slice(0, 10)) {
891
+ console.log(chalk4.yellow(` \u26A0 ${tc.url}`) + chalk4.dim(` \u2014 ${tc.wordCount} words`));
892
+ }
893
+ if (realThin.length > 10) console.log(chalk4.dim(` ...and ${realThin.length - 10} more`));
894
+ console.log("");
895
+ }
896
+ if (appThin.length > 0) {
897
+ console.log(chalk4.bold("- App/wizard pages") + chalk4.dim(" (client-rendered, low word count expected)"));
898
+ for (const tc of appThin.slice(0, 5)) {
899
+ console.log(chalk4.dim(` \u2139 ${tc.url} \u2014 ${tc.wordCount} words`));
900
+ }
901
+ if (appThin.length > 5) console.log(chalk4.dim(` ...and ${appThin.length - 5} more`));
902
+ console.log("");
903
+ }
904
+ }
905
+ 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`));
909
+ console.log("");
910
+ }
911
+ if (a.slowestPages.length > 0 && a.slowestPages[0].responseTimeMs > 1e3) {
912
+ console.log(chalk4.bold("- Slowest pages"));
913
+ 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`));
916
+ }
917
+ console.log("");
918
+ }
919
+ if (a.structuredDataSummary.length > 0) {
920
+ console.log(chalk4.bold("- Structured data (JSON-LD)"));
921
+ for (const sd of a.structuredDataSummary) {
922
+ console.log(chalk4.green(` \u2713 ${sd.type}`) + chalk4.dim(` \u2014 ${sd.count} page${sd.count > 1 ? "s" : ""}`));
923
+ }
924
+ const pagesWithSD = crawlResult.pages.filter((p) => !p.error && p.structuredDataTypes.length > 0).length;
925
+ const pagesWithout = crawlResult.pages.filter((p) => !p.error).length - pagesWithSD;
926
+ if (pagesWithout > 0) {
927
+ console.log(chalk4.yellow(` \u26A0 ${pagesWithout} pages without any structured data`));
928
+ }
929
+ console.log("");
930
+ } else {
931
+ console.log(chalk4.bold("- Structured data (JSON-LD)"));
932
+ console.log(chalk4.red(` \u2717 No structured data found on any page`));
933
+ console.log("");
934
+ }
935
+ }
936
+ if (jsonOutput) {
937
+ console.log(
938
+ JSON.stringify(
939
+ {
940
+ crawl: crawlResult,
941
+ robots: robotsResult,
942
+ sitemap: sitemapComparison,
943
+ assets: assetResult
944
+ },
945
+ null,
946
+ 2
947
+ )
948
+ );
949
+ } 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"));
952
+ 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")}`);
957
+ if (assetResult) {
958
+ console.log(` Broken assets: ${assetResult.totalBroken > 0 ? chalk4.red(String(assetResult.totalBroken)) : chalk4.green("0")}`);
959
+ }
960
+ if (crawlResult.skippedUrls.length > 0) {
961
+ console.log(chalk4.dim(` Skipped: ${crawlResult.skippedUrls.length} URLs (over limit)`));
962
+ }
963
+ console.log("");
964
+ }
965
+ if (opts.push) {
966
+ const apiKey = opts.apiKey || process.env.INDXEL_API_KEY;
967
+ if (!apiKey) {
968
+ 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"));
971
+ console.log("");
972
+ }
973
+ } else {
974
+ const pushSpinner = jsonOutput ? null : ora3("Pushing results to Indxel...").start();
975
+ try {
976
+ const pushUrl = process.env.INDXEL_API_URL || "https://www.indxel.com";
977
+ const res = await fetch(`${pushUrl}/api/cli/push`, {
978
+ method: "POST",
979
+ headers: {
980
+ "Content-Type": "application/json",
981
+ Authorization: `Bearer ${apiKey}`
982
+ },
983
+ body: JSON.stringify({
984
+ crawl: crawlResult,
985
+ robots: robotsResult,
986
+ sitemap: sitemapComparison,
987
+ assets: assetResult
988
+ })
989
+ });
990
+ if (res.ok) {
991
+ const data = await res.json();
992
+ if (pushSpinner) pushSpinner.succeed(`Pushed to dashboard \u2014 check ${data.checkId}`);
993
+ } else {
994
+ const data = await res.json().catch(() => ({}));
995
+ if (pushSpinner) pushSpinner.fail(`Push failed: ${data.error || res.statusText}`);
996
+ }
997
+ } catch (err) {
998
+ if (pushSpinner) pushSpinner.fail(`Push failed: ${err instanceof Error ? err.message : String(err)}`);
999
+ }
1000
+ if (!jsonOutput) console.log("");
1001
+ }
1002
+ }
1003
+ if (crawlResult.totalErrors > 0) {
1004
+ process.exit(1);
1005
+ }
1006
+ });
1007
+
1008
+ // src/commands/keywords.ts
1009
+ import { Command as Command4 } from "commander";
1010
+ import chalk5 from "chalk";
1011
+ import ora4 from "ora";
1012
+ import { researchKeywords, crawlSite as crawlSite2, analyzeContentGaps } from "indxel";
1013
+ 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
+ const jsonOutput = opts.json;
1015
+ if (!jsonOutput) {
1016
+ console.log("");
1017
+ console.log(chalk5.bold(` indxel keywords`) + chalk5.dim(` \u2014 "${seed}"`));
1018
+ console.log("");
1019
+ }
1020
+ const kwSpinner = jsonOutput ? null : ora4("Researching keywords...").start();
1021
+ const kwResult = await researchKeywords(seed, {
1022
+ locale: opts.locale,
1023
+ country: opts.country
1024
+ });
1025
+ if (!jsonOutput) {
1026
+ kwSpinner.succeed(`Found ${kwResult.totalKeywords} keywords`);
1027
+ console.log("");
1028
+ if (kwResult.suggestions.length > 0) {
1029
+ console.log(chalk5.bold(` Direct suggestions (${kwResult.suggestions.length})`));
1030
+ for (const s of kwResult.suggestions) {
1031
+ console.log(` ${chalk5.hex("#F4A261")(s.keyword)}`);
1032
+ }
1033
+ console.log("");
1034
+ }
1035
+ if (kwResult.questions.length > 0) {
1036
+ console.log(chalk5.bold(` Questions (${kwResult.questions.length})`));
1037
+ for (const q of kwResult.questions.slice(0, 20)) {
1038
+ console.log(` ${chalk5.cyan("?")} ${q.keyword}`);
1039
+ }
1040
+ console.log("");
1041
+ }
1042
+ if (kwResult.longTail.length > 0) {
1043
+ console.log(chalk5.bold(` Long-tail (${kwResult.longTail.length})`));
1044
+ for (const lt of kwResult.longTail.slice(0, 20)) {
1045
+ console.log(chalk5.dim(` ${lt.keyword}`));
1046
+ }
1047
+ if (kwResult.longTail.length > 20) {
1048
+ console.log(chalk5.dim(` ... and ${kwResult.longTail.length - 20} more`));
1049
+ }
1050
+ console.log("");
1051
+ }
1052
+ }
1053
+ let gapResult = null;
1054
+ if (opts.site) {
1055
+ let siteUrl = opts.site;
1056
+ if (!siteUrl.startsWith("http://") && !siteUrl.startsWith("https://")) {
1057
+ siteUrl = `https://${siteUrl}`;
1058
+ }
1059
+ const crawlSpinner = jsonOutput ? null : ora4(`Crawling ${siteUrl} for gap analysis...`).start();
1060
+ const crawlResult = await crawlSite2(siteUrl, {
1061
+ maxPages: parseInt(opts.maxPages, 10),
1062
+ delay: 200
1063
+ });
1064
+ if (!jsonOutput) {
1065
+ crawlSpinner.succeed(`Crawled ${crawlResult.totalPages} pages`);
1066
+ }
1067
+ const allKeywords = [
1068
+ ...kwResult.suggestions,
1069
+ ...kwResult.questions,
1070
+ ...kwResult.longTail
1071
+ ];
1072
+ const existingPages = crawlResult.pages.filter((p) => !p.error).map((p) => ({ url: p.url, metadata: p.metadata }));
1073
+ gapResult = analyzeContentGaps(allKeywords, existingPages);
1074
+ if (!jsonOutput) {
1075
+ console.log("");
1076
+ console.log(
1077
+ chalk5.bold(` Content coverage: `) + `${gapResult.totalCovered}/${gapResult.totalKeywords} keywords (${gapResult.coveragePercent}%)`
1078
+ );
1079
+ console.log("");
1080
+ if (gapResult.gaps.length > 0) {
1081
+ const highGaps = gapResult.gaps.filter((g) => g.relevance === "high");
1082
+ const medGaps = gapResult.gaps.filter((g) => g.relevance === "medium");
1083
+ if (highGaps.length > 0) {
1084
+ console.log(chalk5.bold.red(` High priority gaps (${highGaps.length})`));
1085
+ for (const gap of highGaps.slice(0, 15)) {
1086
+ console.log(
1087
+ chalk5.red(` \u2717 `) + `"${gap.keyword}" \u2192 ` + chalk5.dim(`${gap.suggestedType} at ${gap.suggestedPath}`)
1088
+ );
1089
+ }
1090
+ console.log("");
1091
+ }
1092
+ if (medGaps.length > 0) {
1093
+ console.log(chalk5.bold.yellow(` Medium priority gaps (${medGaps.length})`));
1094
+ for (const gap of medGaps.slice(0, 10)) {
1095
+ console.log(
1096
+ chalk5.yellow(` \u26A0 `) + `"${gap.keyword}" \u2192 ` + chalk5.dim(`${gap.suggestedType} at ${gap.suggestedPath}`)
1097
+ );
1098
+ }
1099
+ console.log("");
1100
+ }
1101
+ }
1102
+ if (gapResult.gaps.length === 0) {
1103
+ console.log(chalk5.green(` \u2713 All keyword opportunities are covered`));
1104
+ console.log("");
1105
+ }
1106
+ }
1107
+ }
1108
+ if (jsonOutput) {
1109
+ console.log(
1110
+ JSON.stringify(
1111
+ { keywords: kwResult, contentGaps: gapResult },
1112
+ null,
1113
+ 2
1114
+ )
1115
+ );
1116
+ }
1117
+ });
1118
+
1119
+ // src/index.ts
1120
+ function createProgram() {
1121
+ const program = new Command5();
1122
+ program.name("indxel").description("Infrastructure SEO developer-first. ESLint pour le SEO.").version("0.1.0");
1123
+ program.addCommand(initCommand);
1124
+ program.addCommand(checkCommand);
1125
+ program.addCommand(crawlCommand);
1126
+ program.addCommand(keywordsCommand);
1127
+ return program;
1128
+ }
1129
+ export {
1130
+ checkCommand,
1131
+ crawlCommand,
1132
+ createProgram,
1133
+ initCommand,
1134
+ keywordsCommand
1135
+ };
1136
+ //# sourceMappingURL=index.js.map