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/README.md +145 -76
- package/dist/bin.js +1309 -196
- package/dist/bin.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +1313 -197
- package/dist/index.js.map +1 -1
- package/package.json +12 -7
package/dist/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { Command as
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
|
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 =
|
|
197
|
-
await
|
|
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 =
|
|
205
|
-
await
|
|
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 =
|
|
213
|
-
await
|
|
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
|
|
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
|
|
243
|
-
import { join as
|
|
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 =
|
|
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 =
|
|
254
|
-
const content = await
|
|
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:
|
|
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.
|
|
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
|
-
|
|
273
|
-
const
|
|
274
|
-
const
|
|
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
|
|
308
|
-
|
|
513
|
+
const metaBlock = findMetadataBlock(source) ?? source;
|
|
514
|
+
const absoluteMatch = metaBlock.match(
|
|
515
|
+
/absolute\s*:\s*["'`]([^"'`]+)["'`]/
|
|
309
516
|
);
|
|
310
|
-
if (
|
|
311
|
-
meta.title =
|
|
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 =
|
|
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]
|
|
538
|
+
meta.description = descMatch[1];
|
|
318
539
|
}
|
|
319
|
-
if (/openGraph\s*:\s*\{/.test(
|
|
320
|
-
const ogTitleMatch =
|
|
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 =
|
|
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(
|
|
549
|
+
if (/images\s*:\s*\[/.test(metaBlock)) {
|
|
329
550
|
meta.ogImage = "[detected]";
|
|
330
551
|
}
|
|
331
552
|
}
|
|
332
|
-
if (/twitter\s*:\s*\{/.test(
|
|
333
|
-
const cardMatch =
|
|
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(
|
|
339
|
-
const robotsMatch =
|
|
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(
|
|
345
|
-
const canonicalMatch =
|
|
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(
|
|
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
|
|
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)} ${
|
|
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
|
|
668
|
+
const scoreColor3 = getScoreColor(averageScore);
|
|
415
669
|
lines.push(
|
|
416
|
-
` Score: ${
|
|
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 =
|
|
541
|
-
|
|
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/
|
|
552
|
-
import
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
}
|
|
594
|
-
|
|
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(
|
|
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(
|
|
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
|
|
623
|
-
if (
|
|
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(
|
|
960
|
+
console.log(chalk4.dim(` No page.tsx/ts files found in ${project.appDir}/`));
|
|
627
961
|
}
|
|
628
962
|
process.exit(1);
|
|
629
963
|
}
|
|
630
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
1082
|
+
console.log(chalk5.yellow(` \u26A0 ${w}`));
|
|
701
1083
|
}
|
|
702
1084
|
if (robotsResult.sitemapUrls.length > 0) {
|
|
703
|
-
console.log(
|
|
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(
|
|
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(
|
|
1117
|
+
console.log(chalk5.red(` \u2717 ${page.url}`) + chalk5.dim(` \u2014 ${page.error}`));
|
|
736
1118
|
continue;
|
|
737
1119
|
}
|
|
738
|
-
const
|
|
739
|
-
const icon = page.validation.errors.length > 0 ?
|
|
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} ${
|
|
1123
|
+
` ${icon} ${page.url} ${pageColor(`${page.validation.score}/100`)}`
|
|
742
1124
|
);
|
|
743
1125
|
for (const error of page.validation.errors) {
|
|
744
|
-
console.log(
|
|
1126
|
+
console.log(chalk5.red(` \u2717 ${error.message ?? error.description}`));
|
|
745
1127
|
}
|
|
746
1128
|
for (const warning of page.validation.warnings) {
|
|
747
|
-
console.log(
|
|
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(
|
|
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(
|
|
1149
|
+
console.log(chalk5.dim(` - ${u}`));
|
|
768
1150
|
}
|
|
769
1151
|
}
|
|
770
1152
|
if (sitemapComparison.inSitemapOnly.length > 0) {
|
|
771
|
-
|
|
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(
|
|
1157
|
+
console.log(chalk5.dim(` - ${u}`));
|
|
774
1158
|
}
|
|
775
1159
|
}
|
|
776
1160
|
if (sitemapComparison.issues.length === 0) {
|
|
777
|
-
console.log(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1216
|
+
console.log(chalk5.bold("- Duplicate titles"));
|
|
833
1217
|
for (const dup of a.duplicateTitles.slice(0, 5)) {
|
|
834
|
-
console.log(
|
|
835
|
-
for (const u of dup.urls.slice(0, 3)) console.log(
|
|
836
|
-
if (dup.urls.length > 3) console.log(
|
|
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(
|
|
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(
|
|
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(
|
|
846
|
-
for (const u of dup.urls.slice(0, 3)) console.log(
|
|
847
|
-
if (dup.urls.length > 3) console.log(
|
|
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(
|
|
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(
|
|
1239
|
+
console.log(chalk5.bold("- H1 heading issues"));
|
|
856
1240
|
if (missing.length > 0) {
|
|
857
|
-
console.log(
|
|
858
|
-
for (const h of missing.slice(0, 5)) console.log(
|
|
859
|
-
if (missing.length > 5) console.log(
|
|
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(
|
|
863
|
-
for (const h of multiple.slice(0, 5)) console.log(
|
|
864
|
-
if (multiple.length > 5) console.log(
|
|
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(
|
|
1253
|
+
console.log(chalk5.bold("- Broken internal links"));
|
|
870
1254
|
for (const bl of a.brokenInternalLinks.slice(0, 10)) {
|
|
871
|
-
console.log(
|
|
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.
|
|
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(
|
|
1269
|
+
console.log(chalk5.bold("- Redirect chains"));
|
|
878
1270
|
for (const r of a.redirects.slice(0, 10)) {
|
|
879
|
-
console.log(
|
|
880
|
-
for (const step of r.chain) console.log(
|
|
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(
|
|
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(
|
|
1281
|
+
console.log(chalk5.bold("- Thin content") + chalk5.dim(" (< 200 words)"));
|
|
890
1282
|
for (const tc of realThin.slice(0, 10)) {
|
|
891
|
-
console.log(
|
|
1283
|
+
console.log(chalk5.yellow(` \u26A0 ${tc.url}`) + chalk5.dim(` \u2014 ${tc.wordCount} words`));
|
|
892
1284
|
}
|
|
893
|
-
if (realThin.length > 10) console.log(
|
|
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(
|
|
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(
|
|
1291
|
+
console.log(chalk5.dim(` \u2139 ${tc.url} \u2014 ${tc.wordCount} words`));
|
|
900
1292
|
}
|
|
901
|
-
if (appThin.length > 5) console.log(
|
|
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(
|
|
907
|
-
for (const o of a.orphanPages.slice(0, 10)) console.log(
|
|
908
|
-
if (a.orphanPages.length > 10) console.log(
|
|
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(
|
|
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 ?
|
|
915
|
-
console.log(color(` \u26A0 ${sp.url}`) +
|
|
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(
|
|
1312
|
+
console.log(chalk5.bold("- Structured data (JSON-LD)"));
|
|
921
1313
|
for (const sd of a.structuredDataSummary) {
|
|
922
|
-
console.log(
|
|
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(
|
|
1319
|
+
console.log(chalk5.yellow(` \u26A0 ${pagesWithout} pages without any structured data`));
|
|
928
1320
|
}
|
|
929
1321
|
console.log("");
|
|
930
1322
|
} else {
|
|
931
|
-
console.log(
|
|
932
|
-
console.log(
|
|
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
|
|
951
|
-
console.log(
|
|
1363
|
+
const summaryColor = scoreColor(crawlResult.averageScore);
|
|
1364
|
+
console.log(chalk5.bold(" \u2500\u2500\u2500 Summary \u2500\u2500\u2500"));
|
|
952
1365
|
console.log("");
|
|
953
|
-
|
|
954
|
-
console.log(`
|
|
955
|
-
console.log(`
|
|
956
|
-
console.log(`
|
|
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 ?
|
|
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(
|
|
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
|
|
1380
|
+
const apiKey = await resolveApiKey(opts.apiKey);
|
|
967
1381
|
if (!apiKey) {
|
|
968
1382
|
if (!jsonOutput) {
|
|
969
|
-
console.log(
|
|
970
|
-
console.log(
|
|
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://
|
|
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
|
|
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(
|
|
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(
|
|
1446
|
+
console.log(chalk6.bold(` Direct suggestions (${kwResult.suggestions.length})`));
|
|
1030
1447
|
for (const s of kwResult.suggestions) {
|
|
1031
|
-
console.log(` ${
|
|
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(
|
|
1453
|
+
console.log(chalk6.bold(` Questions (${kwResult.questions.length})`));
|
|
1037
1454
|
for (const q of kwResult.questions.slice(0, 20)) {
|
|
1038
|
-
console.log(` ${
|
|
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(
|
|
1460
|
+
console.log(chalk6.bold(` Long-tail (${kwResult.longTail.length})`));
|
|
1044
1461
|
for (const lt of kwResult.longTail.slice(0, 20)) {
|
|
1045
|
-
console.log(
|
|
1462
|
+
console.log(chalk6.dim(` ${lt.keyword}`));
|
|
1046
1463
|
}
|
|
1047
1464
|
if (kwResult.longTail.length > 20) {
|
|
1048
|
-
console.log(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|