crawlemon 0.2.0 → 0.3.1

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.
Files changed (3) hide show
  1. package/README.md +17 -3
  2. package/dist/index.js +1637 -212
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/index.ts
2
- import fs6 from "node:fs";
2
+ import fs7 from "node:fs";
3
3
  import os from "node:os";
4
- import path8 from "node:path";
4
+ import path9 from "node:path";
5
5
  import { spawnSync } from "node:child_process";
6
6
 
7
7
  // ../next-adapter/src/parser.ts
@@ -197,20 +197,646 @@ function parsePageSource(content, framework = "nextjs") {
197
197
  }
198
198
 
199
199
  // ../next-adapter/src/scanner.ts
200
- import fs2 from "node:fs";
201
- import path2 from "node:path";
200
+ import fs3 from "node:fs";
201
+ import path3 from "node:path";
202
202
 
203
203
  // ../next-adapter/src/dependency-graph.ts
204
204
  import fs from "node:fs";
205
205
  import path from "node:path";
206
+
207
+ // ../next-adapter/src/value-parser.ts
208
+ function isWhitespace(ch) {
209
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\xA0";
210
+ }
211
+ function skipTrivia(text, index) {
212
+ let i = index;
213
+ while (i < text.length) {
214
+ if (isWhitespace(text[i])) {
215
+ i += 1;
216
+ continue;
217
+ }
218
+ if (text[i] === "/" && text[i + 1] === "/") {
219
+ while (i < text.length && text[i] !== "\n") i += 1;
220
+ continue;
221
+ }
222
+ if (text[i] === "/" && text[i + 1] === "*") {
223
+ const end = text.indexOf("*/", i + 2);
224
+ i = end === -1 ? text.length : end + 2;
225
+ continue;
226
+ }
227
+ break;
228
+ }
229
+ return i;
230
+ }
231
+ function readStringLiteral(text, index) {
232
+ const quote = text[index];
233
+ let i = index + 1;
234
+ let value = "";
235
+ while (i < text.length) {
236
+ const ch = text[i];
237
+ if (ch === "\\") {
238
+ value += text[i + 1] ?? "";
239
+ i += 2;
240
+ continue;
241
+ }
242
+ if (ch === quote) return { value, end: i + 1 };
243
+ value += ch;
244
+ i += 1;
245
+ }
246
+ return { value, end: i };
247
+ }
248
+ function readTemplateLiteral(text, index) {
249
+ let i = index + 1;
250
+ let depth = 0;
251
+ let value = "";
252
+ while (i < text.length) {
253
+ const ch = text[i];
254
+ if (ch === "\\") {
255
+ value += text[i + 1] ?? "";
256
+ i += 2;
257
+ continue;
258
+ }
259
+ if (ch === "`" && depth === 0) return { value, end: i + 1 };
260
+ if (ch === "$" && text[i + 1] === "{") {
261
+ depth += 1;
262
+ value += "${";
263
+ i += 2;
264
+ continue;
265
+ }
266
+ if (depth > 0 && ch === "}") {
267
+ depth -= 1;
268
+ value += "}";
269
+ i += 1;
270
+ continue;
271
+ }
272
+ value += ch;
273
+ i += 1;
274
+ }
275
+ return { value, end: i };
276
+ }
277
+ function readIdentifier(text, index) {
278
+ let i = index;
279
+ while (i < text.length && /[A-Za-z0-9_$]/.test(text[i])) i += 1;
280
+ return { name: text.slice(index, i), end: i };
281
+ }
282
+ function readTemplateWhole(text, index) {
283
+ return readTemplateLiteral(text, index);
284
+ }
285
+ function readBalanced(text, open) {
286
+ const closer = { "(": ")", "[": "]", "{": "}" };
287
+ const expected = closer[text[open]];
288
+ if (!expected) return open + 1;
289
+ let depth = 0;
290
+ let i = open;
291
+ while (i < text.length) {
292
+ const ch = text[i];
293
+ if (ch === '"' || ch === "'") {
294
+ i = readStringLiteral(text, i).end;
295
+ continue;
296
+ }
297
+ if (ch === "`") {
298
+ i = readTemplateWhole(text, i).end;
299
+ continue;
300
+ }
301
+ if (ch === "/" && text[i + 1] === "/") {
302
+ i = skipTrivia(text, i);
303
+ continue;
304
+ }
305
+ if (ch === "/" && text[i + 1] === "*") {
306
+ i = skipTrivia(text, i);
307
+ continue;
308
+ }
309
+ if (ch === "{" || ch === "[" || ch === "(") depth += 1;
310
+ else if (ch === "}" || ch === "]" || ch === ")") {
311
+ depth -= 1;
312
+ if (depth === 0) return i;
313
+ }
314
+ i += 1;
315
+ }
316
+ return text.length;
317
+ }
318
+ function stripTsSuffix(raw) {
319
+ let text = raw.trim();
320
+ text = text.replace(/^as\s+const\s*/i, "");
321
+ text = text.replace(/\s+as\s+const\s*$/i, "").replace(/\s+satisfies\s+[A-Za-z_][\w.\[\]'"<>|&]*\s*$/i, "");
322
+ return text.trim();
323
+ }
324
+ function parseTemplateValue(text, index) {
325
+ const { value, end } = readTemplateWhole(text, index);
326
+ const parts = [];
327
+ let cursor = 0;
328
+ while (cursor < value.length) {
329
+ const dollar = value.indexOf("${", cursor);
330
+ if (dollar === -1) {
331
+ if (cursor < value.length) parts.push({ t: "str", value: value.slice(cursor) });
332
+ break;
333
+ }
334
+ if (dollar > cursor) parts.push({ t: "str", value: value.slice(cursor, dollar) });
335
+ const exprEnd = findClosingBrace(value, dollar + 1);
336
+ const exprRaw = value.slice(dollar + 2, exprEnd);
337
+ let parsed = { t: "unresolved", raw: exprRaw };
338
+ try {
339
+ const parsedExpr = parseValue(exprRaw);
340
+ if (parsedExpr.t !== "unresolved") parsed = parsedExpr;
341
+ } catch {
342
+ parsed = { t: "unresolved", raw: exprRaw };
343
+ }
344
+ parts.push(parsed);
345
+ cursor = exprEnd + 1;
346
+ }
347
+ return { node: { t: "template", parts, raw: value }, end };
348
+ }
349
+ function findClosingBrace(text, atOpen) {
350
+ let depth = 0;
351
+ let i = atOpen;
352
+ while (i < text.length) {
353
+ const ch = text[i];
354
+ if (ch === "'" || ch === '"') {
355
+ i = readStringLiteral(text, i).end;
356
+ continue;
357
+ }
358
+ if (ch === "`") {
359
+ i = readTemplateWhole(text, i).end;
360
+ continue;
361
+ }
362
+ if (ch === "{") depth += 1;
363
+ else if (ch === "}") {
364
+ depth -= 1;
365
+ if (depth === 0) return i;
366
+ }
367
+ i += 1;
368
+ }
369
+ return text.length;
370
+ }
371
+ function scanContainerElementEnd(text, index) {
372
+ let depth = 0;
373
+ let i = index;
374
+ while (i < text.length) {
375
+ const ch = text[i];
376
+ if (ch === '"' || ch === "'") {
377
+ i = readStringLiteral(text, i).end;
378
+ continue;
379
+ }
380
+ if (ch === "`") {
381
+ i = readTemplateWhole(text, i).end;
382
+ continue;
383
+ }
384
+ if (ch === "/" && text[i + 1] === "/") {
385
+ while (i < text.length && text[i] !== "\n") i += 1;
386
+ continue;
387
+ }
388
+ if (ch === "/" && text[i + 1] === "*") {
389
+ const end = text.indexOf("*/", i + 2);
390
+ i = end === -1 ? text.length : end + 2;
391
+ continue;
392
+ }
393
+ if (ch === "{" || ch === "[" || ch === "(") {
394
+ depth += 1;
395
+ } else if (ch === "}" || ch === "]" || ch === ")") {
396
+ if (depth === 0) return i;
397
+ depth -= 1;
398
+ } else if (depth === 0 && ch === ",") {
399
+ return i;
400
+ }
401
+ i += 1;
402
+ }
403
+ return text.length;
404
+ }
405
+ function parseObjectValue(text, index) {
406
+ const props = [];
407
+ let i = skipTrivia(text, index + 1);
408
+ if (text[i] === "}") return { node: { t: "obj", props }, end: i + 1 };
409
+ while (i < text.length) {
410
+ i = skipTrivia(text, i);
411
+ const ch = text[i];
412
+ if (ch === "}") return { node: { t: "obj", props }, end: i + 1 };
413
+ if (ch === ",") {
414
+ i += 1;
415
+ continue;
416
+ }
417
+ if (ch === ".") {
418
+ const restEnd = scanContainerElementEnd(text, i);
419
+ if (restEnd === text.length) return { node: { t: "obj", props }, end: text.length };
420
+ i = restEnd;
421
+ continue;
422
+ }
423
+ let key;
424
+ if (ch === '"' || ch === "'") {
425
+ key = readStringLiteral(text, i).value;
426
+ i = readStringLiteral(text, i).end;
427
+ } else {
428
+ const ident = readIdentifier(text, i);
429
+ if (ident.end === i) {
430
+ const skipEnd = scanContainerElementEnd(text, i);
431
+ if (skipEnd === text.length) return { node: { t: "obj", props }, end: text.length };
432
+ i = skipEnd;
433
+ continue;
434
+ }
435
+ key = ident.name;
436
+ i = ident.end;
437
+ }
438
+ i = skipTrivia(text, i);
439
+ if (text[i] === ":") {
440
+ const valueStart = skipTrivia(text, i + 1);
441
+ const boundary = scanContainerElementEnd(text, valueStart);
442
+ const raw = text.slice(valueStart, boundary).trim();
443
+ let node;
444
+ try {
445
+ node = parseValue(raw) || { t: "unresolved", raw };
446
+ } catch {
447
+ node = { t: "unresolved", raw };
448
+ }
449
+ props.push({ key, value: node, raw });
450
+ i = boundary;
451
+ } else {
452
+ props.push({ key, value: { t: "ident", name: key }, raw: key });
453
+ i = skipTrivia(text, i);
454
+ if (text[i] === ",") i += 1;
455
+ }
456
+ }
457
+ return { node: { t: "obj", props }, end: text.length };
458
+ }
459
+ function parseArrayValue(text, index) {
460
+ const items = [];
461
+ let i = skipTrivia(text, index + 1);
462
+ if (text[i] === "]") return { node: { t: "arr", items }, end: i + 1 };
463
+ while (i < text.length) {
464
+ i = skipTrivia(text, i);
465
+ const ch = text[i];
466
+ if (ch === "]") return { node: { t: "arr", items }, end: i + 1 };
467
+ if (ch === ",") {
468
+ i += 1;
469
+ continue;
470
+ }
471
+ const boundary = scanContainerElementEnd(text, i);
472
+ const raw = text.slice(i, boundary).trim();
473
+ let node;
474
+ try {
475
+ node = parseValue(raw) || { t: "unresolved", raw };
476
+ } catch {
477
+ node = { t: "unresolved", raw };
478
+ }
479
+ items.push(node);
480
+ i = boundary;
481
+ }
482
+ return { node: { t: "arr", items }, end: text.length };
483
+ }
484
+ function parseValueAt(text, index) {
485
+ let i = skipTrivia(text, index);
486
+ const ch = text[i];
487
+ if (ch === '"' || ch === "'") {
488
+ const { value, end } = readStringLiteral(text, i);
489
+ return { node: { t: "str", value }, end };
490
+ }
491
+ if (ch === "`") {
492
+ return parseTemplateValue(text, i);
493
+ }
494
+ if (ch === "{") {
495
+ return parseObjectValue(text, i);
496
+ }
497
+ if (ch === "[") {
498
+ return parseArrayValue(text, i);
499
+ }
500
+ if (text.startsWith("new ", i)) {
501
+ const after = skipTrivia(text, i + 4);
502
+ const callee = readIdentifier(text, after);
503
+ if (callee.name.toLowerCase() === "url") {
504
+ const open = skipTrivia(text, callee.end);
505
+ if (text[open] === "(") {
506
+ const argResult = parseValueAt(text, skipTrivia(text, open + 1));
507
+ return {
508
+ node: { t: "new-url", arg: argResult.node },
509
+ end: readBalanced(text, open) + 1
510
+ };
511
+ }
512
+ }
513
+ }
514
+ const firstChar = text[i];
515
+ if (/[A-Za-z_$]/.test(firstChar)) {
516
+ const ident = readIdentifier(text, i);
517
+ let node = { t: "ident", name: ident.name };
518
+ let cursor = ident.end;
519
+ while (true) {
520
+ const nxt = skipTrivia(text, cursor);
521
+ if (text[nxt] === "." || text[nxt] === "?" && text[nxt + 1] === ".") {
522
+ const dotEnd = text[nxt] === "?" ? nxt + 2 : nxt + 1;
523
+ const propStart = skipTrivia(text, dotEnd);
524
+ const prop2 = readIdentifier(text, propStart);
525
+ if (prop2.end > propStart) {
526
+ node = { t: "member", object: node, prop: prop2.name };
527
+ cursor = prop2.end;
528
+ continue;
529
+ }
530
+ break;
531
+ }
532
+ if (text[nxt] === "(") {
533
+ return { node: { t: "unresolved", raw: text.slice(index, readBalanced(text, nxt) + 1) }, end: readBalanced(text, nxt) + 1 };
534
+ }
535
+ break;
536
+ }
537
+ return { node, end: cursor };
538
+ }
539
+ if (/[0-9]/.test(firstChar) || firstChar === "-" && /[0-9]/.test(text[i + 1])) {
540
+ let cursor = i;
541
+ while (cursor < text.length && /[0-9.eE_+-]/.test(text[cursor])) cursor += 1;
542
+ const raw = text.slice(i, cursor);
543
+ const num = Number(raw.replace(/_/g, ""));
544
+ return { node: { t: "num", value: Number.isFinite(num) ? num : NaN }, end: cursor };
545
+ }
546
+ if (text.startsWith("true", i) || text.startsWith("false", i)) {
547
+ return { node: { t: "bool", value: text.startsWith("true", i) }, end: i + (text.startsWith("true", i) ? 4 : 5) };
548
+ }
549
+ return { node: { t: "unresolved", raw: text.slice(index, i + 1).trim() }, end: i + 1 };
550
+ }
551
+ function parseValue(rawInput) {
552
+ const text = stripTsSuffix(rawInput);
553
+ const { node, end } = parseValueAt(text, 0);
554
+ const rest = skipTrivia(text, end);
555
+ if (rest < text.length) {
556
+ let current = node;
557
+ let cursor = rest;
558
+ while (true) {
559
+ cursor = skipTrivia(text, cursor);
560
+ if (text[cursor] === "+") {
561
+ const rhs = parseValueAt(text, skipTrivia(text, cursor + 1));
562
+ if (rhs.node.t === "unresolved" && current.t === "unresolved") break;
563
+ const parts = [];
564
+ const collect = (n) => n.t === "concat" ? parts.push(...n.parts) : parts.push(n);
565
+ collect(current);
566
+ collect(rhs.node);
567
+ current = { t: "concat", parts };
568
+ cursor = rhs.end;
569
+ continue;
570
+ }
571
+ break;
572
+ }
573
+ if (cursor >= text.length) return current;
574
+ }
575
+ return node;
576
+ }
577
+
578
+ // ../next-adapter/src/next-metadata.ts
579
+ function prop(node, key) {
580
+ if (!node || node.t !== "obj") return void 0;
581
+ const found = node.props.find((p) => p.key === key);
582
+ if (!found) return void 0;
583
+ return { value: found.value, raw: found.raw };
584
+ }
585
+ function skipQuoted(text, index) {
586
+ const quote = text[index];
587
+ let i = index + 1;
588
+ while (i < text.length) {
589
+ if (text[i] === "\\") {
590
+ i += 2;
591
+ continue;
592
+ }
593
+ if (quote === "`" && text[i] === "`") return i;
594
+ if (quote !== "`" && text[i] === quote) return i;
595
+ if (quote === "`" && text[i] === "$" && text[i + 1] === "{") {
596
+ const close = text.indexOf("}", i);
597
+ i = close === -1 ? text.length : close + 1;
598
+ continue;
599
+ }
600
+ i += 1;
601
+ }
602
+ return index;
603
+ }
604
+ function flatten(node, path10 = "") {
605
+ const map = /* @__PURE__ */ new Map();
606
+ if (node.t !== "obj") return map;
607
+ for (const p of node.props) {
608
+ const full = path10 ? `${path10}.${p.key}` : p.key;
609
+ map.set(full, p.value);
610
+ }
611
+ return map;
612
+ }
613
+ function rawOfValue(file, resolver, raw) {
614
+ if (!resolver) return void 0;
615
+ const result = resolver.foldExpression(file, raw);
616
+ if (result.ok && typeof result.value === "string") return result.value;
617
+ return void 0;
618
+ }
619
+ function fieldFromRaw(file, resolver, raw) {
620
+ if (raw === void 0) return { declared: false };
621
+ const value = rawOfValue(file, resolver, raw);
622
+ return { declared: true, raw, value };
623
+ }
624
+ function robotsFieldFromRaw(raw) {
625
+ if (raw === void 0) return { declared: false };
626
+ const parsed = parseValue(raw);
627
+ if (parsed.t === "str") return { declared: true, raw, value: parsed.value };
628
+ const value = /noindex|\bindex\s*:\s*false\b/.test(raw) ? "noindex" : "";
629
+ return { declared: true, raw, value };
630
+ }
631
+ function extractFromObject(file, resolver, rawObject) {
632
+ let node;
633
+ try {
634
+ node = parseValue(rawObject);
635
+ } catch {
636
+ node = { t: "unresolved", raw: rawObject };
637
+ }
638
+ const props = flatten(node);
639
+ const titleProp = props.get("title");
640
+ let title = { declared: false };
641
+ let titleObject;
642
+ if (titleProp) {
643
+ if (titleProp.t === "obj") {
644
+ const absoluteRaw = prop(titleProp, "absolute")?.raw;
645
+ const defaultRaw = prop(titleProp, "default")?.raw;
646
+ const templateRaw = prop(titleProp, "template")?.raw;
647
+ const absolute = fieldFromRaw(file, resolver, absoluteRaw);
648
+ const defaultField = fieldFromRaw(file, resolver, defaultRaw);
649
+ const template = fieldFromRaw(file, resolver, templateRaw);
650
+ titleObject = { absolute, default: defaultField, template };
651
+ title = {
652
+ declared: true,
653
+ kind: "object",
654
+ value: absolute.value ?? defaultField.value,
655
+ raw: absoluteRaw ?? defaultRaw
656
+ };
657
+ } else {
658
+ const value = fieldFromRaw(file, resolver, prop(node, "title")?.raw);
659
+ title = { ...value, kind: "string" };
660
+ }
661
+ } else {
662
+ titleObject = void 0;
663
+ }
664
+ const alternatesNode = props.get("alternates");
665
+ const canonicalRaw = prop(alternatesNode, "canonical")?.raw ?? prop(node, "canonical")?.raw;
666
+ return {
667
+ source: "export-metadata",
668
+ dynamic: false,
669
+ hasParams: false,
670
+ title,
671
+ titleObject,
672
+ description: fieldFromRaw(file, resolver, prop(node, "description")?.raw),
673
+ canonical: fieldFromRaw(file, resolver, canonicalRaw),
674
+ robots: robotsFieldFromRaw(prop(node, "robots")?.raw),
675
+ metadataBase: fieldFromRaw(file, resolver, prop(node, "metadataBase")?.raw)
676
+ };
677
+ }
678
+ function findObjectAfter(content, keywordPattern) {
679
+ for (const match of content.matchAll(keywordPattern)) {
680
+ const eq = content.indexOf("=", match.index);
681
+ if (eq === -1) continue;
682
+ const brace = content.indexOf("{", eq);
683
+ if (brace === -1) continue;
684
+ const end = readBalanced(content, brace);
685
+ return { raw: content.slice(brace, end + 1), index: brace };
686
+ }
687
+ return null;
688
+ }
689
+ function extractNextMetadata(content, file, resolver) {
690
+ const exportObj = findObjectAfter(content, /export\s+const\s+metadata\b/g);
691
+ const fnMatch = content.match(/export\s+(?:async\s+)?function\s+generateMetadata\b/g);
692
+ const fnObj = fnMatch ? findObjectAfter(content, /function\s+generateMetadata\s*\([^)]*\)\s*\{/g) : null;
693
+ if (!exportObj && !fnMatch) return null;
694
+ if (exportObj) {
695
+ return extractFromObject(file, resolver, exportObj.raw);
696
+ }
697
+ const candidates = [];
698
+ const fnAt = content.indexOf("generateMetadata");
699
+ let hasParams = false;
700
+ let bodyStart = -1;
701
+ let bodyEnd = -1;
702
+ if (fnAt !== -1) {
703
+ const openParen = content.indexOf("(", fnAt);
704
+ if (openParen !== -1) {
705
+ const afterParen = readBalanced(content, openParen) + 1;
706
+ const signature = content.slice(openParen, afterParen);
707
+ hasParams = /\b(params|searchParams)\b/.test(signature);
708
+ let cursor = afterParen;
709
+ while (cursor < content.length && content[cursor] !== "{") cursor += 1;
710
+ if (content[cursor] === "{") {
711
+ bodyStart = cursor;
712
+ bodyEnd = readBalanced(content, cursor);
713
+ }
714
+ }
715
+ }
716
+ if (bodyStart !== -1) {
717
+ let scan = bodyStart + 1;
718
+ while (scan < bodyEnd) {
719
+ if (content[scan] === "{") {
720
+ const end = readBalanced(content, scan);
721
+ if (end < bodyEnd) candidates.push(content.slice(scan, end + 1));
722
+ scan = end + 1;
723
+ continue;
724
+ }
725
+ if (content[scan] === '"' || content[scan] === "'") {
726
+ scan = skipQuoted(content, scan) + 1;
727
+ continue;
728
+ }
729
+ if (content[scan] === "`") {
730
+ scan = skipQuoted(content, scan) + 1;
731
+ continue;
732
+ }
733
+ scan += 1;
734
+ }
735
+ }
736
+ if (candidates.length === 0) return null;
737
+ const merged = {
738
+ source: "generate-metadata",
739
+ dynamic: true,
740
+ hasParams,
741
+ title: { declared: false },
742
+ description: { declared: false },
743
+ canonical: { declared: false },
744
+ robots: { declared: false },
745
+ metadataBase: { declared: false }
746
+ };
747
+ merged.dynamic = hasParams || candidates.length > 1;
748
+ const prefer = (target, candidate) => {
749
+ if (!target.declared) return candidate;
750
+ if (target.value === void 0 && candidate.value !== void 0) return candidate;
751
+ return target;
752
+ };
753
+ for (const raw of candidates) {
754
+ const extract = extractFromObject(file, resolver, raw);
755
+ merged.title = prefer(merged.title, extract.title);
756
+ if (extract.titleObject && (!merged.titleObject || merged.title.value === void 0)) {
757
+ merged.titleObject = extract.titleObject;
758
+ }
759
+ merged.description = prefer(merged.description, extract.description);
760
+ merged.canonical = prefer(merged.canonical, extract.canonical);
761
+ merged.robots = prefer(merged.robots, extract.robots);
762
+ merged.metadataBase = prefer(merged.metadataBase, extract.metadataBase);
763
+ }
764
+ return merged;
765
+ }
766
+ function applyTitleTemplate(title, template) {
767
+ if (!template || !template.includes("%s")) return title;
768
+ return template.replace(/%s/g, title);
769
+ }
770
+ function buildEffectiveMetadata(extract, inherited, ctx) {
771
+ const out = { ...inherited };
772
+ const base = extract.metadataBase.value ?? inherited.metadataBase ?? ctx.origin;
773
+ if (base) out.metadataBase = base.replace(/\/$/, "");
774
+ const titleTemplate = extract.titleObject?.template?.value ?? inherited.titleTemplate;
775
+ if (titleTemplate) out.titleTemplate = titleTemplate;
776
+ out.titleDeclared = extract.title.declared;
777
+ if (extract.title.declared) {
778
+ if (extract.title.kind === "object" || extract.titleObject) {
779
+ const absolute = extract.titleObject?.absolute?.value;
780
+ const def = extract.titleObject?.default?.value;
781
+ const value = absolute ?? def;
782
+ if (value !== void 0) {
783
+ out.title = absolute ? value : applyTitleTemplate(value, ctx.isLayout ? void 0 : titleTemplate);
784
+ } else {
785
+ out.title = void 0;
786
+ }
787
+ } else if (extract.title.value !== void 0) {
788
+ out.title = ctx.isLayout ? extract.title.value : applyTitleTemplate(extract.title.value, titleTemplate);
789
+ } else {
790
+ out.title = void 0;
791
+ }
792
+ }
793
+ out.descriptionDeclared = extract.description.declared;
794
+ if (extract.description.value !== void 0) out.description = extract.description.value;
795
+ else if (extract.description.declared) out.description = void 0;
796
+ out.canonicalDeclared = extract.canonical.declared;
797
+ if (extract.canonical.value !== void 0) {
798
+ out.canonical = absolutize(extract.canonical.value, base);
799
+ } else if (extract.canonical.declared) {
800
+ out.canonical = void 0;
801
+ }
802
+ if (extract.robots.value !== void 0) out.robots = extract.robots.value;
803
+ out.metadataSource = extract.source;
804
+ if (extract.dynamic) out.dynamicMetadata = true;
805
+ return out;
806
+ }
807
+ function absolutize(value, base) {
808
+ if (/^https?:\/\//i.test(value)) return value;
809
+ if (value.startsWith("/") && base) {
810
+ return `${base.replace(/\/$/, "")}${value}`;
811
+ }
812
+ return value;
813
+ }
814
+ function resolvePageMetadata(content, file, inherited, ctx) {
815
+ const extract = extractNextMetadata(content, file, ctx.resolver);
816
+ if (!extract) {
817
+ return inherited;
818
+ }
819
+ return buildEffectiveMetadata(extract, inherited, { ...ctx, isLayout: false });
820
+ }
821
+ function resolveLayoutMetadata(content, file, ctx) {
822
+ const extract = extractNextMetadata(content, file, ctx.resolver);
823
+ if (!extract) return {};
824
+ return buildEffectiveMetadata(extract, {}, { ...ctx, isLayout: true });
825
+ }
826
+
827
+ // ../next-adapter/src/dependency-graph.ts
206
828
  var RouteDependencyGraph = class {
207
829
  projectRoot;
208
830
  appDir;
209
831
  layoutsByDir = /* @__PURE__ */ new Map();
210
832
  routes = [];
211
- constructor(projectRoot, appDir) {
833
+ resolver;
834
+ origin;
835
+ constructor(projectRoot, appDir, resolver, origin) {
212
836
  this.projectRoot = projectRoot;
213
837
  this.appDir = appDir;
838
+ this.resolver = resolver;
839
+ this.origin = origin;
214
840
  }
215
841
  indexLayouts() {
216
842
  this.layoutsByDir.clear();
@@ -225,7 +851,7 @@ var RouteDependencyGraph = class {
225
851
  }
226
852
  } else if (entry.isFile() && /^layout\.(tsx|jsx|js|ts)$/.test(entry.name)) {
227
853
  const content = fs.readFileSync(fullPath, "utf8");
228
- const parsed = parsePageSource(content, "nextjs");
854
+ const metadata = this.resolver ? resolveLayoutMetadata(content, fullPath, { resolver: this.resolver, origin: this.origin, file: fullPath }) : parsePageSource(content, "nextjs").metadata;
229
855
  const relativeDir = path.relative(this.appDir, currentDir);
230
856
  let canonicalLine;
231
857
  let robotsLine;
@@ -240,7 +866,7 @@ var RouteDependencyGraph = class {
240
866
  this.layoutsByDir.set(relativeDir, {
241
867
  filePath: fullPath,
242
868
  relativeDir,
243
- metadata: parsed.metadata,
869
+ metadata,
244
870
  metadataSourceLocation: {
245
871
  canonicalLine,
246
872
  robotsLine,
@@ -374,6 +1000,612 @@ var RouteDependencyGraph = class {
374
1000
  }
375
1001
  };
376
1002
 
1003
+ // ../next-adapter/src/project-resolver.ts
1004
+ import fs2 from "node:fs";
1005
+ import path2 from "node:path";
1006
+ function readStringEnd(text, index) {
1007
+ const quote = text[index];
1008
+ let i = index + 1;
1009
+ while (i < text.length) {
1010
+ if (text[i] === "\\") {
1011
+ i += 2;
1012
+ continue;
1013
+ }
1014
+ if (text[i] === quote) return i + 1;
1015
+ i += 1;
1016
+ }
1017
+ return text.length;
1018
+ }
1019
+ function splitTopLevelStatements(code) {
1020
+ const out = [];
1021
+ let depth = 0;
1022
+ let statementStart = -1;
1023
+ let i = 0;
1024
+ const flush = (end) => {
1025
+ if (statementStart !== -1) {
1026
+ const raw = code.slice(statementStart, end);
1027
+ if (raw.trim()) out.push({ statement: raw, start: statementStart, end });
1028
+ }
1029
+ statementStart = -1;
1030
+ };
1031
+ while (i < code.length) {
1032
+ const ch = code[i];
1033
+ if (ch === " " || ch === " " || ch === "\r") {
1034
+ if (statementStart === -1 && depth === 0 && out.length === 0 && i < code.length) {
1035
+ }
1036
+ i += 1;
1037
+ continue;
1038
+ }
1039
+ if (ch === "\n") {
1040
+ if (depth === 0) flush(i);
1041
+ i += 1;
1042
+ continue;
1043
+ }
1044
+ if (statementStart === -1) statementStart = i;
1045
+ if (ch === "/" && code[i + 1] === "/") {
1046
+ while (i < code.length && code[i] !== "\n") i += 1;
1047
+ continue;
1048
+ }
1049
+ if (ch === "/" && code[i + 1] === "*") {
1050
+ const end = code.indexOf("*/", i + 2);
1051
+ i = end === -1 ? code.length : end + 2;
1052
+ continue;
1053
+ }
1054
+ if (ch === '"' || ch === "'") {
1055
+ i = readStringEnd(code, i);
1056
+ continue;
1057
+ }
1058
+ if (ch === "`") {
1059
+ let t = i + 1;
1060
+ while (t < code.length) {
1061
+ if (code[t] === "\\") {
1062
+ t += 2;
1063
+ continue;
1064
+ }
1065
+ if (code[t] === "`") break;
1066
+ t += 1;
1067
+ }
1068
+ i = Math.min(t + 1, code.length);
1069
+ continue;
1070
+ }
1071
+ if (ch === "{" || ch === "[" || ch === "(") {
1072
+ depth += 1;
1073
+ } else if (ch === "}" || ch === "]" || ch === ")") {
1074
+ depth = Math.max(0, depth - 1);
1075
+ } else if (ch === ";" && depth === 0) {
1076
+ flush(i + 1);
1077
+ i += 1;
1078
+ continue;
1079
+ }
1080
+ i += 1;
1081
+ }
1082
+ flush(code.length);
1083
+ return out;
1084
+ }
1085
+ var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"];
1086
+ var ProjectResolver = class {
1087
+ projectRoot;
1088
+ cache = /* @__PURE__ */ new Map();
1089
+ aliasRoots = [];
1090
+ loading = /* @__PURE__ */ new Set();
1091
+ origin;
1092
+ constructor(projectRoot) {
1093
+ this.projectRoot = projectRoot;
1094
+ this.loadAliases();
1095
+ }
1096
+ loadAliases() {
1097
+ const roots = ["tsconfig.json", "tsconfig.app.json"];
1098
+ for (const name of roots) {
1099
+ const file = path2.join(this.projectRoot, name);
1100
+ if (!fs2.existsSync(file)) continue;
1101
+ try {
1102
+ const json = JSON.parse(fs2.readFileSync(file, "utf8"));
1103
+ const paths = json.compilerOptions?.paths || {};
1104
+ const baseUrl = json.compilerOptions?.baseUrl || ".";
1105
+ for (const [key, targets] of Object.entries(paths)) {
1106
+ if (!key.endsWith("/*") || !Array.isArray(targets)) continue;
1107
+ const dir = path2.resolve(this.projectRoot, baseUrl, String(targets[0]).replace(/\*$/, ""));
1108
+ this.aliasRoots.push({ prefix: key.slice(0, -2), dir });
1109
+ }
1110
+ } catch {
1111
+ }
1112
+ }
1113
+ if (this.aliasRoots.length === 0) {
1114
+ this.aliasRoots.push({ prefix: "@", dir: path2.join(this.projectRoot, "src") });
1115
+ }
1116
+ this.aliasRoots.push({ prefix: "~", dir: path2.join(this.projectRoot, "src") });
1117
+ }
1118
+ readFile(filePath) {
1119
+ try {
1120
+ return fs2.readFileSync(filePath, "utf8");
1121
+ } catch {
1122
+ return void 0;
1123
+ }
1124
+ }
1125
+ /**
1126
+ * Converts a module specifier (relative or `@/`/`~/` alias) into an absolute
1127
+ * path when it points at a resolvable local file.
1128
+ */
1129
+ resolveModule(specifier, fromFile) {
1130
+ let base = specifier;
1131
+ let fromDir = fromFile ? path2.dirname(fromFile) : this.projectRoot;
1132
+ const alias = this.aliasRoots.find((a) => base.startsWith(a.prefix + "/"));
1133
+ if (alias) {
1134
+ fromDir = alias.dir;
1135
+ base = base.slice(alias.prefix.length + 1);
1136
+ } else if (base.startsWith(".")) {
1137
+ } else {
1138
+ return null;
1139
+ }
1140
+ if (!fromDir.startsWith(this.projectRoot)) return null;
1141
+ const candidate = path2.resolve(fromDir, base);
1142
+ return this.resolveAsFile(candidate) || this.resolveAsIndex(candidate);
1143
+ }
1144
+ resolveAsFile(candidate) {
1145
+ if (!fs2.existsSync(candidate)) {
1146
+ for (const ext of EXTENSIONS) {
1147
+ if (fs2.existsSync(candidate + ext)) return candidate + ext;
1148
+ }
1149
+ return null;
1150
+ }
1151
+ const stat = fs2.statSync(candidate);
1152
+ if (stat.isFile()) return candidate;
1153
+ return null;
1154
+ }
1155
+ resolveAsIndex(base) {
1156
+ if (fs2.existsSync(base) && fs2.statSync(base).isDirectory()) {
1157
+ for (const name of ["index.ts", "index.tsx", "index.js", "index.jsx"]) {
1158
+ const candidate = path2.join(base, name);
1159
+ if (fs2.existsSync(candidate)) return candidate;
1160
+ }
1161
+ }
1162
+ return null;
1163
+ }
1164
+ getModule(filePath) {
1165
+ if (!filePath) return void 0;
1166
+ const normalized = path2.resolve(filePath);
1167
+ const cached = this.cache.get(normalized);
1168
+ if (cached) return cached;
1169
+ const content = this.readFile(normalized);
1170
+ if (content === void 0) return void 0;
1171
+ const info = {
1172
+ filePath: normalized,
1173
+ content,
1174
+ consts: /* @__PURE__ */ new Map(),
1175
+ destructure: /* @__PURE__ */ new Map(),
1176
+ imports: /* @__PURE__ */ new Map(),
1177
+ importsDefault: /* @__PURE__ */ new Map()
1178
+ };
1179
+ this.cache.set(normalized, info);
1180
+ this.parseModule(info);
1181
+ return info;
1182
+ }
1183
+ parseModule(info) {
1184
+ if (info.parsed) return;
1185
+ info.parsed = true;
1186
+ for (const { statement } of splitTopLevelStatements(info.content)) {
1187
+ const stmt = statement.trim();
1188
+ const importMatch = stmt.match(/^import\s+([\s\S]+?)\s+from\s+["']([^"']+)["']/);
1189
+ if (importMatch) {
1190
+ const spec = importMatch[2];
1191
+ const resolved = this.resolveModule(spec, info.filePath);
1192
+ const clause = importMatch[1].trim();
1193
+ const namedBlock = clause.match(/\{([\s\S]*?)\}/);
1194
+ const namedRaw = namedBlock ? namedBlock[1] : "";
1195
+ let defaultName;
1196
+ if (namedBlock) {
1197
+ const before = clause.slice(0, namedBlock.index).replace(/,\s*$/, "").trim();
1198
+ if (/^[A-Za-z_$][\w$]*$/.test(before)) defaultName = before;
1199
+ } else if (/^[A-Za-z_$][\w$]*$/.test(clause)) {
1200
+ defaultName = clause;
1201
+ }
1202
+ if (defaultName) {
1203
+ info.imports.set(defaultName, resolved || spec);
1204
+ info.importsDefault.set(defaultName, resolved || spec);
1205
+ }
1206
+ for (const raw of namedRaw.split(",")) {
1207
+ const pair = raw.trim();
1208
+ if (!pair) continue;
1209
+ const parts = pair.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
1210
+ if (parts) info.imports.set(parts[2] || parts[1], resolved || spec);
1211
+ }
1212
+ continue;
1213
+ }
1214
+ const nsImport = stmt.match(/^import\s*\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["']/);
1215
+ if (nsImport) {
1216
+ const resolved = this.resolveModule(nsImport[2], info.filePath);
1217
+ info.imports.set(nsImport[1], resolved || nsImport[2]);
1218
+ continue;
1219
+ }
1220
+ const constStmt = stmt.match(/^(?:export\s+|declare\s+)?const\s+([\s\S]+?)=\s*([\s\S]+)$/);
1221
+ if (constStmt) {
1222
+ const lhs = constStmt[1].trim();
1223
+ const rawValue = constStmt[2].trim().replace(/;\s*$/, "");
1224
+ if (lhs.startsWith("{") || lhs.startsWith("[")) {
1225
+ const names = [...lhs.matchAll(/([A-Za-z_$][\w$]*)(?:\s*:\s*([A-Za-z_$][\w$]*))?/g)];
1226
+ for (const name of names) {
1227
+ const sourceProp = name[1];
1228
+ const target = name[2] || sourceProp;
1229
+ info.destructure.set(target, `${rawValue}.${sourceProp}`);
1230
+ }
1231
+ } else if (/^[A-Za-z_$][\w$]*$/.test(lhs)) {
1232
+ info.consts.set(lhs, rawValue);
1233
+ }
1234
+ continue;
1235
+ }
1236
+ const exportMatch = stmt.match(/^export\s+default\s+([\s\S]+)$/);
1237
+ if (exportMatch) {
1238
+ info.defaultRaw = exportMatch[1].trim().replace(/;\s*$/, "");
1239
+ }
1240
+ }
1241
+ }
1242
+ /**
1243
+ * Looks up an identifier's raw expression within `file`, following imports,
1244
+ * module destructuring, and default-export bindings.
1245
+ */
1246
+ resolveIdentifier(file, name) {
1247
+ const info = this.getModule(file);
1248
+ if (!info) return void 0;
1249
+ if (info.destructure.has(name)) return info.destructure.get(name);
1250
+ if (info.consts.has(name)) return info.consts.get(name);
1251
+ const importedFrom = info.imports.get(name);
1252
+ if (!importedFrom) return void 0;
1253
+ const targetModule = this.getModule(importedFrom);
1254
+ if (!targetModule) return void 0;
1255
+ if (targetModule.consts.has(name)) return targetModule.consts.get(name);
1256
+ if (info.importsDefault.has(name) && targetModule.defaultRaw !== void 0) {
1257
+ return targetModule.defaultRaw;
1258
+ }
1259
+ return void 0;
1260
+ }
1261
+ /** Folds an expression string in the context of `file`. */
1262
+ foldExpression(file, raw) {
1263
+ let node;
1264
+ try {
1265
+ node = parseValue(raw);
1266
+ } catch {
1267
+ return { ok: false, unresolved: [raw.trim().slice(0, 80)] };
1268
+ }
1269
+ const state = { ok: true };
1270
+ const value = this.foldNode(file, node, /* @__PURE__ */ new Set(), state);
1271
+ if (!state.ok) {
1272
+ return { ok: false, value, unresolved: state.unresolved };
1273
+ }
1274
+ return { ok: true, value };
1275
+ }
1276
+ foldNode(file, node, seen, state) {
1277
+ switch (node.t) {
1278
+ case "str":
1279
+ return node.value;
1280
+ case "num":
1281
+ return node.value;
1282
+ case "bool":
1283
+ return node.value;
1284
+ case "template": {
1285
+ let out = "";
1286
+ for (const part of node.parts) {
1287
+ if (part.t === "str") {
1288
+ out += part.value;
1289
+ continue;
1290
+ }
1291
+ const folded = this.foldNode(file, part, seen, state);
1292
+ if (typeof folded === "string") out += folded;
1293
+ else if (typeof folded === "number" || typeof folded === "boolean") out += String(folded);
1294
+ else {
1295
+ state.ok = false;
1296
+ (state.unresolved = state.unresolved || []).push(node.raw.slice(0, 60));
1297
+ return void 0;
1298
+ }
1299
+ }
1300
+ return out;
1301
+ }
1302
+ case "concat": {
1303
+ let out = "";
1304
+ for (const part of node.parts) {
1305
+ const folded = this.foldNode(file, part, seen, state);
1306
+ if (typeof folded === "string") out += folded;
1307
+ else if (typeof folded === "number" || typeof folded === "boolean") out += String(folded);
1308
+ else {
1309
+ state.ok = false;
1310
+ (state.unresolved = state.unresolved || []).push("concat");
1311
+ return void 0;
1312
+ }
1313
+ }
1314
+ return out;
1315
+ }
1316
+ case "new-url": {
1317
+ const folded = this.foldNode(file, node.arg, seen, state);
1318
+ return typeof folded === "string" ? folded : (state.ok = false, void 0);
1319
+ }
1320
+ case "ident": {
1321
+ if (node.name === "undefined") return void 0;
1322
+ if (seen.has(node.name)) {
1323
+ state.ok = false;
1324
+ (state.unresolved = state.unresolved || []).push(node.name);
1325
+ return void 0;
1326
+ }
1327
+ const raw = this.resolveIdentifier(file, node.name);
1328
+ if (raw === void 0) {
1329
+ state.ok = false;
1330
+ (state.unresolved = state.unresolved || []).push(node.name);
1331
+ return void 0;
1332
+ }
1333
+ const nextSeen = new Set(seen);
1334
+ nextSeen.add(node.name);
1335
+ let sub;
1336
+ try {
1337
+ sub = parseValue(raw);
1338
+ } catch {
1339
+ state.ok = false;
1340
+ return void 0;
1341
+ }
1342
+ return this.foldNode(file, sub, nextSeen, state);
1343
+ }
1344
+ case "member": {
1345
+ const prevOk = state.ok;
1346
+ const prevUnresolved = state.unresolved ? state.unresolved.slice() : [];
1347
+ const object = this.foldNode(file, node.object, seen, state);
1348
+ const isRecord = object !== null && object !== void 0 && typeof object === "object" && !Array.isArray(object);
1349
+ if (isRecord) {
1350
+ const record = object;
1351
+ if (node.prop in record && record[node.prop] !== void 0) {
1352
+ if (!state.ok) {
1353
+ state.ok = true;
1354
+ if (state.unresolved) state.unresolved = prevUnresolved;
1355
+ }
1356
+ return record[node.prop];
1357
+ }
1358
+ }
1359
+ state.ok = prevOk;
1360
+ state.unresolved = prevUnresolved;
1361
+ (state.unresolved = state.unresolved || []).push(node.object.t === "ident" ? `${node.object.name}.${node.prop}` : `.${node.prop}`);
1362
+ return void 0;
1363
+ }
1364
+ case "arr": {
1365
+ const items = [];
1366
+ for (const item of node.items) {
1367
+ items.push(this.foldNode(file, item, seen, state));
1368
+ }
1369
+ return items;
1370
+ }
1371
+ case "obj": {
1372
+ const record = {};
1373
+ for (const prop2 of node.props) {
1374
+ record[prop2.key] = this.foldNode(file, prop2.value, seen, state);
1375
+ }
1376
+ return record;
1377
+ }
1378
+ case "unresolved":
1379
+ state.ok = false;
1380
+ (state.unresolved = state.unresolved || []).push(node.raw.slice(0, 60));
1381
+ return void 0;
1382
+ }
1383
+ }
1384
+ /** Reads raw const initializer by name in file (for generateStaticParams etc.). */
1385
+ rawConst(file, name) {
1386
+ return this.resolveIdentifier(file, name);
1387
+ }
1388
+ /** Reads the default export expression of a module. */
1389
+ defaultExport(file) {
1390
+ return this.getModule(file)?.defaultRaw;
1391
+ }
1392
+ /**
1393
+ * Best-effort guess of the production origin (scheme + host) by scanning
1394
+ * common configuration modules and the root layout for an http(s) URL that
1395
+ * is clearly not a placeholder.
1396
+ */
1397
+ guessOrigin(knownUrls = []) {
1398
+ if (this.origin) return this.origin;
1399
+ const candidates = [...knownUrls];
1400
+ const filesToScan = [
1401
+ path2.join(this.projectRoot, "config", "site.ts"),
1402
+ path2.join(this.projectRoot, "config", "site.tsx"),
1403
+ path2.join(this.projectRoot, "lib", "site.ts"),
1404
+ path2.join(this.projectRoot, "src", "config", "site.ts"),
1405
+ path2.join(this.projectRoot, "src", "config", "site.tsx"),
1406
+ path2.join(this.projectRoot, "src", "lib", "site.ts"),
1407
+ path2.join(this.projectRoot, "src", "app", "layout.tsx"),
1408
+ path2.join(this.projectRoot, "app", "layout.tsx"),
1409
+ path2.join(this.projectRoot, "package.json")
1410
+ ];
1411
+ for (const file of filesToScan) {
1412
+ const content = this.readFile(file);
1413
+ if (!content) continue;
1414
+ for (const m of content.matchAll(/https?:\/\/[A-Za-z0-9.-]+(?::\d+)?/g)) {
1415
+ candidates.push(m[0]);
1416
+ }
1417
+ }
1418
+ const preferred = candidates.find(
1419
+ (u) => /^https:/.test(u) && !/example\.|localhost|127\.0\.0\.1|test\.|\.invalid/i.test(u)
1420
+ );
1421
+ const fallback = candidates.find((u) => !/example\.|localhost|127\.0\.0\.1|test\.|\.invalid/i.test(u));
1422
+ const chosen = preferred || fallback;
1423
+ if (chosen) {
1424
+ try {
1425
+ this.origin = new URL(chosen).origin;
1426
+ } catch {
1427
+ this.origin = void 0;
1428
+ }
1429
+ }
1430
+ return this.origin;
1431
+ }
1432
+ /** Parses a project file into an AST-slim statement list (for heading discovery). */
1433
+ readModuleFile(filePath) {
1434
+ const info = this.getModule(filePath);
1435
+ return info?.content;
1436
+ }
1437
+ };
1438
+
1439
+ // ../next-adapter/src/enrich.ts
1440
+ function stringLiterals(text) {
1441
+ const out = [];
1442
+ for (const m of text.matchAll(/["'`]([^"'`\n]{1,200})["'`]/g)) out.push(m[1]);
1443
+ return out;
1444
+ }
1445
+ function headingRegexFor(level) {
1446
+ return new RegExp(`<h${level}\\b[^>]*>([\\s\\S]*?)</h${level}>`, "gi");
1447
+ }
1448
+ function stripTags(text) {
1449
+ return text.replace(/<[^>]+>/g, "").replace(/\{[^{}]*\}/g, " ").replace(/\{\{[\s\S]*?\}\}/g, " ").replace(/\s+/g, " ").trim();
1450
+ }
1451
+ function lineAt2(text, index) {
1452
+ return text.slice(0, index).split("\n").length;
1453
+ }
1454
+ function collectInlineHeadings(content) {
1455
+ const headings = [];
1456
+ for (let level = 1; level <= 6; level += 1) {
1457
+ const re = headingRegexFor(level);
1458
+ for (const match of content.matchAll(re)) {
1459
+ headings.push({ level, text: stripTags(match[1]), line: lineAt2(content, match.index || 0) });
1460
+ }
1461
+ }
1462
+ return headings.sort((a, b) => (a.line || 0) - (b.line || 0));
1463
+ }
1464
+ function localComponentImports(content, fromFile, resolver) {
1465
+ const files = [];
1466
+ for (const m of content.matchAll(/import[\s\S]*?from\s+["']([^"']+)["']/g)) {
1467
+ const spec = m[1];
1468
+ const resolved = resolver.resolveModule(spec, fromFile);
1469
+ if (resolved) files.push(resolved);
1470
+ }
1471
+ return files;
1472
+ }
1473
+ function fileHasFlag(content, flag) {
1474
+ const re = new RegExp(`\\b${flag}\\s*=\\s*false\\b`);
1475
+ return re.test(content);
1476
+ }
1477
+ var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1478
+ function addSlugCandidates(params, values) {
1479
+ for (const literal of values) {
1480
+ if (SLUG_RE.test(literal) && literal.length <= 64) params.add(literal);
1481
+ }
1482
+ }
1483
+ function dynamicParamsForFile(filePath, resolver) {
1484
+ const content = resolver.readModuleFile(filePath);
1485
+ if (content === void 0) return { dynamicParams: void 0, generatedParams: [] };
1486
+ const dynamicParams = fileHasFlag(content, "dynamicParams") ? false : void 0;
1487
+ const params = /* @__PURE__ */ new Set();
1488
+ const fnIdx = content.search(/generateStaticParams\s*\(/);
1489
+ if (fnIdx !== -1) {
1490
+ const bodyStart = content.indexOf("{", fnIdx);
1491
+ if (bodyStart !== -1) {
1492
+ const bodyEnd = readBalanced(content, bodyStart);
1493
+ const body = content.slice(bodyStart, bodyEnd);
1494
+ addSlugCandidates(params, stringLiterals(body));
1495
+ }
1496
+ }
1497
+ const visitedFiles = /* @__PURE__ */ new Set([filePath]);
1498
+ const queue = [];
1499
+ const enqueueFile = (file, depth) => {
1500
+ if (!visitedFiles.has(file) && depth <= 4) queue.push({ file, depth });
1501
+ };
1502
+ for (const m of content.matchAll(/import[\s\S]*?from\s+["']([^"']+)["']/g)) {
1503
+ const resolved = resolver.resolveModule(m[1], filePath);
1504
+ if (resolved) enqueueFile(resolved, 1);
1505
+ }
1506
+ let budget = 400;
1507
+ while (queue.length > 0 && budget > 0) {
1508
+ budget -= 1;
1509
+ const { file, depth } = queue.shift();
1510
+ if (visitedFiles.has(file)) continue;
1511
+ visitedFiles.add(file);
1512
+ const nextContent = resolver.readModuleFile(file);
1513
+ if (nextContent === void 0) continue;
1514
+ addSlugCandidates(params, stringLiterals(nextContent));
1515
+ for (const match of nextContent.matchAll(/(?:^|\n)\s*(?:export\s+)?const\s+[A-Za-z_$][\w$]*\s*(?::[^=\n]+)?=\s*\[/g)) {
1516
+ const open = nextContent.indexOf("[", match.index);
1517
+ if (open === -1) continue;
1518
+ const end = readBalanced(nextContent, open);
1519
+ const arrBody = nextContent.slice(open, end + 1);
1520
+ addSlugCandidates(params, stringLiterals(arrBody));
1521
+ }
1522
+ for (const match of nextContent.matchAll(/(?:\bslug\s*|\bid\s*|\bkind\s*|\bhref\s*)\s*:\s*["']([^"'\n]+)["']/g)) {
1523
+ addSlugCandidates(params, [match[1]]);
1524
+ }
1525
+ if (depth < 4) {
1526
+ for (const m of nextContent.matchAll(/import[\s\S]*?from\s+["']([^"']+)["']/g)) {
1527
+ const resolved = resolver.resolveModule(m[1], file);
1528
+ if (resolved) enqueueFile(resolved, depth + 1);
1529
+ }
1530
+ }
1531
+ }
1532
+ return { dynamicParams, generatedParams: Array.from(params) };
1533
+ }
1534
+ function discoverImportedH1(filePath, resolver, maxDepth = 3) {
1535
+ const content = resolver.readModuleFile(filePath);
1536
+ if (content === void 0) return [];
1537
+ const visited = /* @__PURE__ */ new Set([filePath]);
1538
+ const result = [];
1539
+ const queue = localComponentImports(content, filePath, resolver).map(
1540
+ (file) => ({ file, depth: 1 })
1541
+ );
1542
+ while (queue.length > 0) {
1543
+ const { file, depth } = queue.shift();
1544
+ if (depth > maxDepth || visited.has(file)) continue;
1545
+ visited.add(file);
1546
+ const nextContent = resolver.readModuleFile(file);
1547
+ if (nextContent === void 0) continue;
1548
+ const h1s = collectInlineHeadings(nextContent).filter((h) => h.level === 1);
1549
+ if (h1s.length > 0) {
1550
+ result.push(h1s[0]);
1551
+ continue;
1552
+ }
1553
+ for (const next of localComponentImports(nextContent, file, resolver)) {
1554
+ queue.push({ file: next, depth: depth + 1 });
1555
+ }
1556
+ }
1557
+ return result;
1558
+ }
1559
+ function findDefaultExportBody(content) {
1560
+ const idx = content.search(/export\s+default\b/);
1561
+ if (idx === -1) return null;
1562
+ const head = content.slice(idx);
1563
+ if (/export\s+default\s+(?:async\s+)?function\b/.test(head)) {
1564
+ const openParen = head.indexOf("(", head.indexOf("function"));
1565
+ if (openParen === -1) return null;
1566
+ let cursor = readBalanced(head, openParen) + 1;
1567
+ while (cursor < head.length && head[cursor] !== "{") cursor += 1;
1568
+ if (head[cursor] !== "{") return null;
1569
+ const end = readBalanced(head, cursor);
1570
+ return { start: idx + cursor, end: idx + end };
1571
+ }
1572
+ const arrow = head.indexOf("=>");
1573
+ if (arrow !== -1) {
1574
+ let cursor = arrow + 2;
1575
+ while (cursor < head.length && /\s/.test(head[cursor])) cursor += 1;
1576
+ if (head[cursor] === "{") {
1577
+ const end = readBalanced(head, cursor);
1578
+ return { start: idx + cursor, end: idx + end };
1579
+ }
1580
+ }
1581
+ return null;
1582
+ }
1583
+ function computeH1Concurrency(content, h1Lines) {
1584
+ const body = findDefaultExportBody(content);
1585
+ if (!body) return null;
1586
+ const { start, end } = body;
1587
+ const blockStarts = [];
1588
+ const returnRe = /(?:^|[^A-Za-z0-9_$])return\s*(?:\(\s*)?</g;
1589
+ for (const match of content.slice(start, end).matchAll(returnRe)) {
1590
+ const absolute = start + (match.index || 0);
1591
+ blockStarts.push(lineAt2(content, absolute));
1592
+ }
1593
+ if (blockStarts.length === 0) return null;
1594
+ blockStarts.sort((a, b) => a - b);
1595
+ const counts = /* @__PURE__ */ new Map();
1596
+ for (const h1Line of h1Lines) {
1597
+ let owner = blockStarts[0];
1598
+ for (const block of blockStarts) {
1599
+ if (block <= h1Line) owner = block;
1600
+ else break;
1601
+ }
1602
+ counts.set(owner, (counts.get(owner) || 0) + 1);
1603
+ }
1604
+ let max = 0;
1605
+ for (const count of counts.values()) max = Math.max(max, count);
1606
+ return max;
1607
+ }
1608
+
377
1609
  // ../next-adapter/src/scanner.ts
378
1610
  function normalizeRouteGroup(segment) {
379
1611
  return segment.startsWith("(") && segment.endsWith(")") ? "" : segment;
@@ -383,39 +1615,39 @@ var NextJsAdapter = class {
383
1615
  * Detects if the given directory contains a Next.js application.
384
1616
  */
385
1617
  static async detect(projectRoot) {
386
- const pkgPath = path2.join(projectRoot, "package.json");
387
- if (fs2.existsSync(pkgPath)) {
1618
+ const pkgPath = path3.join(projectRoot, "package.json");
1619
+ if (fs3.existsSync(pkgPath)) {
388
1620
  try {
389
- const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf8"));
1621
+ const pkg = JSON.parse(fs3.readFileSync(pkgPath, "utf8"));
390
1622
  const deps = { ...pkg.dependencies || {}, ...pkg.devDependencies || {} };
391
1623
  if (deps.next) return true;
392
1624
  } catch {
393
1625
  }
394
1626
  }
395
- return fs2.existsSync(path2.join(projectRoot, "app")) || fs2.existsSync(path2.join(projectRoot, "src", "app")) || fs2.existsSync(path2.join(projectRoot, "pages")) || fs2.existsSync(path2.join(projectRoot, "src", "pages")) || fs2.existsSync(path2.join(projectRoot, "next.config.js")) || fs2.existsSync(path2.join(projectRoot, "next.config.mjs"));
1627
+ return fs3.existsSync(path3.join(projectRoot, "app")) || fs3.existsSync(path3.join(projectRoot, "src", "app")) || fs3.existsSync(path3.join(projectRoot, "pages")) || fs3.existsSync(path3.join(projectRoot, "src", "pages")) || fs3.existsSync(path3.join(projectRoot, "next.config.js")) || fs3.existsSync(path3.join(projectRoot, "next.config.mjs"));
396
1628
  }
397
1629
  /**
398
1630
  * Loads optional seo.config.ts or returns default configuration.
399
1631
  */
400
1632
  static async loadConfig(projectRoot) {
401
1633
  const configCandidates = [
402
- path2.join(projectRoot, "crawlemon.config.ts"),
403
- path2.join(projectRoot, "crawlemon.config.js"),
404
- path2.join(projectRoot, "crawlemon.config.mjs"),
405
- path2.join(projectRoot, "crawlemon.config.json"),
406
- path2.join(projectRoot, "crawlemon.yml"),
407
- path2.join(projectRoot, "crawlemon.yaml"),
408
- path2.join(projectRoot, "seo.config.ts"),
409
- path2.join(projectRoot, "seo.config.js"),
410
- path2.join(projectRoot, "seo.config.json")
1634
+ path3.join(projectRoot, "crawlemon.config.ts"),
1635
+ path3.join(projectRoot, "crawlemon.config.js"),
1636
+ path3.join(projectRoot, "crawlemon.config.mjs"),
1637
+ path3.join(projectRoot, "crawlemon.config.json"),
1638
+ path3.join(projectRoot, "crawlemon.yml"),
1639
+ path3.join(projectRoot, "crawlemon.yaml"),
1640
+ path3.join(projectRoot, "seo.config.ts"),
1641
+ path3.join(projectRoot, "seo.config.js"),
1642
+ path3.join(projectRoot, "seo.config.json")
411
1643
  ];
412
1644
  for (const candidate of configCandidates) {
413
- if (fs2.existsSync(candidate)) {
1645
+ if (fs3.existsSync(candidate)) {
414
1646
  try {
415
1647
  if (candidate.endsWith(".json")) {
416
- return JSON.parse(fs2.readFileSync(candidate, "utf8"));
1648
+ return JSON.parse(fs3.readFileSync(candidate, "utf8"));
417
1649
  }
418
- const content = fs2.readFileSync(candidate, "utf8");
1650
+ const content = fs3.readFileSync(candidate, "utf8");
419
1651
  const siteUrlMatch = content.match(/siteUrl\s*:\s*["']([^"']+)["']/);
420
1652
  const ignoreMatch = content.match(/ignore\s*:\s*\[([\s\S]*?)\]/);
421
1653
  const ignore = ignoreMatch ? Array.from(ignoreMatch[1].matchAll(/["']([^"']+)["']/g), (match) => match[1]) : void 0;
@@ -463,61 +1695,80 @@ var NextJsAdapter = class {
463
1695
  static async scan(projectRoot) {
464
1696
  const isNext = await this.detect(projectRoot);
465
1697
  const config = await this.loadConfig(projectRoot);
466
- let appDir = path2.join(projectRoot, "app");
467
- if (!fs2.existsSync(appDir) && fs2.existsSync(path2.join(projectRoot, "src", "app"))) {
468
- appDir = path2.join(projectRoot, "src", "app");
1698
+ let appDir = path3.join(projectRoot, "app");
1699
+ if (!fs3.existsSync(appDir) && fs3.existsSync(path3.join(projectRoot, "src", "app"))) {
1700
+ appDir = path3.join(projectRoot, "src", "app");
469
1701
  }
470
- let pagesDir = path2.join(projectRoot, "pages");
471
- if (!fs2.existsSync(pagesDir) && fs2.existsSync(path2.join(projectRoot, "src", "pages"))) {
472
- pagesDir = path2.join(projectRoot, "src", "pages");
1702
+ let pagesDir = path3.join(projectRoot, "pages");
1703
+ if (!fs3.existsSync(pagesDir) && fs3.existsSync(path3.join(projectRoot, "src", "pages"))) {
1704
+ pagesDir = path3.join(projectRoot, "src", "pages");
473
1705
  }
474
- const isAppRouter = fs2.existsSync(appDir);
1706
+ const isAppRouter = fs3.existsSync(appDir);
475
1707
  const routes = [];
476
1708
  const redirects = [];
477
1709
  for (const configName of ["next.config.js", "next.config.mjs", "next.config.ts"]) {
478
- const configPath = path2.join(projectRoot, configName);
479
- if (!fs2.existsSync(configPath)) continue;
480
- const content = fs2.readFileSync(configPath, "utf8");
1710
+ const configPath = path3.join(projectRoot, configName);
1711
+ if (!fs3.existsSync(configPath)) continue;
1712
+ const content = fs3.readFileSync(configPath, "utf8");
481
1713
  for (const match of content.matchAll(/source\s*:\s*["'`]([^"'`]+)["'`][\s\S]{0,500}?destination\s*:\s*["'`]([^"'`]+)["'`][\s\S]{0,200}?permanent\s*:\s*(true|false)/g)) {
482
1714
  redirects.push({ source: match[1], destination: match[2], permanent: match[3] === "true" });
483
1715
  }
484
1716
  break;
485
1717
  }
486
- const dependencyGraph = isAppRouter ? new RouteDependencyGraph(projectRoot, appDir) : void 0;
1718
+ const resolver = isAppRouter ? new ProjectResolver(projectRoot) : void 0;
1719
+ const origin = resolveProjectOrigin(config, resolver);
1720
+ const dependencyGraph = isAppRouter ? new RouteDependencyGraph(projectRoot, appDir, resolver, origin) : void 0;
487
1721
  if (dependencyGraph) {
488
1722
  dependencyGraph.indexLayouts();
489
1723
  }
490
1724
  if (isAppRouter) {
491
1725
  const scanAppDir = (currentDir, relativePath = "") => {
492
- const entries = fs2.readdirSync(currentDir, { withFileTypes: true });
1726
+ const entries = fs3.readdirSync(currentDir, { withFileTypes: true });
493
1727
  for (const entry of entries) {
494
- const fullPath = path2.join(currentDir, entry.name);
1728
+ const fullPath = path3.join(currentDir, entry.name);
495
1729
  if (entry.isDirectory()) {
496
1730
  if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "api") {
497
1731
  continue;
498
1732
  }
499
1733
  const normalized = normalizeRouteGroup(entry.name);
500
- const nextRelative = normalized ? path2.join(relativePath, normalized) : relativePath;
1734
+ const nextRelative = normalized ? path3.join(relativePath, normalized) : relativePath;
501
1735
  scanAppDir(fullPath, nextRelative);
502
1736
  } else if (entry.isFile()) {
503
1737
  if (/^page\.(tsx|jsx|js|ts)$/.test(entry.name)) {
504
1738
  const routePath = relativePath === "" ? "/" : `/${relativePath.replace(/\\/g, "/")}`;
505
- const content = fs2.readFileSync(fullPath, "utf8");
1739
+ const content = fs3.readFileSync(fullPath, "utf8");
506
1740
  const parsed = parsePageSource(content);
507
1741
  const inheritedMetadata = dependencyGraph ? dependencyGraph.getInheritedMetadata(fullPath) : {};
1742
+ let metadata = resolver ? resolvePageMetadata(content, fullPath, inheritedMetadata, { resolver, origin, file: fullPath }) : { ...inheritedMetadata, ...parsed.metadata };
1743
+ if (parsed.metadata.hasConflictingDeclarations) metadata.hasConflictingDeclarations = true;
1744
+ if (parsed.metadata.jsonLd) metadata.jsonLd = parsed.metadata.jsonLd;
1745
+ const headings = [...parsed.headings];
1746
+ if (!headings.some((h) => h.level === 1) && resolver) {
1747
+ headings.push(...discoverImportedH1(fullPath, resolver));
1748
+ }
1749
+ const dynamic = resolver ? dynamicParamsForFile(fullPath, resolver) : void 0;
1750
+ const inlineH1 = parsed.headings.filter((h) => h.level === 1);
1751
+ let maxConcurrentH1;
1752
+ if (inlineH1.length > 0) {
1753
+ const concurrency = computeH1Concurrency(content, inlineH1.map((h) => h.line || 0));
1754
+ maxConcurrentH1 = concurrency === null ? inlineH1.length : concurrency;
1755
+ } else {
1756
+ maxConcurrentH1 = headings.filter((h) => h.level === 1).length;
1757
+ }
508
1758
  routes.push({
509
1759
  route: routePath,
510
1760
  filePath: fullPath,
511
- metadata: {
512
- ...inheritedMetadata,
513
- ...parsed.metadata
514
- },
515
- headings: parsed.headings,
1761
+ metadata,
1762
+ headings,
516
1763
  images: parsed.images,
517
1764
  links: parsed.links,
518
1765
  textContent: parsed.textContent,
519
1766
  hasLittleContent: parsed.hasLittleContent,
520
- hasDynamicSegments: routePath.includes("[")
1767
+ hasDynamicSegments: routePath.includes("["),
1768
+ dynamicParams: dynamic?.dynamicParams,
1769
+ generatedParams: dynamic?.generatedParams,
1770
+ isRedirect: isRedirectOnly(content),
1771
+ maxConcurrentH1
521
1772
  });
522
1773
  }
523
1774
  }
@@ -525,26 +1776,29 @@ var NextJsAdapter = class {
525
1776
  };
526
1777
  scanAppDir(appDir);
527
1778
  }
528
- if (fs2.existsSync(pagesDir)) {
1779
+ if (fs3.existsSync(pagesDir)) {
529
1780
  const scanPagesDir = (currentDir, relativePath = "") => {
530
- const entries = fs2.readdirSync(currentDir, { withFileTypes: true });
1781
+ const entries = fs3.readdirSync(currentDir, { withFileTypes: true });
531
1782
  for (const entry of entries) {
532
- const fullPath = path2.join(currentDir, entry.name);
1783
+ const fullPath = path3.join(currentDir, entry.name);
533
1784
  if (entry.isDirectory()) {
534
1785
  if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "api") {
535
1786
  continue;
536
1787
  }
537
- scanPagesDir(fullPath, path2.join(relativePath, entry.name));
1788
+ scanPagesDir(fullPath, path3.join(relativePath, entry.name));
538
1789
  } else if (entry.isFile()) {
539
1790
  if (/\.(tsx|jsx|js)$/.test(entry.name)) {
540
1791
  const baseName = entry.name.replace(/\.(tsx|jsx|js)$/, "");
541
1792
  if (baseName.startsWith("_") || baseName === "api") {
542
1793
  continue;
543
1794
  }
544
- let routePath = `/${path2.join(relativePath, baseName === "index" ? "" : baseName).replace(/\\/g, "/")}`;
1795
+ let routePath = `/${path3.join(relativePath, baseName === "index" ? "" : baseName).replace(/\\/g, "/")}`;
545
1796
  if (routePath === "//" || routePath === "") routePath = "/";
546
- const content = fs2.readFileSync(fullPath, "utf8");
1797
+ const content = fs3.readFileSync(fullPath, "utf8");
547
1798
  const parsed = parsePageSource(content);
1799
+ const inlineH1 = parsed.headings.filter((h) => h.level === 1);
1800
+ const concurrency = computeH1Concurrency(content, inlineH1.map((h) => h.line || 0));
1801
+ const maxConcurrentH1 = concurrency === null ? inlineH1.length : concurrency;
548
1802
  routes.push({
549
1803
  route: routePath,
550
1804
  filePath: fullPath,
@@ -554,7 +1808,8 @@ var NextJsAdapter = class {
554
1808
  links: parsed.links,
555
1809
  textContent: parsed.textContent,
556
1810
  hasLittleContent: parsed.hasLittleContent,
557
- hasDynamicSegments: routePath.includes("[")
1811
+ hasDynamicSegments: routePath.includes("["),
1812
+ maxConcurrentH1
558
1813
  });
559
1814
  }
560
1815
  }
@@ -563,36 +1818,36 @@ var NextJsAdapter = class {
563
1818
  scanPagesDir(pagesDir);
564
1819
  }
565
1820
  const sitemapCandidates = [
566
- path2.join(appDir, "sitemap.ts"),
567
- path2.join(appDir, "sitemap.js"),
568
- path2.join(projectRoot, "public", "sitemap.xml")
1821
+ path3.join(appDir, "sitemap.ts"),
1822
+ path3.join(appDir, "sitemap.js"),
1823
+ path3.join(projectRoot, "public", "sitemap.xml")
569
1824
  ];
570
1825
  let sitemapFound = false;
571
1826
  let sitemapMalformed = false;
572
1827
  const sitemapUrls = [];
573
1828
  for (const candidate of sitemapCandidates) {
574
- if (fs2.existsSync(candidate)) {
1829
+ if (fs3.existsSync(candidate)) {
575
1830
  sitemapFound = true;
576
- const content = fs2.readFileSync(candidate, "utf8");
1831
+ const content = fs3.readFileSync(candidate, "utf8");
577
1832
  if (candidate.endsWith(".xml") && (!/<urlset\b/i.test(content) || !/<loc>[^<]+<\/loc>/i.test(content))) {
578
1833
  sitemapMalformed = true;
579
1834
  }
580
- const urlMatches = content.matchAll(/url\s*:\s*['"`]([^'"`]+)['"`]|<loc>([^<]+)<\/loc>/gi);
581
- for (const m of urlMatches) {
582
- const url = m[1] || m[2];
583
- if (url) sitemapUrls.push(url.trim());
1835
+ if (candidate.endsWith(".xml")) {
1836
+ for (const m of content.matchAll(/<loc>([^<]+)<\/loc>/gi)) sitemapUrls.push(m[1].trim());
1837
+ } else {
1838
+ resolveTsSitemapUrls(content, candidate, resolver, sitemapUrls, origin);
584
1839
  }
585
1840
  break;
586
1841
  }
587
1842
  }
588
1843
  const robotsCandidates = [
589
- path2.join(appDir, "robots.ts"),
590
- path2.join(appDir, "robots.js"),
591
- path2.join(projectRoot, "public", "robots.txt")
1844
+ path3.join(appDir, "robots.ts"),
1845
+ path3.join(appDir, "robots.js"),
1846
+ path3.join(projectRoot, "public", "robots.txt")
592
1847
  ];
593
- const robotsFile = robotsCandidates.find((candidate) => fs2.existsSync(candidate));
1848
+ const robotsFile = robotsCandidates.find((candidate) => fs3.existsSync(candidate));
594
1849
  const robotsFound = Boolean(robotsFile);
595
- const robotsContent = robotsFile ? fs2.readFileSync(robotsFile, "utf8") : "";
1850
+ const robotsContent = robotsFile ? fs3.readFileSync(robotsFile, "utf8") : "";
596
1851
  routes.sort((a, b) => a.route.localeCompare(b.route));
597
1852
  if (dependencyGraph) {
598
1853
  dependencyGraph.setRoutes(routes);
@@ -613,10 +1868,64 @@ var NextJsAdapter = class {
613
1868
  };
614
1869
  }
615
1870
  };
1871
+ function resolveProjectOrigin(config, resolver) {
1872
+ if (config.siteUrl) {
1873
+ try {
1874
+ const url = new URL(config.siteUrl);
1875
+ if (url.protocol === "http:" || url.protocol === "https:") return url.origin;
1876
+ } catch {
1877
+ }
1878
+ }
1879
+ return resolver ? resolver.guessOrigin(config.siteUrl ? [config.siteUrl] : []) : void 0;
1880
+ }
1881
+ function isRedirectOnly(content) {
1882
+ const usesNextNavigation = /from\s+["']next\/navigation["']/.test(content);
1883
+ if (!usesNextNavigation) return false;
1884
+ const hasRedirectCall = /\b(redirect|permanentRedirect)\s*\(/.test(content);
1885
+ if (!hasRedirectCall) return false;
1886
+ return !/<[A-Za-z]/.test(content);
1887
+ }
1888
+ function resolveTsSitemapUrls(content, file, resolver, out, origin) {
1889
+ const foldWithOrigin = (raw) => {
1890
+ if (!resolver) return void 0;
1891
+ const direct = resolver.foldExpression(file, raw);
1892
+ if (direct.ok && typeof direct.value === "string" && direct.value) return direct.value;
1893
+ if (!origin) return void 0;
1894
+ const substituted = raw.replace(/\$\{\s*([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)\s*\}/g, (whole, inner) => {
1895
+ const last = inner.split(/\.|\s/).pop() || "";
1896
+ if (last === "url" || last === "origin" || last === "URL" || /^siteUrl$/i.test(inner)) return origin;
1897
+ return whole;
1898
+ }).replace(/\b(?:siteConfig|siteURL|siteUrl)\.url\b/g, origin);
1899
+ const retry = resolver.foldExpression(file, substituted);
1900
+ if (retry.ok && typeof retry.value === "string" && retry.value) return retry.value;
1901
+ return void 0;
1902
+ };
1903
+ const pushExpressionUrl = (start) => {
1904
+ const i = skipTrivia(content, start);
1905
+ if (i >= content.length) return;
1906
+ const { end } = parseValueAt(content, i);
1907
+ if (end <= i) return;
1908
+ const raw = content.slice(i, end).trim();
1909
+ const value = foldWithOrigin(raw);
1910
+ if (value) out.push(value);
1911
+ };
1912
+ let search = 0;
1913
+ while (search < content.length) {
1914
+ const idx = content.indexOf("url:", search);
1915
+ if (idx === -1) break;
1916
+ const before = content.slice(Math.max(0, idx - 1), idx);
1917
+ if (!/[{,:\s]/.test(before) || /[A-Za-z0-9_$]/.test(before)) {
1918
+ search = idx + 1;
1919
+ continue;
1920
+ }
1921
+ pushExpressionUrl(idx + "url:".length);
1922
+ search = idx + "url:".length;
1923
+ }
1924
+ }
616
1925
 
617
1926
  // ../next-adapter/src/framework-adapter.ts
618
- import fs3 from "node:fs";
619
- import path3 from "node:path";
1927
+ import fs4 from "node:fs";
1928
+ import path4 from "node:path";
620
1929
  var INHERITED_METADATA_FIELDS = ["title", "description", "canonical", "robots"];
621
1930
  var LABELS = {
622
1931
  nextjs: "Next.js",
@@ -629,23 +1938,23 @@ var LABELS = {
629
1938
  };
630
1939
  function dependencies(root) {
631
1940
  try {
632
- const pkg = JSON.parse(fs3.readFileSync(path3.join(root, "package.json"), "utf8"));
1941
+ const pkg = JSON.parse(fs4.readFileSync(path4.join(root, "package.json"), "utf8"));
633
1942
  return { ...pkg.dependencies || {}, ...pkg.devDependencies || {} };
634
1943
  } catch {
635
1944
  return {};
636
1945
  }
637
1946
  }
638
1947
  function walk(dir, visit) {
639
- if (!fs3.existsSync(dir)) return;
640
- for (const entry of fs3.readdirSync(dir, { withFileTypes: true })) {
1948
+ if (!fs4.existsSync(dir)) return;
1949
+ for (const entry of fs4.readdirSync(dir, { withFileTypes: true })) {
641
1950
  if (entry.name.startsWith(".") || ["node_modules", "dist", "build", ".output"].includes(entry.name)) continue;
642
- const full = path3.join(dir, entry.name);
1951
+ const full = path4.join(dir, entry.name);
643
1952
  if (entry.isDirectory()) walk(full, visit);
644
1953
  else if (entry.isFile()) visit(full);
645
1954
  }
646
1955
  }
647
1956
  function routeNode(file, route, framework) {
648
- const parsed = parsePageSource(fs3.readFileSync(file, "utf8"), framework);
1957
+ const parsed = parsePageSource(fs4.readFileSync(file, "utf8"), framework);
649
1958
  return { route, filePath: file, ...parsed, hasDynamicSegments: /[:[*]/.test(route) };
650
1959
  }
651
1960
  function cleanRoute(value) {
@@ -657,27 +1966,27 @@ function discoverRoutes(root, framework) {
657
1966
  const routes = [];
658
1967
  const add = (base, extensions, convert) => {
659
1968
  walk(base, (file) => {
660
- const relative = path3.relative(base, file);
1969
+ const relative = path4.relative(base, file);
661
1970
  if (!extensions.test(relative) || /(^|\/)api(\/|\.|$)/.test(relative)) return;
662
1971
  routes.push(routeNode(file, convert(relative), framework));
663
1972
  });
664
1973
  };
665
1974
  if (framework === "nuxt") {
666
- const base = fs3.existsSync(path3.join(root, "pages")) ? path3.join(root, "pages") : path3.join(root, "app", "pages");
1975
+ const base = fs4.existsSync(path4.join(root, "pages")) ? path4.join(root, "pages") : path4.join(root, "app", "pages");
667
1976
  add(base, /\.vue$/, (r) => cleanRoute(r.replace(/\.vue$/, "").replace(/\[\.\.\.([^\]]+)\]/g, "*$1").replace(/\[([^\]]+)\]/g, ":$1")));
668
1977
  } else if (framework === "sveltekit") {
669
- const base = path3.join(root, "src", "routes");
1978
+ const base = path4.join(root, "src", "routes");
670
1979
  add(base, /(^|\/)\+page\.svelte$/, (r) => cleanRoute(r.replace(/\/\+page\.svelte$/, "").replace(/^\+page\.svelte$/, "")));
671
1980
  } else if (framework === "astro") {
672
- const base = path3.join(root, "src", "pages");
1981
+ const base = path4.join(root, "src", "pages");
673
1982
  add(base, /\.(astro|md|mdx)$/, (r) => cleanRoute(r.replace(/\.(astro|md|mdx)$/, "")));
674
1983
  } else if (framework === "remix") {
675
- const base = path3.join(root, "app", "routes");
1984
+ const base = path4.join(root, "app", "routes");
676
1985
  add(base, /\.(tsx|jsx|ts|js)$/, (r) => cleanRoute(r.replace(/\.(tsx|jsx|ts|js)$/, "").replace(/\._index$/, "").replace(/^_index$/, "").replace(/\./g, "/").replace(/\$([^/]+)/g, ":$1")));
677
1986
  } else if (framework === "vite") {
678
1987
  for (const candidate of ["src/App.tsx", "src/App.jsx", "src/App.vue", "src/App.svelte", "index.html"]) {
679
- const file = path3.join(root, candidate);
680
- if (fs3.existsSync(file)) routes.push(routeNode(file, "/", framework));
1988
+ const file = path4.join(root, candidate);
1989
+ if (fs4.existsSync(file)) routes.push(routeNode(file, "/", framework));
681
1990
  }
682
1991
  } else {
683
1992
  add(root, /\.html?$/, (r) => cleanRoute(r.replace(/\.html?$/, "")));
@@ -687,30 +1996,30 @@ function discoverRoutes(root, framework) {
687
1996
  }
688
1997
  function svelteKitLayouts(root) {
689
1998
  const layoutByDir = /* @__PURE__ */ new Map();
690
- const base = path3.join(root, "src", "routes");
1999
+ const base = path4.join(root, "src", "routes");
691
2000
  const readLayout = (dir) => {
692
- const layoutFile = path3.join(dir, "+layout.svelte");
693
- if (fs3.existsSync(layoutFile)) {
694
- layoutByDir.set(dir, parsePageSource(fs3.readFileSync(layoutFile, "utf8"), "sveltekit").metadata);
2001
+ const layoutFile = path4.join(dir, "+layout.svelte");
2002
+ if (fs4.existsSync(layoutFile)) {
2003
+ layoutByDir.set(dir, parsePageSource(fs4.readFileSync(layoutFile, "utf8"), "sveltekit").metadata);
695
2004
  }
696
- for (const entry of fs3.readdirSync(dir, { withFileTypes: true })) {
2005
+ for (const entry of fs4.readdirSync(dir, { withFileTypes: true })) {
697
2006
  if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
698
- readLayout(path3.join(dir, entry.name));
2007
+ readLayout(path4.join(dir, entry.name));
699
2008
  }
700
2009
  }
701
2010
  };
702
- if (fs3.existsSync(base)) readLayout(base);
2011
+ if (fs4.existsSync(base)) readLayout(base);
703
2012
  return layoutByDir;
704
2013
  }
705
2014
  function mergeLayoutMetadata(routes, framework, root) {
706
2015
  if (framework !== "sveltekit") return routes;
707
2016
  const layoutByDir = svelteKitLayouts(root);
708
2017
  if (layoutByDir.size === 0) return routes;
709
- const base = path3.join(root, "src", "routes");
2018
+ const base = path4.join(root, "src", "routes");
710
2019
  return routes.map((route) => {
711
2020
  const merged = {};
712
- let dir = path3.dirname(route.filePath);
713
- while (dir === base || dir.startsWith(`${base}${path3.sep}`)) {
2021
+ let dir = path4.dirname(route.filePath);
2022
+ while (dir === base || dir.startsWith(`${base}${path4.sep}`)) {
714
2023
  const meta = layoutByDir.get(dir);
715
2024
  if (meta) {
716
2025
  for (const field of INHERITED_METADATA_FIELDS) {
@@ -718,7 +2027,7 @@ function mergeLayoutMetadata(routes, framework, root) {
718
2027
  }
719
2028
  }
720
2029
  if (dir === base) break;
721
- dir = path3.dirname(dir);
2030
+ dir = path4.dirname(dir);
722
2031
  }
723
2032
  if (Object.keys(merged).length === 0) return route;
724
2033
  for (const field of INHERITED_METADATA_FIELDS) {
@@ -728,16 +2037,16 @@ function mergeLayoutMetadata(routes, framework, root) {
728
2037
  });
729
2038
  }
730
2039
  function crawlFiles(root) {
731
- const roots = [path3.join(root, "public"), path3.join(root, "static"), root];
732
- const sitemap = roots.map((dir) => path3.join(dir, "sitemap.xml")).find(fs3.existsSync);
733
- const robots = roots.map((dir) => path3.join(dir, "robots.txt")).find(fs3.existsSync);
734
- const sitemapContent = sitemap ? fs3.readFileSync(sitemap, "utf8") : "";
2040
+ const roots = [path4.join(root, "public"), path4.join(root, "static"), root];
2041
+ const sitemap = roots.map((dir) => path4.join(dir, "sitemap.xml")).find(fs4.existsSync);
2042
+ const robots = roots.map((dir) => path4.join(dir, "robots.txt")).find(fs4.existsSync);
2043
+ const sitemapContent = sitemap ? fs4.readFileSync(sitemap, "utf8") : "";
735
2044
  return {
736
2045
  sitemapFound: Boolean(sitemap),
737
2046
  sitemapUrls: [...sitemapContent.matchAll(/<loc>([^<]+)<\/loc>/gi)].map((m) => m[1].trim()),
738
2047
  sitemapMalformed: Boolean(sitemap && (!/<urlset\b/i.test(sitemapContent) || !/<loc>[^<]+<\/loc>/i.test(sitemapContent))),
739
2048
  robotsFound: Boolean(robots),
740
- robotsContent: robots ? fs3.readFileSync(robots, "utf8") : ""
2049
+ robotsContent: robots ? fs4.readFileSync(robots, "utf8") : ""
741
2050
  };
742
2051
  }
743
2052
  var FrameworkAdapter = class {
@@ -750,11 +2059,11 @@ var FrameworkAdapter = class {
750
2059
  if (deps["@remix-run/react"] || deps["@remix-run/node"]) return "remix";
751
2060
  if (deps.vite) return "vite";
752
2061
  if (await NextJsAdapter.detect(root)) return "nextjs";
753
- if (fs3.existsSync(path3.join(root, "nuxt.config.ts")) || fs3.existsSync(path3.join(root, "nuxt.config.js"))) return "nuxt";
754
- if (fs3.existsSync(path3.join(root, "svelte.config.js")) && fs3.existsSync(path3.join(root, "src", "routes"))) {
2062
+ if (fs4.existsSync(path4.join(root, "nuxt.config.ts")) || fs4.existsSync(path4.join(root, "nuxt.config.js"))) return "nuxt";
2063
+ if (fs4.existsSync(path4.join(root, "svelte.config.js")) && fs4.existsSync(path4.join(root, "src", "routes"))) {
755
2064
  return "sveltekit";
756
2065
  }
757
- if (fs3.existsSync(path3.join(root, "index.html"))) return "static";
2066
+ if (fs4.existsSync(path4.join(root, "index.html"))) return "static";
758
2067
  return null;
759
2068
  }
760
2069
  static async scan(root) {
@@ -790,8 +2099,12 @@ var metadataTitleRule = {
790
2099
  const findings = [];
791
2100
  const titleMap = /* @__PURE__ */ new Map();
792
2101
  for (const route of context.routes) {
2102
+ if (route.isRedirect) continue;
793
2103
  const title = route.metadata.title;
2104
+ const dynamic = route.metadata.dynamicMetadata === true;
2105
+ const robots = (route.metadata.robots || "").toLowerCase();
794
2106
  if (!title || title.trim() === "") {
2107
+ if (route.metadata.titleDeclared || dynamic) continue;
795
2108
  findings.push({
796
2109
  id: `title-missing-${route.route}`,
797
2110
  rule: "metadata-title",
@@ -806,9 +2119,13 @@ var metadataTitleRule = {
806
2119
  continue;
807
2120
  }
808
2121
  const trimmed = title.trim();
809
- const existing = titleMap.get(trimmed) || [];
810
- existing.push(route.route);
811
- titleMap.set(trimmed, existing);
2122
+ const indexable = !robots.includes("noindex");
2123
+ if (route.metadata.titleDeclared && !dynamic && indexable) {
2124
+ const existing = titleMap.get(trimmed) || [];
2125
+ existing.push(route.route);
2126
+ titleMap.set(trimmed, existing);
2127
+ }
2128
+ if (dynamic) continue;
812
2129
  if (trimmed.length < 10) {
813
2130
  findings.push({
814
2131
  id: `title-short-${route.route}`,
@@ -864,8 +2181,12 @@ var metadataDescriptionRule = {
864
2181
  const findings = [];
865
2182
  const descMap = /* @__PURE__ */ new Map();
866
2183
  for (const route of context.routes) {
2184
+ if (route.isRedirect) continue;
867
2185
  const desc = route.metadata.description;
2186
+ const dynamic = route.metadata.dynamicMetadata === true;
2187
+ const robots = (route.metadata.robots || "").toLowerCase();
868
2188
  if (!desc || desc.trim() === "") {
2189
+ if (route.metadata.descriptionDeclared || dynamic) continue;
869
2190
  findings.push({
870
2191
  id: `desc-missing-${route.route}`,
871
2192
  rule: "metadata-description",
@@ -880,9 +2201,13 @@ var metadataDescriptionRule = {
880
2201
  continue;
881
2202
  }
882
2203
  const trimmed = desc.trim();
883
- const existing = descMap.get(trimmed) || [];
884
- existing.push(route.route);
885
- descMap.set(trimmed, existing);
2204
+ const indexable = !robots.includes("noindex");
2205
+ if (route.metadata.descriptionDeclared && !dynamic && indexable) {
2206
+ const existing = descMap.get(trimmed) || [];
2207
+ existing.push(route.route);
2208
+ descMap.set(trimmed, existing);
2209
+ }
2210
+ if (dynamic) continue;
886
2211
  if (trimmed.length < 50) {
887
2212
  findings.push({
888
2213
  id: `desc-short-${route.route}`,
@@ -956,8 +2281,12 @@ var canonicalRule = {
956
2281
  }
957
2282
  }
958
2283
  for (const route of context.routes) {
2284
+ if (route.isRedirect) continue;
2285
+ if ((route.metadata.robots || "").toLowerCase().includes("noindex")) continue;
2286
+ if (route.metadata.dynamicMetadata) continue;
959
2287
  const canonical = route.metadata.canonical;
960
2288
  if (!canonical || canonical.trim() === "") {
2289
+ if (route.metadata.canonicalDeclared) continue;
961
2290
  findings.push({
962
2291
  id: `canonical-missing-${route.route}`,
963
2292
  rule: "canonical",
@@ -1031,6 +2360,20 @@ var canonicalRule = {
1031
2360
  }
1032
2361
  }
1033
2362
  } catch {
2363
+ const looksLikePath = canonical.startsWith("/");
2364
+ if (looksLikePath) {
2365
+ findings.push({
2366
+ id: `canonical-relative-no-base-${route.route}`,
2367
+ rule: "canonical",
2368
+ severity: "warning",
2369
+ category: "technical",
2370
+ message: `Canonical URL "${canonical}" on route "${route.route}" is relative and no siteUrl/metadataBase was found to resolve it.`,
2371
+ file: route.filePath,
2372
+ route: route.route,
2373
+ fixable: false
2374
+ });
2375
+ continue;
2376
+ }
1034
2377
  findings.push({
1035
2378
  id: `canonical-malformed-${route.route}`,
1036
2379
  rule: "canonical",
@@ -1069,6 +2412,7 @@ var headingsRule = {
1069
2412
  analyze(context) {
1070
2413
  const findings = [];
1071
2414
  for (const route of context.routes) {
2415
+ if (route.isRedirect) continue;
1072
2416
  const headings = route.headings;
1073
2417
  const h1s = headings.filter((h) => h.level === 1);
1074
2418
  if (h1s.length === 0) {
@@ -1083,19 +2427,22 @@ var headingsRule = {
1083
2427
  fixable: false,
1084
2428
  explanation: "Every page should have exactly one <h1> element defining its primary topic for search engines and accessibility."
1085
2429
  });
1086
- } else if (h1s.length > 1) {
1087
- findings.push({
1088
- id: `heading-multiple-h1-${route.route}`,
1089
- rule: "headings",
1090
- severity: "warning",
1091
- category: "content",
1092
- message: `Found ${h1s.length} <h1> tags on route "${route.route}". Best practice is a single prominent <h1>.`,
1093
- file: route.filePath,
1094
- line: h1s[1].line,
1095
- route: route.route,
1096
- fixable: false,
1097
- explanation: "Multiple <h1> tags can dilute topical hierarchy and confuse screen readers."
1098
- });
2430
+ } else {
2431
+ const effectiveCount = route.maxConcurrentH1 !== void 0 ? Math.max(route.maxConcurrentH1, h1s.length > 0 ? 1 : 0) : h1s.length;
2432
+ if (effectiveCount > 1) {
2433
+ findings.push({
2434
+ id: `heading-multiple-h1-${route.route}`,
2435
+ rule: "headings",
2436
+ severity: "warning",
2437
+ category: "content",
2438
+ message: `Found ${effectiveCount} <h1> tags on route "${route.route}". Best practice is a single prominent <h1>.`,
2439
+ file: route.filePath,
2440
+ line: h1s[1].line,
2441
+ route: route.route,
2442
+ fixable: false,
2443
+ explanation: "Multiple <h1> tags can dilute topical hierarchy and confuse screen readers."
2444
+ });
2445
+ }
1099
2446
  }
1100
2447
  let prevLevel = 1;
1101
2448
  for (const h of headings) {
@@ -1157,7 +2504,9 @@ var imagesRule = {
1157
2504
  // Never invent alt descriptions without AI
1158
2505
  explanation: "Missing alt attributes harm accessibility and prevent images from ranking in Google Image Search."
1159
2506
  });
1160
- } else if (img.alt.trim() === "" && !img.src.includes("icon") && !img.src.includes("decorative")) {
2507
+ } else if (img.alt.trim() === "" && !img.src.includes("icon") && !img.src.includes("decorative") && // A runtime/dynamic src (e.g. favicon from data) cannot be judged;
2508
+ // empty alt for it is commonly the intended decorative usage.
2509
+ !img.src.includes("unknown-image")) {
1161
2510
  findings.push({
1162
2511
  id: `img-empty-alt-${route.route}-${img.src}`,
1163
2512
  rule: "images",
@@ -1177,6 +2526,44 @@ var imagesRule = {
1177
2526
  }
1178
2527
  };
1179
2528
 
2529
+ // ../core/src/route-match.ts
2530
+ function segmentsOf(route) {
2531
+ return route.split("/").filter((segment) => segment !== "");
2532
+ }
2533
+ function normalizeHref(href) {
2534
+ const clean = href.split("?")[0].split("#")[0];
2535
+ const withoutSlash = clean.length > 1 && clean.endsWith("/") ? clean.slice(0, -1) : clean;
2536
+ return withoutSlash === "" ? "/" : withoutSlash;
2537
+ }
2538
+ function isCatchAllSegment(segment) {
2539
+ return segment.startsWith("[...") || segment.startsWith("[[...");
2540
+ }
2541
+ function isDynamicSegment(segment) {
2542
+ return segment.startsWith("[") && segment.endsWith("]") || isCatchAllSegment(segment);
2543
+ }
2544
+ function routePatternMatches(pattern, href) {
2545
+ const patSegs = segmentsOf(pattern);
2546
+ const hrefSegs = segmentsOf(href);
2547
+ let hi = 0;
2548
+ for (let pi = 0; pi < patSegs.length; pi += 1) {
2549
+ const seg = patSegs[pi];
2550
+ if (seg.startsWith("[[...") && seg.endsWith("]")) {
2551
+ return true;
2552
+ }
2553
+ if (seg.startsWith("[...") && seg.endsWith("]")) {
2554
+ return hi < hrefSegs.length;
2555
+ }
2556
+ if (hi >= hrefSegs.length) return false;
2557
+ if (isDynamicSegment(seg)) {
2558
+ hi += 1;
2559
+ continue;
2560
+ }
2561
+ if (seg !== hrefSegs[hi]) return false;
2562
+ hi += 1;
2563
+ }
2564
+ return hi === hrefSegs.length;
2565
+ }
2566
+
1180
2567
  // ../core/src/rules/links.ts
1181
2568
  var linksRule = {
1182
2569
  id: "links",
@@ -1184,11 +2571,29 @@ var linksRule = {
1184
2571
  category: "links",
1185
2572
  analyze(context) {
1186
2573
  const findings = [];
1187
- const validRoutes = new Set(context.routes.map((r) => r.route));
1188
2574
  const normalizeRoute2 = (r) => r.endsWith("/") && r.length > 1 ? r.slice(0, -1) : r;
1189
- const normalizedValidRoutes = new Set([...validRoutes].map(normalizeRoute2));
2575
+ const validRoutes = new Set(context.routes.map((r) => normalizeRoute2(r.route)));
1190
2576
  const redirects = new Map((context.redirects || []).map((redirect) => [normalizeRoute2(redirect.source), redirect.destination]));
2577
+ const dynamicRoutes = context.routes.filter((r) => r.hasDynamicSegments);
2578
+ const dynamicByPattern = new Map(dynamicRoutes.map((r) => [normalizeRoute2(r.route), r]));
2579
+ const isDynamicReachable = (cleanHref) => {
2580
+ for (const [pattern, routeNode2] of dynamicByPattern) {
2581
+ if (routePatternMatches(pattern, cleanHref)) {
2582
+ if (routeNode2.dynamicParams !== false) return { reachable: true, route: routeNode2 };
2583
+ const candidates = new Set(routeNode2.generatedParams || []);
2584
+ const targetSegment = cleanHref.split("/").pop() || "";
2585
+ if (targetSegment !== "" && candidates.has(targetSegment)) return { reachable: true, route: routeNode2 };
2586
+ return {
2587
+ reachable: false,
2588
+ route: routeNode2,
2589
+ reason: `route "${pattern}" only renders its generateStaticParams output and "${targetSegment}" could not be verified`
2590
+ };
2591
+ }
2592
+ }
2593
+ return { reachable: false };
2594
+ };
1191
2595
  for (const route of context.routes) {
2596
+ if (route.isRedirect) continue;
1192
2597
  for (const link of route.links) {
1193
2598
  if (!link.isInternal) continue;
1194
2599
  const href = link.href.trim();
@@ -1207,7 +2612,7 @@ var linksRule = {
1207
2612
  });
1208
2613
  continue;
1209
2614
  }
1210
- const cleanHref = normalizeRoute2(href.split("?")[0].split("#")[0]);
2615
+ const cleanHref = normalizeHref(href);
1211
2616
  if (cleanHref.startsWith("/")) {
1212
2617
  const redirectTarget = redirects.get(cleanHref);
1213
2618
  if (redirectTarget) {
@@ -1223,10 +2628,28 @@ var linksRule = {
1223
2628
  fixable: false,
1224
2629
  explanation: "Link directly to the final internal route to avoid unnecessary crawl hops."
1225
2630
  });
2631
+ continue;
1226
2632
  }
1227
- if (!normalizedValidRoutes.has(cleanHref) && !redirectTarget) {
2633
+ if (!validRoutes.has(cleanHref)) {
2634
+ const dynamic = isDynamicReachable(cleanHref);
2635
+ if (dynamic.reachable) continue;
2636
+ if (dynamic.route) {
2637
+ findings.push({
2638
+ id: `link-unverifiable-dynamic-${route.route}-${cleanHref}`,
2639
+ rule: "links",
2640
+ severity: "warning",
2641
+ category: "links",
2642
+ message: `Internal link "${href}" on "${route.route}" targets dynamic ${dynamic.route.route}, but ${dynamic.reason}.`,
2643
+ file: route.filePath,
2644
+ line: link.line,
2645
+ route: route.route,
2646
+ fixable: false,
2647
+ explanation: "The target route renders only its generateStaticParams output; this link cannot be statically verified as reachable."
2648
+ });
2649
+ continue;
2650
+ }
1228
2651
  let isTypoFixable = false;
1229
- for (const valid of normalizedValidRoutes) {
2652
+ for (const valid of validRoutes) {
1230
2653
  if (valid.toLowerCase() === cleanHref.toLowerCase()) {
1231
2654
  isTypoFixable = true;
1232
2655
  break;
@@ -1324,11 +2747,13 @@ var crawlabilityRule = {
1324
2747
  });
1325
2748
  }
1326
2749
  const validRoutes = new Set(context.routes.map((r) => r.route));
2750
+ const dynamicPatterns = context.routes.filter((r) => r.hasDynamicSegments).map((r) => r.route);
1327
2751
  for (const url of context.sitemapUrls) {
1328
2752
  try {
1329
2753
  const pathname = url.startsWith("http") ? new URL(url).pathname : url;
1330
- const clean = pathname.endsWith("/") && pathname.length > 1 ? pathname.slice(0, -1) : pathname;
1331
- if (!validRoutes.has(clean) && clean !== "") {
2754
+ const clean = normalizeHref(pathname);
2755
+ const isKnown = validRoutes.has(clean) || dynamicPatterns.some((pattern) => routePatternMatches(pattern, clean));
2756
+ if (!isKnown && clean !== "" && clean !== "/") {
1332
2757
  findings.push({
1333
2758
  id: `sitemap-orphan-url-${clean}`,
1334
2759
  rule: "crawlability",
@@ -1637,7 +3062,7 @@ function buildLinkGraph(routes, redirects = []) {
1637
3062
  outgoingCount[node] = 0;
1638
3063
  adjacencyList[node] = [];
1639
3064
  }
1640
- const normalize = (path9) => path9.endsWith("/") && path9.length > 1 ? path9.slice(0, -1) : path9;
3065
+ const normalize = (path10) => path10.endsWith("/") && path10.length > 1 ? path10.slice(0, -1) : path10;
1641
3066
  const redirectMap = new Map(redirects.map((redirect) => [normalize(redirect.source), normalize(redirect.destination)]));
1642
3067
  for (const route of routes) {
1643
3068
  const source = route.route;
@@ -1903,8 +3328,8 @@ function detectContentOpportunities(routes, providedKeywords = {}) {
1903
3328
  }
1904
3329
 
1905
3330
  // ../core/src/fixer.ts
1906
- import path4 from "node:path";
1907
- import fs4 from "node:fs";
3331
+ import path5 from "node:path";
3332
+ import fs5 from "node:fs";
1908
3333
  function createUnifiedDiff(filename, oldText, newText) {
1909
3334
  const oldLines = oldText ? oldText.split("\n") : [];
1910
3335
  const newLines = newText ? newText.split("\n") : [];
@@ -1958,7 +3383,7 @@ function generateRevertSafeFix(options) {
1958
3383
  filePath,
1959
3384
  originalContent: headContent,
1960
3385
  newContent: updated,
1961
- diff: createUnifiedDiff(path4.relative(projectRoot, filePath), headContent, updated),
3386
+ diff: createUnifiedDiff(path5.relative(projectRoot, filePath), headContent, updated),
1962
3387
  description: `Restored canonical value "${canonicalVal}" from BASE revision.`
1963
3388
  };
1964
3389
  }
@@ -1973,7 +3398,7 @@ function generateRevertSafeFix(options) {
1973
3398
  filePath,
1974
3399
  originalContent: headContent,
1975
3400
  newContent: updated,
1976
- diff: createUnifiedDiff(path4.relative(projectRoot, filePath), headContent, updated),
3401
+ diff: createUnifiedDiff(path5.relative(projectRoot, filePath), headContent, updated),
1977
3402
  description: "Restored indexable robots directive present in BASE revision."
1978
3403
  };
1979
3404
  }
@@ -2072,19 +3497,19 @@ ${content}`;
2072
3497
  }
2073
3498
  function assetLayout(projectRoot, framework, isAppRouter) {
2074
3499
  if (framework === "nextjs" && isAppRouter) {
2075
- const appDirectory = fs4.existsSync(path4.join(projectRoot, "app")) ? path4.join(projectRoot, "app") : path4.join(projectRoot, "src", "app");
3500
+ const appDirectory = fs5.existsSync(path5.join(projectRoot, "app")) ? path5.join(projectRoot, "app") : path5.join(projectRoot, "src", "app");
2076
3501
  return { kind: "app-router", directory: appDirectory };
2077
3502
  }
2078
3503
  let directory;
2079
3504
  switch (framework) {
2080
3505
  case "sveltekit":
2081
- directory = path4.join(projectRoot, "static");
3506
+ directory = path5.join(projectRoot, "static");
2082
3507
  break;
2083
3508
  case "static":
2084
3509
  directory = projectRoot;
2085
3510
  break;
2086
3511
  default:
2087
- directory = path4.join(projectRoot, "public");
3512
+ directory = path5.join(projectRoot, "public");
2088
3513
  }
2089
3514
  return { kind: "file", directory };
2090
3515
  }
@@ -2115,21 +3540,21 @@ async function applySafeFixes(options) {
2115
3540
  const fixedFindingIds = /* @__PURE__ */ new Set();
2116
3541
  const virtualFiles = /* @__PURE__ */ new Map();
2117
3542
  const safeFile = (candidate) => {
2118
- const root = path4.resolve(projectRoot);
2119
- const resolved = path4.resolve(candidate);
2120
- return resolved === root || resolved.startsWith(`${root}${path4.sep}`) ? resolved : null;
3543
+ const root = path5.resolve(projectRoot);
3544
+ const resolved = path5.resolve(candidate);
3545
+ return resolved === root || resolved.startsWith(`${root}${path5.sep}`) ? resolved : null;
2121
3546
  };
2122
3547
  const readCurrent = (candidate) => {
2123
3548
  if (virtualFiles.has(candidate)) return virtualFiles.get(candidate);
2124
- return fs4.existsSync(candidate) ? fs4.readFileSync(candidate, "utf8") : "";
3549
+ return fs5.existsSync(candidate) ? fs5.readFileSync(candidate, "utf8") : "";
2125
3550
  };
2126
3551
  const recordChange = (change, findingId) => {
2127
3552
  appliedChanges.push(change);
2128
3553
  fixedFindingIds.add(findingId);
2129
3554
  virtualFiles.set(change.filePath, change.newContent);
2130
3555
  if (!dryRun) {
2131
- fs4.mkdirSync(path4.dirname(change.filePath), { recursive: true });
2132
- fs4.writeFileSync(change.filePath, change.newContent, "utf8");
3556
+ fs5.mkdirSync(path5.dirname(change.filePath), { recursive: true });
3557
+ fs5.writeFileSync(change.filePath, change.newContent, "utf8");
2133
3558
  }
2134
3559
  };
2135
3560
  const layout = assetLayout(projectRoot, framework, isAppRouter);
@@ -2140,7 +3565,7 @@ async function applySafeFixes(options) {
2140
3565
  for (const f of revertSafeFindings) {
2141
3566
  const targetFile = f.file ? safeFile(f.file) : null;
2142
3567
  if (!targetFile) continue;
2143
- const relPath = path4.relative(projectRoot, targetFile).replace(/\\/g, "/");
3568
+ const relPath = path5.relative(projectRoot, targetFile).replace(/\\/g, "/");
2144
3569
  const baseContent = options.baseFiles.get(relPath) || options.baseFiles.get(targetFile);
2145
3570
  if (!baseContent) continue;
2146
3571
  const headContent = readCurrent(targetFile);
@@ -2159,9 +3584,9 @@ async function applySafeFixes(options) {
2159
3584
  const robotsFinding = findings.find((f) => f.rule === "crawlability" && f.id === "robots-missing");
2160
3585
  if (robotsFinding) {
2161
3586
  if (layout.kind === "app-router") {
2162
- const robotsFilePath = path4.join(layout.directory, "robots.ts");
2163
- const relativePath = path4.relative(projectRoot, robotsFilePath);
2164
- const oldContent = fs4.existsSync(robotsFilePath) ? fs4.readFileSync(robotsFilePath, "utf8") : "";
3587
+ const robotsFilePath = path5.join(layout.directory, "robots.ts");
3588
+ const relativePath = path5.relative(projectRoot, robotsFilePath);
3589
+ const oldContent = fs5.existsSync(robotsFilePath) ? fs5.readFileSync(robotsFilePath, "utf8") : "";
2165
3590
  const sitemapLine = siteUrl ? `
2166
3591
  sitemap: '${siteUrl}/sitemap.xml',` : "";
2167
3592
  const newContent = `import { MetadataRoute } from 'next';
@@ -2185,9 +3610,9 @@ export default function robots(): MetadataRoute.Robots {
2185
3610
  description: "Generated app/robots.ts with standard crawler rules and sitemap reference."
2186
3611
  }, robotsFinding.id);
2187
3612
  } else {
2188
- const robotsFilePath = path4.join(layout.directory, "robots.txt");
2189
- const relativePath = path4.relative(projectRoot, robotsFilePath);
2190
- const oldContent = fs4.existsSync(robotsFilePath) ? fs4.readFileSync(robotsFilePath, "utf8") : "";
3613
+ const robotsFilePath = path5.join(layout.directory, "robots.txt");
3614
+ const relativePath = path5.relative(projectRoot, robotsFilePath);
3615
+ const oldContent = fs5.existsSync(robotsFilePath) ? fs5.readFileSync(robotsFilePath, "utf8") : "";
2191
3616
  const newContent = ROBOTS_TXT(siteUrl);
2192
3617
  recordChange({
2193
3618
  filePath: robotsFilePath,
@@ -2203,9 +3628,9 @@ export default function robots(): MetadataRoute.Robots {
2203
3628
  );
2204
3629
  if (sitemapFinding && siteUrl) {
2205
3630
  if (layout.kind === "app-router") {
2206
- const sitemapFilePath = path4.join(layout.directory, "sitemap.ts");
2207
- const relativePath = path4.relative(projectRoot, sitemapFilePath);
2208
- const oldContent = fs4.existsSync(sitemapFilePath) ? fs4.readFileSync(sitemapFilePath, "utf8") : "";
3631
+ const sitemapFilePath = path5.join(layout.directory, "sitemap.ts");
3632
+ const relativePath = path5.relative(projectRoot, sitemapFilePath);
3633
+ const oldContent = fs5.existsSync(sitemapFilePath) ? fs5.readFileSync(sitemapFilePath, "utf8") : "";
2209
3634
  const routeEntries = validRoutes.map(
2210
3635
  (r) => ` {
2211
3636
  url: '${siteUrl}${r === "/" ? "" : r}',
@@ -2230,9 +3655,9 @@ ${routeEntries}
2230
3655
  description: `Generated app/sitemap.ts containing ${validRoutes.length} discovered routes.`
2231
3656
  }, sitemapFinding.id);
2232
3657
  } else {
2233
- const sitemapFilePath = path4.join(layout.directory, "sitemap.xml");
2234
- const relativePath = path4.relative(projectRoot, sitemapFilePath);
2235
- const oldContent = fs4.existsSync(sitemapFilePath) ? fs4.readFileSync(sitemapFilePath, "utf8") : "";
3658
+ const sitemapFilePath = path5.join(layout.directory, "sitemap.xml");
3659
+ const relativePath = path5.relative(projectRoot, sitemapFilePath);
3660
+ const oldContent = fs5.existsSync(sitemapFilePath) ? fs5.readFileSync(sitemapFilePath, "utf8") : "";
2236
3661
  const xmlEntries = validRoutes.map(
2237
3662
  (r) => ` <url>
2238
3663
  <loc>${siteUrl}${r === "/" ? "" : r}</loc>
@@ -2258,7 +3683,7 @@ ${xmlEntries}
2258
3683
  const canonicalFindings = findings.filter((f) => f.rule === "canonical" && f.fixable && f.file);
2259
3684
  for (const finding of canonicalFindings) {
2260
3685
  const findingFile = finding.file ? safeFile(finding.file) : null;
2261
- if (!findingFile || !fs4.existsSync(findingFile)) continue;
3686
+ if (!findingFile || !fs5.existsSync(findingFile)) continue;
2262
3687
  const fileContent = readCurrent(findingFile);
2263
3688
  const route = finding.route || "/";
2264
3689
  const canonicalUrl = `${siteUrl}${route === "/" ? "" : route}`;
@@ -2268,8 +3693,8 @@ ${xmlEntries}
2268
3693
  filePath: findingFile,
2269
3694
  originalContent: fileContent,
2270
3695
  newContent: updatedContent,
2271
- diff: createUnifiedDiff(path4.relative(projectRoot, findingFile), fileContent, updatedContent),
2272
- description: `Added canonical "${canonicalUrl}" declaration to ${path4.basename(findingFile)}.`
3696
+ diff: createUnifiedDiff(path5.relative(projectRoot, findingFile), fileContent, updatedContent),
3697
+ description: `Added canonical "${canonicalUrl}" declaration to ${path5.basename(findingFile)}.`
2273
3698
  }, finding.id);
2274
3699
  }
2275
3700
  }
@@ -2277,7 +3702,7 @@ ${xmlEntries}
2277
3702
  const fixableLinkFindings = findings.filter((f) => f.rule === "links" && f.fixable && f.file);
2278
3703
  for (const lf of fixableLinkFindings) {
2279
3704
  const linkFile = lf.file ? safeFile(lf.file) : null;
2280
- if (!linkFile || !fs4.existsSync(linkFile)) continue;
3705
+ if (!linkFile || !fs5.existsSync(linkFile)) continue;
2281
3706
  const fileContent = readCurrent(linkFile);
2282
3707
  const match = lf.message.match(/nonexistent route "([^"]+)"/);
2283
3708
  if (match) {
@@ -2296,7 +3721,7 @@ ${xmlEntries}
2296
3721
  filePath: linkFile,
2297
3722
  originalContent: fileContent,
2298
3723
  newContent: updatedContent,
2299
- diff: createUnifiedDiff(path4.relative(projectRoot, linkFile), fileContent, updatedContent),
3724
+ diff: createUnifiedDiff(path5.relative(projectRoot, linkFile), fileContent, updatedContent),
2300
3725
  description: `Fixed casing of internal link: "${brokenHref}" -> "${normalizedTarget}".`
2301
3726
  }, lf.id);
2302
3727
  }
@@ -2446,7 +3871,7 @@ function runSEOAudit(options) {
2446
3871
  recommendations,
2447
3872
  opportunities,
2448
3873
  timestamp,
2449
- engineVersion: "0.2.0"
3874
+ engineVersion: "0.3.1"
2450
3875
  };
2451
3876
  }
2452
3877
 
@@ -2524,7 +3949,7 @@ function evaluateQualityGate(input) {
2524
3949
  }
2525
3950
 
2526
3951
  // ../core/src/diff-engine.ts
2527
- import path5 from "node:path";
3952
+ import path6 from "node:path";
2528
3953
 
2529
3954
  // ../core/src/contracts.ts
2530
3955
  function matchesRoutePattern(route, pattern) {
@@ -3012,19 +4437,19 @@ function consolidateRegressionsByRootCause(rawNewFindings, baseAudit, headAudit,
3012
4437
  const layoutPath = mut.nodeId.replace(/^layout:/, "");
3013
4438
  const downstream = traceDownstreamRoutes(headSEOGraph, layoutPath);
3014
4439
  if (downstream.length === 0) continue;
3015
- const prop = mut.property;
4440
+ const prop2 = mut.property;
3016
4441
  const matching = rawNewFindings.filter((f) => {
3017
4442
  if (handledIds.has(f.id)) return false;
3018
4443
  const routeMatch = f.route && downstream.includes(f.route);
3019
- const propMatch = prop === "canonical" ? f.rule.includes("canonical") : f.rule.includes("robots") || f.message.includes("noindex");
4444
+ const propMatch = prop2 === "canonical" ? f.rule.includes("canonical") : f.rule.includes("robots") || f.message.includes("noindex");
3020
4445
  return routeMatch && propMatch;
3021
4446
  });
3022
4447
  if (matching.length > 0) {
3023
4448
  for (const m of matching) handledIds.add(m.id);
3024
- const isCanonical = prop === "canonical";
3025
- const baseName = path5.basename(layoutPath);
4449
+ const isCanonical = prop2 === "canonical";
4450
+ const baseName = path6.basename(layoutPath);
3026
4451
  consolidated.push({
3027
- id: `regression-${baseName}-${prop}`,
4452
+ id: `regression-${baseName}-${prop2}`,
3028
4453
  rule: isCanonical ? "metadata/canonical-regression" : "crawlability/robots-regression",
3029
4454
  severity: "error",
3030
4455
  category: isCanonical ? "metadata" : "technical",
@@ -3057,7 +4482,7 @@ function consolidateRegressionsByRootCause(rawNewFindings, baseAudit, headAudit,
3057
4482
  sampleRoutes: downstream.slice(0, 3),
3058
4483
  confidence: "STRUCTURAL",
3059
4484
  revertSafeValue: mut.oldValue ? String(mut.oldValue) : void 0,
3060
- fixRecommendation: `Restore previous ${prop} declaration from BASE revision in ${baseName}.`
4485
+ fixRecommendation: `Restore previous ${prop2} declaration from BASE revision in ${baseName}.`
3061
4486
  }
3062
4487
  });
3063
4488
  }
@@ -3087,7 +4512,7 @@ function consolidateRegressionsByRootCause(rawNewFindings, baseAudit, headAudit,
3087
4512
  (n) => n.type === "layout" && (n.path?.includes(dir.replace("/", "")) || n.id.includes(dir.replace("/", "")))
3088
4513
  );
3089
4514
  const inferredLayoutFile = layoutNode?.path || `app${dir}/layout.tsx`;
3090
- const baseName = path5.basename(inferredLayoutFile);
4515
+ const baseName = path6.basename(inferredLayoutFile);
3091
4516
  const affected = dirFindings.map((f) => f.route).sort();
3092
4517
  for (const df of dirFindings) handledIds.add(df.id);
3093
4518
  consolidated.push({
@@ -3807,8 +5232,8 @@ var SessionKeyStore = class {
3807
5232
  var sessionKeyStore = new SessionKeyStore();
3808
5233
 
3809
5234
  // ../ai/src/semantic-resolver.ts
3810
- import fs5 from "node:fs";
3811
- import path6 from "node:path";
5235
+ import fs6 from "node:fs";
5236
+ import path7 from "node:path";
3812
5237
  var METADATA_KEYS = ["canonical", "robots", "title", "description"];
3813
5238
  function helperDefinesProperty(helperCode, property) {
3814
5239
  const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -3819,8 +5244,8 @@ async function resolveSemanticHelpers(options) {
3819
5244
  if (!aiProvider) return [];
3820
5245
  const results = [];
3821
5246
  for (const route of routes) {
3822
- if (!route.filePath || !fs5.existsSync(route.filePath)) continue;
3823
- const content = fs5.readFileSync(route.filePath, "utf8");
5247
+ if (!route.filePath || !fs6.existsSync(route.filePath)) continue;
5248
+ const content = fs6.readFileSync(route.filePath, "utf8");
3824
5249
  const helperMatch = content.match(
3825
5250
  /export\s+const\s+metadata\s*=\s*([A-Za-z0-9_]+)\s*\(([\s\S]*?)\)/
3826
5251
  );
@@ -3835,16 +5260,16 @@ async function resolveSemanticHelpers(options) {
3835
5260
  if (importMatch) {
3836
5261
  const importPath = importMatch[1];
3837
5262
  const candidates = [
3838
- path6.resolve(path6.dirname(route.filePath), `${importPath}.ts`),
3839
- path6.resolve(path6.dirname(route.filePath), `${importPath}.tsx`),
3840
- path6.resolve(path6.dirname(route.filePath), `${importPath}/index.ts`),
3841
- path6.resolve(projectRoot, `${importPath.replace(/^@\//, "src/").replace(/^~\//, "")}.ts`),
3842
- path6.resolve(projectRoot, `${importPath.replace(/^@\//, "src/").replace(/^~\//, "")}.tsx`)
5263
+ path7.resolve(path7.dirname(route.filePath), `${importPath}.ts`),
5264
+ path7.resolve(path7.dirname(route.filePath), `${importPath}.tsx`),
5265
+ path7.resolve(path7.dirname(route.filePath), `${importPath}/index.ts`),
5266
+ path7.resolve(projectRoot, `${importPath.replace(/^@\//, "src/").replace(/^~\//, "")}.ts`),
5267
+ path7.resolve(projectRoot, `${importPath.replace(/^@\//, "src/").replace(/^~\//, "")}.tsx`)
3843
5268
  ];
3844
5269
  for (const cand of candidates) {
3845
- if (fs5.existsSync(cand)) {
5270
+ if (fs6.existsSync(cand)) {
3846
5271
  helperFilePath = cand;
3847
- helperCode = fs5.readFileSync(cand, "utf8");
5272
+ helperCode = fs6.readFileSync(cand, "utf8");
3848
5273
  break;
3849
5274
  }
3850
5275
  }
@@ -3869,7 +5294,7 @@ async function resolveSemanticHelpers(options) {
3869
5294
  if (hasMeaningfulProps) {
3870
5295
  const verifiedProperties = helperCode ? METADATA_KEYS.filter((property) => resolution.maps[property] && helperDefinesProperty(helperCode, property)) : [];
3871
5296
  const confidence = verifiedProperties.length > 0 ? "AI_VERIFIED" : "AI_ASSISTED";
3872
- const cleanHelperPath = helperFilePath ? path6.relative(projectRoot, helperFilePath).replace(/\\/g, "/") : helperName;
5297
+ const cleanHelperPath = helperFilePath ? path7.relative(projectRoot, helperFilePath).replace(/\\/g, "/") : helperName;
3873
5298
  const resolvedMeta = {};
3874
5299
  if (verifiedProperties.includes("canonical") && !route.metadata?.canonical) {
3875
5300
  resolvedMeta.canonical = resolution.maps.canonical;
@@ -3877,7 +5302,7 @@ async function resolveSemanticHelpers(options) {
3877
5302
  if (verifiedProperties.includes("robots") && !route.metadata?.robots) {
3878
5303
  resolvedMeta.robots = resolution.maps.robots;
3879
5304
  }
3880
- const proofId = `proof-seo-${path6.basename(route.filePath)}-${helperName}`;
5305
+ const proofId = `proof-seo-${path7.basename(route.filePath)}-${helperName}`;
3881
5306
  const evidenceItems = [
3882
5307
  {
3883
5308
  id: `ev-callsite-${helperName}`,
@@ -3898,23 +5323,23 @@ async function resolveSemanticHelpers(options) {
3898
5323
  }
3899
5324
  const verifiedClaims = [];
3900
5325
  const rejectedClaims = [];
3901
- for (const [prop, val] of Object.entries(resolution.maps)) {
5326
+ for (const [prop2, val] of Object.entries(resolution.maps)) {
3902
5327
  if (val) {
3903
- if (helperVerified && verifiedProperties.includes(prop)) {
5328
+ if (helperVerified && verifiedProperties.includes(prop2)) {
3904
5329
  verifiedClaims.push({
3905
- id: `claim-${prop}-${helperName}`,
5330
+ id: `claim-${prop2}-${helperName}`,
3906
5331
  subject: route.route,
3907
- predicate: `has${prop.charAt(0).toUpperCase() + prop.slice(1)}`,
5332
+ predicate: `has${prop2.charAt(0).toUpperCase() + prop2.slice(1)}`,
3908
5333
  observedValue: val,
3909
5334
  evidenceRefs: evidenceItems.map((e) => e.id),
3910
5335
  source: { file: cleanHelperPath, route: route.route }
3911
5336
  });
3912
5337
  } else {
3913
5338
  rejectedClaims.push({
3914
- id: `claim-${prop}-${helperName}`,
5339
+ id: `claim-${prop2}-${helperName}`,
3915
5340
  subject: route.route,
3916
- predicate: `has${prop.charAt(0).toUpperCase() + prop.slice(1)}`,
3917
- reason: helperVerified ? `Helper source does not deterministically define metadata property "${prop}".` : "Helper source code could not be verified on filesystem."
5341
+ predicate: `has${prop2.charAt(0).toUpperCase() + prop2.slice(1)}`,
5342
+ reason: helperVerified ? `Helper source does not deterministically define metadata property "${prop2}".` : "Helper source code could not be verified on filesystem."
3918
5343
  });
3919
5344
  }
3920
5345
  }
@@ -3942,7 +5367,7 @@ async function resolveSemanticHelpers(options) {
3942
5367
  discoveredBy: "AI",
3943
5368
  verifiedBy: verifiedProperties.length > 0 ? "DETERMINISTIC_VERIFIER" : void 0,
3944
5369
  evidence: [
3945
- `Call site: ${path6.basename(route.filePath)}`,
5370
+ `Call site: ${path7.basename(route.filePath)}`,
3946
5371
  helperVerified ? `Helper definition: ${cleanHelperPath}` : `Inferred signature from ${helperName}`,
3947
5372
  ...resolution.evidenceChain || []
3948
5373
  ]
@@ -3965,7 +5390,7 @@ async function resolveSemanticHelpers(options) {
3965
5390
  }
3966
5391
 
3967
5392
  // src/formatter.ts
3968
- import path7 from "node:path";
5393
+ import path8 from "node:path";
3969
5394
  var colors = {
3970
5395
  reset: "\x1B[0m",
3971
5396
  bold: "\x1B[1m",
@@ -4020,7 +5445,7 @@ ${colors.dim}${"\u2501".repeat(45)}${colors.reset}
4020
5445
  console.log(`${colors.dim}${"\u2501".repeat(45)}${colors.reset}
4021
5446
  `);
4022
5447
  const rawFile = f.rootCause?.file || f.sourceFile || f.file || "";
4023
- const relFile = rawFile ? path7.relative(projectRoot, rawFile).replace(/\\/g, "/") : "";
5448
+ const relFile = rawFile ? path8.relative(projectRoot, rawFile).replace(/\\/g, "/") : "";
4024
5449
  const lineSuffix = f.rootCause?.line || f.sourceLine || f.line ? `:${f.rootCause?.line || f.sourceLine || f.line}` : "";
4025
5450
  if (f.rootCause) {
4026
5451
  console.log(`${colors.bold}Root cause${colors.reset}`);
@@ -4041,7 +5466,7 @@ ${colors.dim}${"\u2501".repeat(45)}${colors.reset}
4041
5466
  }
4042
5467
  if (f.evidenceChain?.steps && f.evidenceChain.steps.length > 0) {
4043
5468
  console.log(`${colors.bold}Dependency${colors.reset}`);
4044
- const sourceName = path7.basename(relFile || f.evidenceChain.sourceFile);
5469
+ const sourceName = path8.basename(relFile || f.evidenceChain.sourceFile);
4045
5470
  console.log(`${sourceName}`);
4046
5471
  for (const step of f.evidenceChain.steps) {
4047
5472
  const detail = step.detail ? ` ${step.detail}` : "";
@@ -4165,7 +5590,7 @@ function printAuditReport(result, projectRoot) {
4165
5590
  if (f.severity === "warning") icon = colors.yellow + "\u26A0" + colors.reset;
4166
5591
  const fixTag = f.fixable ? ` ${colors.green}[fixable]${colors.reset}` : "";
4167
5592
  const routeTag = f.route ? ` ${colors.cyan}(${f.route})${colors.reset}` : "";
4168
- const relativeFile = f.file ? path7.relative(projectRoot, f.file) : "";
5593
+ const relativeFile = f.file ? path8.relative(projectRoot, f.file) : "";
4169
5594
  const fileTag = relativeFile ? `
4170
5595
  ${colors.dim}${relativeFile}${f.line ? `:${f.line}` : ""}${colors.reset}` : "";
4171
5596
  console.log(` ${icon} ${f.message}${fixTag}${routeTag}${fileTag}`);
@@ -4261,7 +5686,7 @@ function loadLocalEnv(projectRoot) {
4261
5686
  const dirsToSearch = [projectRoot];
4262
5687
  let curr = projectRoot;
4263
5688
  for (let i = 0; i < 4; i++) {
4264
- const parent = path8.dirname(curr);
5689
+ const parent = path9.dirname(curr);
4265
5690
  if (parent && parent !== curr) {
4266
5691
  dirsToSearch.push(parent);
4267
5692
  curr = parent;
@@ -4271,10 +5696,10 @@ function loadLocalEnv(projectRoot) {
4271
5696
  }
4272
5697
  for (const dir of dirsToSearch) {
4273
5698
  for (const envFile of [".env", ".env.local"]) {
4274
- const fullPath = path8.join(dir, envFile);
4275
- if (fs6.existsSync(fullPath)) {
5699
+ const fullPath = path9.join(dir, envFile);
5700
+ if (fs7.existsSync(fullPath)) {
4276
5701
  try {
4277
- const content = fs6.readFileSync(fullPath, "utf8");
5702
+ const content = fs7.readFileSync(fullPath, "utf8");
4278
5703
  for (const line of content.split("\n")) {
4279
5704
  const trimmed = line.trim();
4280
5705
  if (!trimmed || trimmed.startsWith("#")) continue;
@@ -4297,15 +5722,15 @@ function loadLocalEnv(projectRoot) {
4297
5722
  }
4298
5723
  }
4299
5724
  function checkSecretRisk(projectRoot) {
4300
- const envCandidates = fs6.readdirSync(projectRoot).filter((name) => name !== ".env.example" && (name === ".env" || name.startsWith(".env.")));
4301
- const gitignorePath = path8.join(projectRoot, ".gitignore");
5725
+ const envCandidates = fs7.readdirSync(projectRoot).filter((name) => name !== ".env.example" && (name === ".env" || name.startsWith(".env.")));
5726
+ const gitignorePath = path9.join(projectRoot, ".gitignore");
4302
5727
  let gitignoreContent = "";
4303
- if (fs6.existsSync(gitignorePath)) {
4304
- gitignoreContent = fs6.readFileSync(gitignorePath, "utf8");
5728
+ if (fs7.existsSync(gitignorePath)) {
5729
+ gitignoreContent = fs7.readFileSync(gitignorePath, "utf8");
4305
5730
  }
4306
5731
  for (const env of envCandidates) {
4307
- const envPath = path8.join(projectRoot, env);
4308
- if (fs6.existsSync(envPath)) {
5732
+ const envPath = path9.join(projectRoot, env);
5733
+ if (fs7.existsSync(envPath)) {
4309
5734
  const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--", env], {
4310
5735
  cwd: projectRoot,
4311
5736
  stdio: "ignore"
@@ -4317,11 +5742,11 @@ function checkSecretRisk(projectRoot) {
4317
5742
  }
4318
5743
  }
4319
5744
  async function auditGitRef(projectRoot, ref) {
4320
- const tmpDir = fs6.mkdtempSync(path8.join(os.tmpdir(), "crawlemon-base-"));
5745
+ const tmpDir = fs7.mkdtempSync(path9.join(os.tmpdir(), "crawlemon-base-"));
4321
5746
  try {
4322
5747
  const gitRootRes = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: projectRoot, encoding: "utf8" });
4323
5748
  const gitRoot = gitRootRes.status === 0 ? gitRootRes.stdout.trim() : projectRoot;
4324
- const relToGitRoot = path8.relative(gitRoot, projectRoot).replace(/\\/g, "/");
5749
+ const relToGitRoot = path9.relative(gitRoot, projectRoot).replace(/\\/g, "/");
4325
5750
  const archiveRef = relToGitRoot && relToGitRoot !== "." ? `${ref}:${relToGitRoot}` : ref;
4326
5751
  const gitArchive = spawnSync("git", ["archive", archiveRef], {
4327
5752
  cwd: gitRoot,
@@ -4348,7 +5773,7 @@ async function auditGitRef(projectRoot, ref) {
4348
5773
  isAppRouter: scanData.isAppRouter
4349
5774
  });
4350
5775
  } finally {
4351
- fs6.rmSync(tmpDir, { recursive: true, force: true });
5776
+ fs7.rmSync(tmpDir, { recursive: true, force: true });
4352
5777
  }
4353
5778
  }
4354
5779
  async function runCli(args) {
@@ -4364,7 +5789,7 @@ async function runCli(args) {
4364
5789
  return;
4365
5790
  }
4366
5791
  if (command === "--version" || command === "-v") {
4367
- console.log("0.2.0");
5792
+ console.log("0.3.1");
4368
5793
  return;
4369
5794
  }
4370
5795
  checkSecretRisk(projectRoot);
@@ -4463,25 +5888,25 @@ async function runCli(args) {
4463
5888
  try {
4464
5889
  const gitRootRes = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: projectRoot, encoding: "utf8" });
4465
5890
  const gitRoot = gitRootRes.status === 0 ? gitRootRes.stdout.trim() : projectRoot;
4466
- const relToGitRoot = path8.relative(gitRoot, projectRoot).replace(/\\/g, "/");
5891
+ const relToGitRoot = path9.relative(gitRoot, projectRoot).replace(/\\/g, "/");
4467
5892
  const archiveRef = relToGitRoot && relToGitRoot !== "." ? `${baseRefArg}:${relToGitRoot}` : baseRefArg;
4468
5893
  const gitArchive = spawnSync("git", ["archive", archiveRef], { cwd: gitRoot, maxBuffer: 64 * 1024 * 1024 });
4469
5894
  if (gitArchive.status === 0 && gitArchive.stdout) {
4470
- const tmpDir = fs6.mkdtempSync(path8.join(os.tmpdir(), "crawlemon-base-fix-"));
5895
+ const tmpDir = fs7.mkdtempSync(path9.join(os.tmpdir(), "crawlemon-base-fix-"));
4471
5896
  spawnSync("tar", ["-x", "-C", tmpDir], { input: gitArchive.stdout });
4472
5897
  baseFiles = /* @__PURE__ */ new Map();
4473
5898
  const walk2 = (d) => {
4474
- for (const ent of fs6.readdirSync(d, { withFileTypes: true })) {
4475
- const full = path8.join(d, ent.name);
5899
+ for (const ent of fs7.readdirSync(d, { withFileTypes: true })) {
5900
+ const full = path9.join(d, ent.name);
4476
5901
  if (ent.isDirectory()) walk2(full);
4477
5902
  else if (ent.isFile()) {
4478
- const rel = path8.relative(tmpDir, full).replace(/\\/g, "/");
4479
- baseFiles.set(rel, fs6.readFileSync(full, "utf8"));
5903
+ const rel = path9.relative(tmpDir, full).replace(/\\/g, "/");
5904
+ baseFiles.set(rel, fs7.readFileSync(full, "utf8"));
4480
5905
  }
4481
5906
  }
4482
5907
  };
4483
5908
  walk2(tmpDir);
4484
- fs6.rmSync(tmpDir, { recursive: true, force: true });
5909
+ fs7.rmSync(tmpDir, { recursive: true, force: true });
4485
5910
  }
4486
5911
  } catch {
4487
5912
  }