crawlemon 0.3.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 +9 -3
  2. package/dist/index.js +87 -19
  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.1
10
+
11
+ - Canonical URLs are no longer required on `robots: noindex` pages.
12
+ - `<h1>` duplicates in mutually exclusive early-return branches (auth gates, loading/success/error states) are no longer reported.
13
+ - Empty `alt` on dynamic-src decorative images (e.g. favicons) is no longer flagged.
14
+
9
15
  ## What is new in 0.3.0
10
16
 
11
17
  - Production-grade static resolution for Next.js: site config, `metadataBase`, title objects (`default`/`template`/`absolute`), and layout inheritance.
@@ -217,11 +223,11 @@ jobs:
217
223
  - name: Audit SEO
218
224
  env:
219
225
  CI: true
220
- run: npx --yes crawlemon@0.3.0 audit
226
+ run: npx --yes crawlemon@0.3.1 audit
221
227
  ```
222
228
 
223
229
  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
230
+ change your CI result. Update `crawlemon@0.3.1` deliberately when you are ready
225
231
  to adopt a newer version.
226
232
 
227
233
  With `CI=true`, the workflow fails when Crawlemon finds at least one error-level
@@ -236,7 +242,7 @@ working directory:
236
242
  working-directory: apps/web
237
243
  env:
238
244
  CI: true
239
- run: npx --yes crawlemon@0.3.0 audit
245
+ run: npx --yes crawlemon@0.3.1 audit
240
246
  ```
241
247
 
242
248
  ## Zero-configuration detection
package/dist/index.js CHANGED
@@ -1547,7 +1547,7 @@ function discoverImportedH1(filePath, resolver, maxDepth = 3) {
1547
1547
  if (nextContent === void 0) continue;
1548
1548
  const h1s = collectInlineHeadings(nextContent).filter((h) => h.level === 1);
1549
1549
  if (h1s.length > 0) {
1550
- result.push(...h1s);
1550
+ result.push(h1s[0]);
1551
1551
  continue;
1552
1552
  }
1553
1553
  for (const next of localComponentImports(nextContent, file, resolver)) {
@@ -1556,6 +1556,55 @@ function discoverImportedH1(filePath, resolver, maxDepth = 3) {
1556
1556
  }
1557
1557
  return result;
1558
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
+ }
1559
1608
 
1560
1609
  // ../next-adapter/src/scanner.ts
1561
1610
  function normalizeRouteGroup(segment) {
@@ -1698,6 +1747,14 @@ var NextJsAdapter = class {
1698
1747
  headings.push(...discoverImportedH1(fullPath, resolver));
1699
1748
  }
1700
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
+ }
1701
1758
  routes.push({
1702
1759
  route: routePath,
1703
1760
  filePath: fullPath,
@@ -1710,7 +1767,8 @@ var NextJsAdapter = class {
1710
1767
  hasDynamicSegments: routePath.includes("["),
1711
1768
  dynamicParams: dynamic?.dynamicParams,
1712
1769
  generatedParams: dynamic?.generatedParams,
1713
- isRedirect: isRedirectOnly(content)
1770
+ isRedirect: isRedirectOnly(content),
1771
+ maxConcurrentH1
1714
1772
  });
1715
1773
  }
1716
1774
  }
@@ -1738,6 +1796,9 @@ var NextJsAdapter = class {
1738
1796
  if (routePath === "//" || routePath === "") routePath = "/";
1739
1797
  const content = fs3.readFileSync(fullPath, "utf8");
1740
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;
1741
1802
  routes.push({
1742
1803
  route: routePath,
1743
1804
  filePath: fullPath,
@@ -1747,7 +1808,8 @@ var NextJsAdapter = class {
1747
1808
  links: parsed.links,
1748
1809
  textContent: parsed.textContent,
1749
1810
  hasLittleContent: parsed.hasLittleContent,
1750
- hasDynamicSegments: routePath.includes("[")
1811
+ hasDynamicSegments: routePath.includes("["),
1812
+ maxConcurrentH1
1751
1813
  });
1752
1814
  }
1753
1815
  }
@@ -2220,6 +2282,7 @@ var canonicalRule = {
2220
2282
  }
2221
2283
  for (const route of context.routes) {
2222
2284
  if (route.isRedirect) continue;
2285
+ if ((route.metadata.robots || "").toLowerCase().includes("noindex")) continue;
2223
2286
  if (route.metadata.dynamicMetadata) continue;
2224
2287
  const canonical = route.metadata.canonical;
2225
2288
  if (!canonical || canonical.trim() === "") {
@@ -2364,19 +2427,22 @@ var headingsRule = {
2364
2427
  fixable: false,
2365
2428
  explanation: "Every page should have exactly one <h1> element defining its primary topic for search engines and accessibility."
2366
2429
  });
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
- });
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
+ }
2380
2446
  }
2381
2447
  let prevLevel = 1;
2382
2448
  for (const h of headings) {
@@ -2438,7 +2504,9 @@ var imagesRule = {
2438
2504
  // Never invent alt descriptions without AI
2439
2505
  explanation: "Missing alt attributes harm accessibility and prevent images from ranking in Google Image Search."
2440
2506
  });
2441
- } 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")) {
2442
2510
  findings.push({
2443
2511
  id: `img-empty-alt-${route.route}-${img.src}`,
2444
2512
  rule: "images",
@@ -3803,7 +3871,7 @@ function runSEOAudit(options) {
3803
3871
  recommendations,
3804
3872
  opportunities,
3805
3873
  timestamp,
3806
- engineVersion: "0.3.0"
3874
+ engineVersion: "0.3.1"
3807
3875
  };
3808
3876
  }
3809
3877
 
@@ -5721,7 +5789,7 @@ async function runCli(args) {
5721
5789
  return;
5722
5790
  }
5723
5791
  if (command === "--version" || command === "-v") {
5724
- console.log("0.3.0");
5792
+ console.log("0.3.1");
5725
5793
  return;
5726
5794
  }
5727
5795
  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.1",
4
4
  "type": "module",
5
5
  "description": "ESLint / Dependabot for SEO \u2014 Developer-First SEO Automation Tool",
6
6
  "license": "MIT",