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.
- package/README.md +9 -3
- package/dist/index.js +156 -53
- 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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
1465
|
-
const
|
|
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
|
|
1468
|
-
|
|
1469
|
-
|
|
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
|
|
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
|
|
1560
|
-
const
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
const
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
|
1646
|
+
return regions;
|
|
1582
1647
|
}
|
|
1583
|
-
function
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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 (
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
5895
|
+
console.log("0.3.2");
|
|
5793
5896
|
return;
|
|
5794
5897
|
}
|
|
5795
5898
|
checkSecretRisk(projectRoot);
|