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