@rankcli/agent-runtime 0.0.35 → 0.0.37

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/dist/index.d.mts CHANGED
@@ -3106,14 +3106,9 @@ declare function servedDir(framework: Framework | undefined, files: string[], ap
3106
3106
 
3107
3107
  interface ReplacementConcern {
3108
3108
  /** Machine-readable, for logs and the PR body. */
3109
- code: 'shrunk' | 'lost_closing_tag' | 'lost_script' | 'lost_stylesheet' | 'lost_verification' | 'lost_import' | 'invalid_json' | 'invalid_xml' | 'invalid_json_ld' | 'fewer_sitemap_urls' | 'lastmod_moved_backwards';
3109
+ code: 'shrunk' | 'lost_closing_tag' | 'lost_script' | 'lost_stylesheet' | 'lost_verification' | 'lost_import' | 'invalid_json' | 'invalid_xml' | 'invalid_json_ld' | 'fewer_sitemap_urls' | 'lastmod_moved_backwards' | 'placeholder_content' | 'canonical_in_shared_shell';
3110
3110
  detail: string;
3111
3111
  }
3112
- /**
3113
- * Everything wrong with replacing `original` with `replacement` at `path`. Empty means safe
3114
- * to commit. `original` is null for a file being created, where there is nothing to lose and
3115
- * only the content's own validity matters.
3116
- */
3117
3112
  declare function assessReplacement(path: string, original: string | null, replacement: string): ReplacementConcern[];
3118
3113
  /** One line naming why a file was dropped, for a log line or a PR body. */
3119
3114
  declare function describeConcerns(concerns: ReplacementConcern[]): string;
package/dist/index.d.ts CHANGED
@@ -3106,14 +3106,9 @@ declare function servedDir(framework: Framework | undefined, files: string[], ap
3106
3106
 
3107
3107
  interface ReplacementConcern {
3108
3108
  /** Machine-readable, for logs and the PR body. */
3109
- code: 'shrunk' | 'lost_closing_tag' | 'lost_script' | 'lost_stylesheet' | 'lost_verification' | 'lost_import' | 'invalid_json' | 'invalid_xml' | 'invalid_json_ld' | 'fewer_sitemap_urls' | 'lastmod_moved_backwards';
3109
+ code: 'shrunk' | 'lost_closing_tag' | 'lost_script' | 'lost_stylesheet' | 'lost_verification' | 'lost_import' | 'invalid_json' | 'invalid_xml' | 'invalid_json_ld' | 'fewer_sitemap_urls' | 'lastmod_moved_backwards' | 'placeholder_content' | 'canonical_in_shared_shell';
3110
3110
  detail: string;
3111
3111
  }
3112
- /**
3113
- * Everything wrong with replacing `original` with `replacement` at `path`. Empty means safe
3114
- * to commit. `original` is null for a file being created, where there is nothing to lose and
3115
- * only the content's own validity matters.
3116
- */
3117
3112
  declare function assessReplacement(path: string, original: string | null, replacement: string): ReplacementConcern[];
3118
3113
  /** One line naming why a file was dropped, for a log line or a PR body. */
3119
3114
  declare function describeConcerns(concerns: ReplacementConcern[]): string;
package/dist/index.js CHANGED
@@ -10401,6 +10401,121 @@ async function checkInternalRedirects(internalLinks, batchSize = 10) {
10401
10401
  return { redirectLinks };
10402
10402
  }
10403
10403
 
10404
+ // src/audit/checks/crawler-access.ts
10405
+ init_http();
10406
+ var CRAWLERS = [
10407
+ {
10408
+ name: "Googlebot",
10409
+ userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
10410
+ matters: "Google Search indexing",
10411
+ critical: true
10412
+ },
10413
+ {
10414
+ name: "bingbot",
10415
+ userAgent: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
10416
+ matters: "Bing indexing, and Copilot answers",
10417
+ critical: true
10418
+ },
10419
+ {
10420
+ name: "GPTBot",
10421
+ userAgent: "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
10422
+ matters: "ChatGPT being able to read and cite the page",
10423
+ critical: false
10424
+ },
10425
+ {
10426
+ name: "ClaudeBot",
10427
+ userAgent: "Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)",
10428
+ matters: "Claude being able to read and cite the page",
10429
+ critical: false
10430
+ },
10431
+ {
10432
+ name: "PerplexityBot",
10433
+ userAgent: "Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
10434
+ matters: "Perplexity being able to read and cite the page",
10435
+ critical: false
10436
+ }
10437
+ ];
10438
+ var BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
10439
+ function isRefusal(status) {
10440
+ return status === 401 || status === 403 || status === 429 || status === 503;
10441
+ }
10442
+ async function statusFor(url, userAgent) {
10443
+ try {
10444
+ const response = await httpGet(url, {
10445
+ headers: { "User-Agent": userAgent },
10446
+ timeout: 15e3,
10447
+ // We want the status itself, not an exception on 4xx/5xx.
10448
+ validateStatus: () => true
10449
+ });
10450
+ return response.status;
10451
+ } catch {
10452
+ return null;
10453
+ }
10454
+ }
10455
+ async function analyzeCrawlerAccess(url) {
10456
+ const issues = [];
10457
+ const baselineStatus = await statusFor(url, BROWSER_UA);
10458
+ const data = { baselineStatus, crawlers: [] };
10459
+ if (baselineStatus === null || baselineStatus >= 400) {
10460
+ return { issues, data };
10461
+ }
10462
+ const results = await Promise.all(
10463
+ CRAWLERS.map(async (crawler) => ({
10464
+ crawler,
10465
+ status: await statusFor(url, crawler.userAgent)
10466
+ }))
10467
+ );
10468
+ const blocked = results.filter(({ status }) => status !== null && isRefusal(status));
10469
+ data.crawlers = results.map(({ crawler, status }) => ({
10470
+ name: crawler.name,
10471
+ status,
10472
+ blocked: status !== null && isRefusal(status)
10473
+ }));
10474
+ if (blocked.length === 0) return { issues, data };
10475
+ const search = blocked.filter(({ crawler }) => crawler.critical);
10476
+ const ai = blocked.filter(({ crawler }) => !crawler.critical);
10477
+ if (search.length > 0) {
10478
+ const names = search.map(({ crawler }) => crawler.name).join(", ");
10479
+ issues.push({
10480
+ code: "SEARCH_CRAWLER_BLOCKED_AT_EDGE",
10481
+ severity: "error",
10482
+ category: "crawlability",
10483
+ title: `${names} refused at the edge, not by robots.txt`,
10484
+ description: `A browser request to this URL returned ${baselineStatus}, but ${names} received ${search.map(({ status }) => status).join(", ")}. Something in front of the origin - a CDN, WAF or bot rule - is turning the crawler away, and robots.txt cannot show you this.`,
10485
+ impact: "Pages a search crawler cannot fetch fall out of the index. This is the failure mode behind Cloudflare\u2019s 2026-09-15 default, where blocking AI training also blocked Googlebot because it is classed as a mixed-use crawler.",
10486
+ howToFix: `Check your CDN or WAF bot rules rather than robots.txt.
10487
+
10488
+ On Cloudflare: the zone's Security > Bots settings, and any "Block AI Bots" or AI Crawler Control toggle. Because Googlebot crawls for both Search and AI training, blocking Training applies to all of its functions on ad-supported pages - allow Search crawlers explicitly, or opt out of the default.
10489
+
10490
+ Confirm with Search Console's URL Inspection, which fetches as the verified Googlebot.`,
10491
+ affectedUrls: [url],
10492
+ details: {
10493
+ baselineStatus,
10494
+ blocked: search.map(({ crawler, status }) => ({ crawler: crawler.name, status })),
10495
+ note: "Observed with the crawler\u2019s user-agent. Real crawlers are also verified by IP, so a site may treat them differently."
10496
+ }
10497
+ });
10498
+ }
10499
+ if (ai.length > 0) {
10500
+ const names = ai.map(({ crawler }) => crawler.name).join(", ");
10501
+ issues.push({
10502
+ code: "AI_CRAWLER_BLOCKED_AT_EDGE",
10503
+ severity: "warning",
10504
+ category: "ai-readiness",
10505
+ title: `${names} refused at the edge, not by robots.txt`,
10506
+ description: `A browser request returned ${baselineStatus}, but ${names} received ${ai.map(({ status }) => status).join(", ")}. The block is in front of the origin, so allowing these agents in robots.txt will not change it.`,
10507
+ impact: "An assistant that cannot fetch the page cannot quote it. This is invisible to a robots.txt check, which is where most AI-visibility tools stop.",
10508
+ howToFix: `If blocking them is deliberate, nothing to do. If not, the rule is in your CDN or WAF, not robots.txt - on Cloudflare see Security > Bots and any AI Crawler Control setting.`,
10509
+ affectedUrls: [url],
10510
+ details: {
10511
+ baselineStatus,
10512
+ blocked: ai.map(({ crawler, status }) => ({ crawler: crawler.name, status }))
10513
+ }
10514
+ });
10515
+ }
10516
+ return { issues, data };
10517
+ }
10518
+
10404
10519
  // src/audit/checks/ai-readiness.ts
10405
10520
  init_http();
10406
10521
  var cheerio13 = __toESM(require("cheerio"));
@@ -11429,17 +11544,14 @@ var DOM_SIZE_ERROR = 3e3;
11429
11544
  var DOM_DEPTH_WARNING = 32;
11430
11545
  function countNodes($) {
11431
11546
  let count = 0;
11432
- function traverse(element) {
11433
- count++;
11434
- const el = element;
11547
+ function traverse(node) {
11548
+ const el = node;
11549
+ if (el.type === "tag") count++;
11435
11550
  if (el.children) {
11436
- for (const child of el.children) {
11437
- traverse(child);
11438
- }
11551
+ for (const child of el.children) traverse(child);
11439
11552
  }
11440
11553
  }
11441
- const root = $.root();
11442
- root.each((_, el) => traverse(el));
11554
+ $.root().each((_, el) => traverse(el));
11443
11555
  return count;
11444
11556
  }
11445
11557
  function calculateMaxDepth($) {
@@ -14325,7 +14437,7 @@ function analyzeResponsiveImages(html, url) {
14325
14437
  if (imagesWithoutDimensions >= 3) {
14326
14438
  issues.push({
14327
14439
  code: "IMAGES_MISSING_DIMENSIONS",
14328
- severity: "error",
14440
+ severity: "warning",
14329
14441
  category: "performance",
14330
14442
  title: `${imagesWithoutDimensions} images missing width/height attributes`,
14331
14443
  description: `Found ${imagesWithoutDimensions} images without explicit dimensions.`,
@@ -14340,7 +14452,8 @@ function analyzeResponsiveImages(html, url) {
14340
14452
  } else if (imagesWithoutDimensions > 0) {
14341
14453
  issues.push({
14342
14454
  code: "IMAGES_MISSING_DIMENSIONS",
14343
- severity: "warning",
14455
+ // One or two keeps a gradient below the >=3 case rather than flattening both to warning.
14456
+ severity: "notice",
14344
14457
  category: "performance",
14345
14458
  title: `${imagesWithoutDimensions} image(s) missing width/height attributes`,
14346
14459
  description: `Found ${imagesWithoutDimensions} images without explicit dimensions.`,
@@ -17710,6 +17823,7 @@ async function runFullAudit(options) {
17710
17823
  asyncChecks.push(safeAsyncIssues("Certificate", async () => (await checkCertificate(url)).issues));
17711
17824
  asyncChecks.push(safeAsync("Redirects", () => analyzeRedirects(url)));
17712
17825
  asyncChecks.push(safeAsync("Robots Validation", () => validateRobotsTxt(url)));
17826
+ asyncChecks.push(safeAsyncIssues("Crawler Access", async () => (await analyzeCrawlerAccess(url)).issues));
17713
17827
  asyncChecks.push(safeAsync("Canonical Domain", () => analyzeCanonicalDomain(url)));
17714
17828
  asyncChecks.push(safeAsyncIssues("Framework Detection", async () => {
17715
17829
  const frameworkInfo = detectFramework({ html, headers });
@@ -33097,9 +33211,37 @@ function jsonLdConcerns(html) {
33097
33211
  }
33098
33212
  return bad;
33099
33213
  }
33214
+ var PLACEHOLDERS = [
33215
+ { pattern: /\+?1[-\s.]?\(?800\)?[-\s.]?555[-\s.]?\d{4}/i, what: "a reserved 555 phone number" },
33216
+ { pattern: /\b555[-\s.]?01\d{2}\b/, what: "a reserved 555 phone number" },
33217
+ { pattern: /\bAnytown\b/i, what: '"Anytown"' },
33218
+ { pattern: /\b123 Main (Street|St)\b/i, what: '"123 Main Street"' },
33219
+ { pattern: /your_[a-z_]+/i, what: 'an unfilled "your_..." placeholder' },
33220
+ { pattern: /\b(yourdomain|your-domain|example)\.(com|org|net)\b/i, what: "a placeholder domain" },
33221
+ { pattern: /\blorem ipsum\b/i, what: "lorem ipsum" },
33222
+ { pattern: /\bXXX-XXX-XXXX\b/i, what: "an unfilled phone template" }
33223
+ ];
33224
+ function isSharedShell(html) {
33225
+ return /<div[^>]+id=["'](root|app|__next|__nuxt)["']/i.test(html);
33226
+ }
33100
33227
  function assessReplacement(path3, original, replacement) {
33101
33228
  const concerns = [];
33102
33229
  const add = (code, detail) => concerns.push({ code, detail });
33230
+ for (const { pattern, what } of PLACEHOLDERS) {
33231
+ const hit = replacement.match(pattern);
33232
+ if (hit && !(original && pattern.test(original))) {
33233
+ add("placeholder_content", `contains ${what} (${hit[0]})`);
33234
+ }
33235
+ }
33236
+ if (/\.html?$/i.test(path3) && isSharedShell(replacement)) {
33237
+ const addsCanonical = /<link[^>]+rel=["']canonical["']/i.test(replacement) && !(original && /<link[^>]+rel=["']canonical["']/i.test(original));
33238
+ if (addsCanonical) {
33239
+ add(
33240
+ "canonical_in_shared_shell",
33241
+ `${path3} mounts a single-page app, so a canonical here applies to every route`
33242
+ );
33243
+ }
33244
+ }
33103
33245
  if (/\.json$/i.test(path3) || /\.webmanifest$/i.test(path3)) {
33104
33246
  try {
33105
33247
  JSON.parse(replacement);
package/dist/index.mjs CHANGED
@@ -6862,6 +6862,120 @@ async function checkInternalRedirects(internalLinks, batchSize = 10) {
6862
6862
  return { redirectLinks };
6863
6863
  }
6864
6864
 
6865
+ // src/audit/checks/crawler-access.ts
6866
+ var CRAWLERS = [
6867
+ {
6868
+ name: "Googlebot",
6869
+ userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
6870
+ matters: "Google Search indexing",
6871
+ critical: true
6872
+ },
6873
+ {
6874
+ name: "bingbot",
6875
+ userAgent: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
6876
+ matters: "Bing indexing, and Copilot answers",
6877
+ critical: true
6878
+ },
6879
+ {
6880
+ name: "GPTBot",
6881
+ userAgent: "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
6882
+ matters: "ChatGPT being able to read and cite the page",
6883
+ critical: false
6884
+ },
6885
+ {
6886
+ name: "ClaudeBot",
6887
+ userAgent: "Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)",
6888
+ matters: "Claude being able to read and cite the page",
6889
+ critical: false
6890
+ },
6891
+ {
6892
+ name: "PerplexityBot",
6893
+ userAgent: "Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
6894
+ matters: "Perplexity being able to read and cite the page",
6895
+ critical: false
6896
+ }
6897
+ ];
6898
+ var BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
6899
+ function isRefusal(status) {
6900
+ return status === 401 || status === 403 || status === 429 || status === 503;
6901
+ }
6902
+ async function statusFor(url, userAgent) {
6903
+ try {
6904
+ const response = await httpGet(url, {
6905
+ headers: { "User-Agent": userAgent },
6906
+ timeout: 15e3,
6907
+ // We want the status itself, not an exception on 4xx/5xx.
6908
+ validateStatus: () => true
6909
+ });
6910
+ return response.status;
6911
+ } catch {
6912
+ return null;
6913
+ }
6914
+ }
6915
+ async function analyzeCrawlerAccess(url) {
6916
+ const issues = [];
6917
+ const baselineStatus = await statusFor(url, BROWSER_UA);
6918
+ const data = { baselineStatus, crawlers: [] };
6919
+ if (baselineStatus === null || baselineStatus >= 400) {
6920
+ return { issues, data };
6921
+ }
6922
+ const results = await Promise.all(
6923
+ CRAWLERS.map(async (crawler) => ({
6924
+ crawler,
6925
+ status: await statusFor(url, crawler.userAgent)
6926
+ }))
6927
+ );
6928
+ const blocked = results.filter(({ status }) => status !== null && isRefusal(status));
6929
+ data.crawlers = results.map(({ crawler, status }) => ({
6930
+ name: crawler.name,
6931
+ status,
6932
+ blocked: status !== null && isRefusal(status)
6933
+ }));
6934
+ if (blocked.length === 0) return { issues, data };
6935
+ const search = blocked.filter(({ crawler }) => crawler.critical);
6936
+ const ai = blocked.filter(({ crawler }) => !crawler.critical);
6937
+ if (search.length > 0) {
6938
+ const names = search.map(({ crawler }) => crawler.name).join(", ");
6939
+ issues.push({
6940
+ code: "SEARCH_CRAWLER_BLOCKED_AT_EDGE",
6941
+ severity: "error",
6942
+ category: "crawlability",
6943
+ title: `${names} refused at the edge, not by robots.txt`,
6944
+ description: `A browser request to this URL returned ${baselineStatus}, but ${names} received ${search.map(({ status }) => status).join(", ")}. Something in front of the origin - a CDN, WAF or bot rule - is turning the crawler away, and robots.txt cannot show you this.`,
6945
+ impact: "Pages a search crawler cannot fetch fall out of the index. This is the failure mode behind Cloudflare\u2019s 2026-09-15 default, where blocking AI training also blocked Googlebot because it is classed as a mixed-use crawler.",
6946
+ howToFix: `Check your CDN or WAF bot rules rather than robots.txt.
6947
+
6948
+ On Cloudflare: the zone's Security > Bots settings, and any "Block AI Bots" or AI Crawler Control toggle. Because Googlebot crawls for both Search and AI training, blocking Training applies to all of its functions on ad-supported pages - allow Search crawlers explicitly, or opt out of the default.
6949
+
6950
+ Confirm with Search Console's URL Inspection, which fetches as the verified Googlebot.`,
6951
+ affectedUrls: [url],
6952
+ details: {
6953
+ baselineStatus,
6954
+ blocked: search.map(({ crawler, status }) => ({ crawler: crawler.name, status })),
6955
+ note: "Observed with the crawler\u2019s user-agent. Real crawlers are also verified by IP, so a site may treat them differently."
6956
+ }
6957
+ });
6958
+ }
6959
+ if (ai.length > 0) {
6960
+ const names = ai.map(({ crawler }) => crawler.name).join(", ");
6961
+ issues.push({
6962
+ code: "AI_CRAWLER_BLOCKED_AT_EDGE",
6963
+ severity: "warning",
6964
+ category: "ai-readiness",
6965
+ title: `${names} refused at the edge, not by robots.txt`,
6966
+ description: `A browser request returned ${baselineStatus}, but ${names} received ${ai.map(({ status }) => status).join(", ")}. The block is in front of the origin, so allowing these agents in robots.txt will not change it.`,
6967
+ impact: "An assistant that cannot fetch the page cannot quote it. This is invisible to a robots.txt check, which is where most AI-visibility tools stop.",
6968
+ howToFix: `If blocking them is deliberate, nothing to do. If not, the rule is in your CDN or WAF, not robots.txt - on Cloudflare see Security > Bots and any AI Crawler Control setting.`,
6969
+ affectedUrls: [url],
6970
+ details: {
6971
+ baselineStatus,
6972
+ blocked: ai.map(({ crawler, status }) => ({ crawler: crawler.name, status }))
6973
+ }
6974
+ });
6975
+ }
6976
+ return { issues, data };
6977
+ }
6978
+
6865
6979
  // src/audit/checks/ai-readiness.ts
6866
6980
  import * as cheerio13 from "cheerio";
6867
6981
 
@@ -7886,17 +8000,14 @@ var DOM_SIZE_ERROR = 3e3;
7886
8000
  var DOM_DEPTH_WARNING = 32;
7887
8001
  function countNodes($) {
7888
8002
  let count = 0;
7889
- function traverse(element) {
7890
- count++;
7891
- const el = element;
8003
+ function traverse(node) {
8004
+ const el = node;
8005
+ if (el.type === "tag") count++;
7892
8006
  if (el.children) {
7893
- for (const child of el.children) {
7894
- traverse(child);
7895
- }
8007
+ for (const child of el.children) traverse(child);
7896
8008
  }
7897
8009
  }
7898
- const root = $.root();
7899
- root.each((_, el) => traverse(el));
8010
+ $.root().each((_, el) => traverse(el));
7900
8011
  return count;
7901
8012
  }
7902
8013
  function calculateMaxDepth($) {
@@ -10777,7 +10888,7 @@ function analyzeResponsiveImages(html, url) {
10777
10888
  if (imagesWithoutDimensions >= 3) {
10778
10889
  issues.push({
10779
10890
  code: "IMAGES_MISSING_DIMENSIONS",
10780
- severity: "error",
10891
+ severity: "warning",
10781
10892
  category: "performance",
10782
10893
  title: `${imagesWithoutDimensions} images missing width/height attributes`,
10783
10894
  description: `Found ${imagesWithoutDimensions} images without explicit dimensions.`,
@@ -10792,7 +10903,8 @@ function analyzeResponsiveImages(html, url) {
10792
10903
  } else if (imagesWithoutDimensions > 0) {
10793
10904
  issues.push({
10794
10905
  code: "IMAGES_MISSING_DIMENSIONS",
10795
- severity: "warning",
10906
+ // One or two keeps a gradient below the >=3 case rather than flattening both to warning.
10907
+ severity: "notice",
10796
10908
  category: "performance",
10797
10909
  title: `${imagesWithoutDimensions} image(s) missing width/height attributes`,
10798
10910
  description: `Found ${imagesWithoutDimensions} images without explicit dimensions.`,
@@ -14153,6 +14265,7 @@ async function runFullAudit(options) {
14153
14265
  asyncChecks.push(safeAsyncIssues("Certificate", async () => (await checkCertificate(url)).issues));
14154
14266
  asyncChecks.push(safeAsync("Redirects", () => analyzeRedirects(url)));
14155
14267
  asyncChecks.push(safeAsync("Robots Validation", () => validateRobotsTxt(url)));
14268
+ asyncChecks.push(safeAsyncIssues("Crawler Access", async () => (await analyzeCrawlerAccess(url)).issues));
14156
14269
  asyncChecks.push(safeAsync("Canonical Domain", () => analyzeCanonicalDomain(url)));
14157
14270
  asyncChecks.push(safeAsyncIssues("Framework Detection", async () => {
14158
14271
  const frameworkInfo = detectFramework({ html, headers });
@@ -29525,9 +29638,37 @@ function jsonLdConcerns(html) {
29525
29638
  }
29526
29639
  return bad;
29527
29640
  }
29641
+ var PLACEHOLDERS = [
29642
+ { pattern: /\+?1[-\s.]?\(?800\)?[-\s.]?555[-\s.]?\d{4}/i, what: "a reserved 555 phone number" },
29643
+ { pattern: /\b555[-\s.]?01\d{2}\b/, what: "a reserved 555 phone number" },
29644
+ { pattern: /\bAnytown\b/i, what: '"Anytown"' },
29645
+ { pattern: /\b123 Main (Street|St)\b/i, what: '"123 Main Street"' },
29646
+ { pattern: /your_[a-z_]+/i, what: 'an unfilled "your_..." placeholder' },
29647
+ { pattern: /\b(yourdomain|your-domain|example)\.(com|org|net)\b/i, what: "a placeholder domain" },
29648
+ { pattern: /\blorem ipsum\b/i, what: "lorem ipsum" },
29649
+ { pattern: /\bXXX-XXX-XXXX\b/i, what: "an unfilled phone template" }
29650
+ ];
29651
+ function isSharedShell(html) {
29652
+ return /<div[^>]+id=["'](root|app|__next|__nuxt)["']/i.test(html);
29653
+ }
29528
29654
  function assessReplacement(path3, original, replacement) {
29529
29655
  const concerns = [];
29530
29656
  const add = (code, detail) => concerns.push({ code, detail });
29657
+ for (const { pattern, what } of PLACEHOLDERS) {
29658
+ const hit = replacement.match(pattern);
29659
+ if (hit && !(original && pattern.test(original))) {
29660
+ add("placeholder_content", `contains ${what} (${hit[0]})`);
29661
+ }
29662
+ }
29663
+ if (/\.html?$/i.test(path3) && isSharedShell(replacement)) {
29664
+ const addsCanonical = /<link[^>]+rel=["']canonical["']/i.test(replacement) && !(original && /<link[^>]+rel=["']canonical["']/i.test(original));
29665
+ if (addsCanonical) {
29666
+ add(
29667
+ "canonical_in_shared_shell",
29668
+ `${path3} mounts a single-page app, so a canonical here applies to every route`
29669
+ );
29670
+ }
29671
+ }
29531
29672
  if (/\.json$/i.test(path3) || /\.webmanifest$/i.test(path3)) {
29532
29673
  try {
29533
29674
  JSON.parse(replacement);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankcli/agent-runtime",
3
- "version": "0.0.35",
3
+ "version": "0.0.37",
4
4
  "description": "RankCLI agent runtime - executes SEO audits and fixes with AI",
5
5
  "homepage": "https://rankcli.dev",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,8 @@
28
28
  "test": "vitest run",
29
29
  "test:watch": "vitest",
30
30
  "test:coverage": "vitest run --coverage",
31
- "benchmark:detection": "node scripts/benchmark-detection.mjs"
31
+ "benchmark:detection": "node scripts/benchmark-detection.mjs",
32
+ "typecheck": "tsc --noEmit"
32
33
  },
33
34
  "dependencies": {
34
35
  "@anthropic-ai/sdk": "^0.52.0",