crawlemon 0.3.1 → 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 +9 -3
  2. package/dist/index.js +156 -53
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,6 +6,12 @@ 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
+
9
15
  ## What is new in 0.3.1
10
16
 
11
17
  - Canonical URLs are no longer required on `robots: noindex` pages.
@@ -223,11 +229,11 @@ jobs:
223
229
  - name: Audit SEO
224
230
  env:
225
231
  CI: true
226
- run: npx --yes crawlemon@0.3.1 audit
232
+ run: npx --yes crawlemon@0.3.2 audit
227
233
  ```
228
234
 
229
235
  The exact package version is pinned so an upstream release cannot unexpectedly
230
- change your CI result. Update `crawlemon@0.3.1` deliberately when you are ready
236
+ change your CI result. Update `crawlemon@0.3.2` deliberately when you are ready
231
237
  to adopt a newer version.
232
238
 
233
239
  With `CI=true`, the workflow fails when Crawlemon finds at least one error-level
@@ -242,7 +248,7 @@ working directory:
242
248
  working-directory: apps/web
243
249
  env:
244
250
  CI: true
245
- run: npx --yes crawlemon@0.3.1 audit
251
+ run: npx --yes crawlemon@0.3.2 audit
246
252
  ```
247
253
 
248
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);
1497
+ }
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
+ }
1470
1512
  }
1471
- return files;
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`);
@@ -1556,53 +1608,103 @@ function discoverImportedH1(filePath, resolver, maxDepth = 3) {
1556
1608
  }
1557
1609
  return result;
1558
1610
  }
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 };
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;
1579
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;
1580
1645
  }
1581
- return null;
1646
+ return regions;
1582
1647
  }
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));
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 };
1592
1677
  }
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;
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);
1601
1694
  }
1602
- counts.set(owner, (counts.get(owner) || 0) + 1);
1603
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;
1604
1703
  let max = 0;
1605
- for (const count of counts.values()) max = Math.max(max, count);
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
+ }
1606
1708
  return max;
1607
1709
  }
1608
1710
 
@@ -1753,8 +1855,9 @@ var NextJsAdapter = class {
1753
1855
  const concurrency = computeH1Concurrency(content, inlineH1.map((h) => h.line || 0));
1754
1856
  maxConcurrentH1 = concurrency === null ? inlineH1.length : concurrency;
1755
1857
  } else {
1756
- maxConcurrentH1 = headings.filter((h) => h.level === 1).length;
1858
+ maxConcurrentH1 = headings.some((h) => h.level === 1) ? 1 : 0;
1757
1859
  }
1860
+ const hasLittleContent = parsed.hasLittleContent && (!resolver || !pageHasMeaningfulContent(fullPath, resolver));
1758
1861
  routes.push({
1759
1862
  route: routePath,
1760
1863
  filePath: fullPath,
@@ -1763,7 +1866,7 @@ var NextJsAdapter = class {
1763
1866
  images: parsed.images,
1764
1867
  links: parsed.links,
1765
1868
  textContent: parsed.textContent,
1766
- hasLittleContent: parsed.hasLittleContent,
1869
+ hasLittleContent,
1767
1870
  hasDynamicSegments: routePath.includes("["),
1768
1871
  dynamicParams: dynamic?.dynamicParams,
1769
1872
  generatedParams: dynamic?.generatedParams,
@@ -3871,7 +3974,7 @@ function runSEOAudit(options) {
3871
3974
  recommendations,
3872
3975
  opportunities,
3873
3976
  timestamp,
3874
- engineVersion: "0.3.1"
3977
+ engineVersion: "0.3.2"
3875
3978
  };
3876
3979
  }
3877
3980
 
@@ -5789,7 +5892,7 @@ async function runCli(args) {
5789
5892
  return;
5790
5893
  }
5791
5894
  if (command === "--version" || command === "-v") {
5792
- console.log("0.3.1");
5895
+ console.log("0.3.2");
5793
5896
  return;
5794
5897
  }
5795
5898
  checkSecretRisk(projectRoot);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlemon",
3
- "version": "0.3.1",
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",