crawlemon 0.3.0 → 0.3.2

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 +15 -3
  2. package/dist/index.js +200 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,6 +6,18 @@ Crawlemon is the SEO safety layer between code and production. It works like an
6
6
 
7
7
  No account, cloud connection, browser, AI provider, or API key is required.
8
8
 
9
+ ## What is new in 0.3.2
10
+
11
+ - Accurate heading analysis across early `return` and ternary/`&&` branches, plus lazy `next/dynamic` imports — far fewer false "missing/duplicate `<h1>`" findings.
12
+ - Thin-content detection counts component text and stops stripping JSX text inside `return` bodies.
13
+ - Fixes metadata inheritance for multi-line site config (`export const x =` followed by the value), so pages no longer show phantom "missing meta description".
14
+
15
+ ## What is new in 0.3.1
16
+
17
+ - Canonical URLs are no longer required on `robots: noindex` pages.
18
+ - `<h1>` duplicates in mutually exclusive early-return branches (auth gates, loading/success/error states) are no longer reported.
19
+ - Empty `alt` on dynamic-src decorative images (e.g. favicons) is no longer flagged.
20
+
9
21
  ## What is new in 0.3.0
10
22
 
11
23
  - Production-grade static resolution for Next.js: site config, `metadataBase`, title objects (`default`/`template`/`absolute`), and layout inheritance.
@@ -217,11 +229,11 @@ jobs:
217
229
  - name: Audit SEO
218
230
  env:
219
231
  CI: true
220
- run: npx --yes crawlemon@0.3.0 audit
232
+ run: npx --yes crawlemon@0.3.2 audit
221
233
  ```
222
234
 
223
235
  The exact package version is pinned so an upstream release cannot unexpectedly
224
- change your CI result. Update `crawlemon@0.3.0` deliberately when you are ready
236
+ change your CI result. Update `crawlemon@0.3.2` deliberately when you are ready
225
237
  to adopt a newer version.
226
238
 
227
239
  With `CI=true`, the workflow fails when Crawlemon finds at least one error-level
@@ -236,7 +248,7 @@ working directory:
236
248
  working-directory: apps/web
237
249
  env:
238
250
  CI: true
239
- run: npx --yes crawlemon@0.3.0 audit
251
+ run: npx --yes crawlemon@0.3.2 audit
240
252
  ```
241
253
 
242
254
  ## Zero-configuration detection
package/dist/index.js CHANGED
@@ -183,7 +183,7 @@ function parsePageSource(content, framework = "nextjs") {
183
183
  isInternal
184
184
  });
185
185
  }
186
- const cleanBodyText = content.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\{\{[\s\S]*?\}\}/g, " ").replace(/\{[^{}]*\}/g, " ").replace(/\s+/g, " ").trim();
186
+ const cleanBodyText = content.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\{\{[\s\S]*?\}\}/g, " ").replace(/\{[^{}]*\}/g, (block) => /\b(return|function)\b|=>/.test(block) ? block : " ").replace(/\s+/g, " ").trim();
187
187
  const words = cleanBodyText.split(/\s+/).filter((word) => word.length > 2);
188
188
  const hasLittleContent = words.length < 20;
189
189
  return {
@@ -814,7 +814,13 @@ function absolutize(value, base) {
814
814
  function resolvePageMetadata(content, file, inherited, ctx) {
815
815
  const extract = extractNextMetadata(content, file, ctx.resolver);
816
816
  if (!extract) {
817
- return inherited;
817
+ const clean = { ...inherited };
818
+ delete clean.titleDeclared;
819
+ delete clean.descriptionDeclared;
820
+ delete clean.canonicalDeclared;
821
+ delete clean.dynamicMetadata;
822
+ delete clean.metadataSource;
823
+ return clean;
818
824
  }
819
825
  return buildEffectiveMetadata(extract, inherited, { ...ctx, isLayout: false });
820
826
  }
@@ -1016,6 +1022,14 @@ function readStringEnd(text, index) {
1016
1022
  }
1017
1023
  return text.length;
1018
1024
  }
1025
+ function statementContinues(code) {
1026
+ const trimmed = code.trimEnd();
1027
+ if (!trimmed) return false;
1028
+ if (trimmed.endsWith(";")) return false;
1029
+ if (/=>$/.test(trimmed)) return true;
1030
+ const last = trimmed[trimmed.length - 1];
1031
+ return /[=+\-*/%&|?:,.<>]/.test(last);
1032
+ }
1019
1033
  function splitTopLevelStatements(code) {
1020
1034
  const out = [];
1021
1035
  let depth = 0;
@@ -1037,7 +1051,7 @@ function splitTopLevelStatements(code) {
1037
1051
  continue;
1038
1052
  }
1039
1053
  if (ch === "\n") {
1040
- if (depth === 0) flush(i);
1054
+ if (depth === 0 && !(statementStart !== -1 && statementContinues(code.slice(statementStart, i)))) flush(i);
1041
1055
  i += 1;
1042
1056
  continue;
1043
1057
  }
@@ -1461,14 +1475,52 @@ function collectInlineHeadings(content) {
1461
1475
  }
1462
1476
  return headings.sort((a, b) => (a.line || 0) - (b.line || 0));
1463
1477
  }
1464
- function localComponentImports(content, fromFile, resolver) {
1465
- const files = [];
1478
+ function textWordCount(content) {
1479
+ const cleaned = content.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\{\{[\s\S]*?\}\}/g, " ").replace(/\{[^{}]*\}/g, (block) => /\b(return|function)\b|=>/.test(block) ? block : " ").replace(/\s+/g, " ").trim();
1480
+ return cleaned.split(/\s+/).filter((word) => word.length > 2).length;
1481
+ }
1482
+ var CONTENT_WORD_THRESHOLD = 20;
1483
+ function pageHasMeaningfulContent(filePath, resolver) {
1484
+ const visited = /* @__PURE__ */ new Set([filePath]);
1485
+ let total = 0;
1486
+ const enqueue = (file, queue2, depth) => {
1487
+ if (!visited.has(file) && depth <= 3) queue2.push({ file, depth });
1488
+ };
1489
+ const queue = [];
1490
+ let content = resolver.readModuleFile(filePath);
1491
+ if (content === void 0) return false;
1492
+ total += textWordCount(content);
1493
+ if (total >= CONTENT_WORD_THRESHOLD) return true;
1466
1494
  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);
1495
+ const resolved = resolver.resolveModule(m[1], filePath);
1496
+ if (resolved) enqueue(resolved, queue, 1);
1470
1497
  }
1471
- return files;
1498
+ while (queue.length > 0) {
1499
+ const { file, depth } = queue.shift();
1500
+ if (visited.has(file)) continue;
1501
+ visited.add(file);
1502
+ const nextContent = resolver.readModuleFile(file);
1503
+ if (nextContent === void 0) continue;
1504
+ total += textWordCount(nextContent);
1505
+ if (total >= CONTENT_WORD_THRESHOLD) return true;
1506
+ if (depth < 3) {
1507
+ for (const m of nextContent.matchAll(/import[\s\S]*?from\s+["']([^"']+)["']/g)) {
1508
+ const resolved = resolver.resolveModule(m[1], file);
1509
+ if (resolved) enqueue(resolved, queue, depth + 1);
1510
+ }
1511
+ }
1512
+ }
1513
+ return total >= CONTENT_WORD_THRESHOLD;
1514
+ }
1515
+ function localComponentImports(content, fromFile, resolver) {
1516
+ const files = /* @__PURE__ */ new Set();
1517
+ const add = (spec) => {
1518
+ const resolved = resolver.resolveModule(spec, fromFile);
1519
+ if (resolved) files.add(resolved);
1520
+ };
1521
+ for (const m of content.matchAll(/import[\s\S]*?from\s+["']([^"']+)["']/g)) add(m[1]);
1522
+ for (const m of content.matchAll(/(?:\bimport|require)\s*\(\s*["']([^"']+)["']/g)) add(m[1]);
1523
+ return Array.from(files);
1472
1524
  }
1473
1525
  function fileHasFlag(content, flag) {
1474
1526
  const re = new RegExp(`\\b${flag}\\s*=\\s*false\\b`);
@@ -1547,7 +1599,7 @@ function discoverImportedH1(filePath, resolver, maxDepth = 3) {
1547
1599
  if (nextContent === void 0) continue;
1548
1600
  const h1s = collectInlineHeadings(nextContent).filter((h) => h.level === 1);
1549
1601
  if (h1s.length > 0) {
1550
- result.push(...h1s);
1602
+ result.push(h1s[0]);
1551
1603
  continue;
1552
1604
  }
1553
1605
  for (const next of localComponentImports(nextContent, file, resolver)) {
@@ -1556,6 +1608,105 @@ function discoverImportedH1(filePath, resolver, maxDepth = 3) {
1556
1608
  }
1557
1609
  return result;
1558
1610
  }
1611
+ function countH1(text) {
1612
+ const re = /<h1\b/g;
1613
+ let count = 0;
1614
+ while (re.exec(text)) count += 1;
1615
+ return count;
1616
+ }
1617
+ function interpolationRegions(text) {
1618
+ const regions = [];
1619
+ let i = 0;
1620
+ while (i < text.length) {
1621
+ const ch = text[i];
1622
+ if (ch === "'" || ch === '"') {
1623
+ const q = text[i];
1624
+ i += 1;
1625
+ while (i < text.length && text[i] !== q) i += 1;
1626
+ i += 1;
1627
+ continue;
1628
+ }
1629
+ if (ch === "`") {
1630
+ let t = i + 1;
1631
+ while (t < text.length && text[t] !== "`") {
1632
+ if (text[t] === "\\") t += 1;
1633
+ t += 1;
1634
+ }
1635
+ i = t + 1;
1636
+ continue;
1637
+ }
1638
+ if (ch === "{") {
1639
+ const end = readBalanced(text, i);
1640
+ regions.push({ start: i, end });
1641
+ i = end + 1;
1642
+ continue;
1643
+ }
1644
+ i += 1;
1645
+ }
1646
+ return regions;
1647
+ }
1648
+ function findTopLevelTernary(text) {
1649
+ let depth = 0;
1650
+ let question = -1;
1651
+ for (let i = 0; i < text.length; i += 1) {
1652
+ const ch = text[i];
1653
+ if (ch === "'" || ch === '"') {
1654
+ const q = ch;
1655
+ i += 1;
1656
+ while (i < text.length && text[i] !== q) i += 1;
1657
+ continue;
1658
+ }
1659
+ if (ch === "`") {
1660
+ while (i < text.length && text[i] !== "`") i += 1;
1661
+ continue;
1662
+ }
1663
+ if (ch === "(" || ch === "[" || ch === "{") depth += 1;
1664
+ else if (ch === ")" || ch === "]" || ch === "}") depth = Math.max(0, depth - 1);
1665
+ else if (ch === "?" && depth === 0) {
1666
+ question = i;
1667
+ break;
1668
+ }
1669
+ }
1670
+ if (question === -1) return null;
1671
+ let depth2 = 0;
1672
+ for (let i = question + 1; i < text.length; i += 1) {
1673
+ const ch = text[i];
1674
+ if (ch === "(" || ch === "[" || ch === "{") depth2 += 1;
1675
+ else if (ch === ")" || ch === "]" || ch === "}") depth2 = Math.max(0, depth2 - 1);
1676
+ else if (ch === ":" && depth2 === 0) return { question, colon: i };
1677
+ }
1678
+ return null;
1679
+ }
1680
+ function computeBlockMaxH1(text) {
1681
+ const regions = interpolationRegions(text);
1682
+ let jsxLevelH1 = countH1(text);
1683
+ let add = 0;
1684
+ for (const region of regions) {
1685
+ const inner = text.slice(region.start + 1, region.end);
1686
+ jsxLevelH1 -= countH1(inner);
1687
+ const ternary = findTopLevelTernary(inner);
1688
+ if (ternary) {
1689
+ const truePart = inner.slice(0, ternary.colon);
1690
+ const falsePart = inner.slice(ternary.colon + 1);
1691
+ add += Math.max(countH1(truePart), countH1(falsePart));
1692
+ } else {
1693
+ add += countH1(inner);
1694
+ }
1695
+ }
1696
+ return jsxLevelH1 + add;
1697
+ }
1698
+ function computeH1Concurrency(content, h1Lines) {
1699
+ const returnRe = /(?:^|[^A-Za-z0-9_$])return\s*(?:\(\s*)?</g;
1700
+ const starts = [];
1701
+ for (const match of content.matchAll(returnRe)) starts.push(match.index || 0);
1702
+ if (starts.length === 0) return null;
1703
+ let max = 0;
1704
+ for (let index = 0; index < starts.length; index += 1) {
1705
+ const blockText = content.slice(starts[index], index + 1 < starts.length ? starts[index + 1] : content.length);
1706
+ max = Math.max(max, computeBlockMaxH1(blockText));
1707
+ }
1708
+ return max;
1709
+ }
1559
1710
 
1560
1711
  // ../next-adapter/src/scanner.ts
1561
1712
  function normalizeRouteGroup(segment) {
@@ -1698,6 +1849,15 @@ var NextJsAdapter = class {
1698
1849
  headings.push(...discoverImportedH1(fullPath, resolver));
1699
1850
  }
1700
1851
  const dynamic = resolver ? dynamicParamsForFile(fullPath, resolver) : void 0;
1852
+ const inlineH1 = parsed.headings.filter((h) => h.level === 1);
1853
+ let maxConcurrentH1;
1854
+ if (inlineH1.length > 0) {
1855
+ const concurrency = computeH1Concurrency(content, inlineH1.map((h) => h.line || 0));
1856
+ maxConcurrentH1 = concurrency === null ? inlineH1.length : concurrency;
1857
+ } else {
1858
+ maxConcurrentH1 = headings.some((h) => h.level === 1) ? 1 : 0;
1859
+ }
1860
+ const hasLittleContent = parsed.hasLittleContent && (!resolver || !pageHasMeaningfulContent(fullPath, resolver));
1701
1861
  routes.push({
1702
1862
  route: routePath,
1703
1863
  filePath: fullPath,
@@ -1706,11 +1866,12 @@ var NextJsAdapter = class {
1706
1866
  images: parsed.images,
1707
1867
  links: parsed.links,
1708
1868
  textContent: parsed.textContent,
1709
- hasLittleContent: parsed.hasLittleContent,
1869
+ hasLittleContent,
1710
1870
  hasDynamicSegments: routePath.includes("["),
1711
1871
  dynamicParams: dynamic?.dynamicParams,
1712
1872
  generatedParams: dynamic?.generatedParams,
1713
- isRedirect: isRedirectOnly(content)
1873
+ isRedirect: isRedirectOnly(content),
1874
+ maxConcurrentH1
1714
1875
  });
1715
1876
  }
1716
1877
  }
@@ -1738,6 +1899,9 @@ var NextJsAdapter = class {
1738
1899
  if (routePath === "//" || routePath === "") routePath = "/";
1739
1900
  const content = fs3.readFileSync(fullPath, "utf8");
1740
1901
  const parsed = parsePageSource(content);
1902
+ const inlineH1 = parsed.headings.filter((h) => h.level === 1);
1903
+ const concurrency = computeH1Concurrency(content, inlineH1.map((h) => h.line || 0));
1904
+ const maxConcurrentH1 = concurrency === null ? inlineH1.length : concurrency;
1741
1905
  routes.push({
1742
1906
  route: routePath,
1743
1907
  filePath: fullPath,
@@ -1747,7 +1911,8 @@ var NextJsAdapter = class {
1747
1911
  links: parsed.links,
1748
1912
  textContent: parsed.textContent,
1749
1913
  hasLittleContent: parsed.hasLittleContent,
1750
- hasDynamicSegments: routePath.includes("[")
1914
+ hasDynamicSegments: routePath.includes("["),
1915
+ maxConcurrentH1
1751
1916
  });
1752
1917
  }
1753
1918
  }
@@ -2220,6 +2385,7 @@ var canonicalRule = {
2220
2385
  }
2221
2386
  for (const route of context.routes) {
2222
2387
  if (route.isRedirect) continue;
2388
+ if ((route.metadata.robots || "").toLowerCase().includes("noindex")) continue;
2223
2389
  if (route.metadata.dynamicMetadata) continue;
2224
2390
  const canonical = route.metadata.canonical;
2225
2391
  if (!canonical || canonical.trim() === "") {
@@ -2364,19 +2530,22 @@ var headingsRule = {
2364
2530
  fixable: false,
2365
2531
  explanation: "Every page should have exactly one <h1> element defining its primary topic for search engines and accessibility."
2366
2532
  });
2367
- } else if (h1s.length > 1) {
2368
- findings.push({
2369
- id: `heading-multiple-h1-${route.route}`,
2370
- rule: "headings",
2371
- severity: "warning",
2372
- category: "content",
2373
- message: `Found ${h1s.length} <h1> tags on route "${route.route}". Best practice is a single prominent <h1>.`,
2374
- file: route.filePath,
2375
- line: h1s[1].line,
2376
- route: route.route,
2377
- fixable: false,
2378
- explanation: "Multiple <h1> tags can dilute topical hierarchy and confuse screen readers."
2379
- });
2533
+ } else {
2534
+ const effectiveCount = route.maxConcurrentH1 !== void 0 ? Math.max(route.maxConcurrentH1, h1s.length > 0 ? 1 : 0) : h1s.length;
2535
+ if (effectiveCount > 1) {
2536
+ findings.push({
2537
+ id: `heading-multiple-h1-${route.route}`,
2538
+ rule: "headings",
2539
+ severity: "warning",
2540
+ category: "content",
2541
+ message: `Found ${effectiveCount} <h1> tags on route "${route.route}". Best practice is a single prominent <h1>.`,
2542
+ file: route.filePath,
2543
+ line: h1s[1].line,
2544
+ route: route.route,
2545
+ fixable: false,
2546
+ explanation: "Multiple <h1> tags can dilute topical hierarchy and confuse screen readers."
2547
+ });
2548
+ }
2380
2549
  }
2381
2550
  let prevLevel = 1;
2382
2551
  for (const h of headings) {
@@ -2438,7 +2607,9 @@ var imagesRule = {
2438
2607
  // Never invent alt descriptions without AI
2439
2608
  explanation: "Missing alt attributes harm accessibility and prevent images from ranking in Google Image Search."
2440
2609
  });
2441
- } else if (img.alt.trim() === "" && !img.src.includes("icon") && !img.src.includes("decorative")) {
2610
+ } 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;
2611
+ // empty alt for it is commonly the intended decorative usage.
2612
+ !img.src.includes("unknown-image")) {
2442
2613
  findings.push({
2443
2614
  id: `img-empty-alt-${route.route}-${img.src}`,
2444
2615
  rule: "images",
@@ -3803,7 +3974,7 @@ function runSEOAudit(options) {
3803
3974
  recommendations,
3804
3975
  opportunities,
3805
3976
  timestamp,
3806
- engineVersion: "0.3.0"
3977
+ engineVersion: "0.3.2"
3807
3978
  };
3808
3979
  }
3809
3980
 
@@ -5721,7 +5892,7 @@ async function runCli(args) {
5721
5892
  return;
5722
5893
  }
5723
5894
  if (command === "--version" || command === "-v") {
5724
- console.log("0.3.0");
5895
+ console.log("0.3.2");
5725
5896
  return;
5726
5897
  }
5727
5898
  checkSecretRisk(projectRoot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlemon",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "ESLint / Dependabot for SEO \u2014 Developer-First SEO Automation Tool",
6
6
  "license": "MIT",