@rankcli/agent-runtime 0.0.36 → 0.0.38
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.js
CHANGED
|
@@ -55,7 +55,8 @@ async function httpGet(url, config = {}) {
|
|
|
55
55
|
timeout = DEFAULT_TIMEOUT,
|
|
56
56
|
validateStatus = (status) => status >= 200 && status < 300,
|
|
57
57
|
maxRedirects = 5,
|
|
58
|
-
params
|
|
58
|
+
params,
|
|
59
|
+
maxBytes
|
|
59
60
|
} = config;
|
|
60
61
|
let finalUrl = url;
|
|
61
62
|
if (params) {
|
|
@@ -82,7 +83,7 @@ async function httpGet(url, config = {}) {
|
|
|
82
83
|
if (contentType.includes("application/json")) {
|
|
83
84
|
data = await response.json();
|
|
84
85
|
} else {
|
|
85
|
-
data = await response
|
|
86
|
+
data = await readText(response, maxBytes);
|
|
86
87
|
}
|
|
87
88
|
clearTimeout(timeoutId);
|
|
88
89
|
if (!validateStatus(response.status)) {
|
|
@@ -109,6 +110,23 @@ async function httpGet(url, config = {}) {
|
|
|
109
110
|
throw error;
|
|
110
111
|
}
|
|
111
112
|
}
|
|
113
|
+
async function readText(response, maxBytes) {
|
|
114
|
+
if (!maxBytes || !response.body) return response.text();
|
|
115
|
+
const reader = response.body.getReader();
|
|
116
|
+
const chunks = [];
|
|
117
|
+
let received = 0;
|
|
118
|
+
while (received < maxBytes) {
|
|
119
|
+
const { done, value } = await reader.read();
|
|
120
|
+
if (done) break;
|
|
121
|
+
chunks.push(value);
|
|
122
|
+
received += value.byteLength;
|
|
123
|
+
}
|
|
124
|
+
if (received >= maxBytes) await reader.cancel().catch(() => void 0);
|
|
125
|
+
const decoder = new TextDecoder();
|
|
126
|
+
let text = "";
|
|
127
|
+
for (const chunk of chunks) text += decoder.decode(chunk, { stream: true });
|
|
128
|
+
return text + decoder.decode();
|
|
129
|
+
}
|
|
112
130
|
async function httpHead(url, config = {}) {
|
|
113
131
|
const {
|
|
114
132
|
headers = {},
|
|
@@ -9996,7 +10014,9 @@ function analyzeAnchorText(html, url) {
|
|
|
9996
10014
|
} catch {
|
|
9997
10015
|
internalLinks++;
|
|
9998
10016
|
}
|
|
9999
|
-
const
|
|
10017
|
+
const imgAlt = $(el).find("img[alt]").map((_2, img) => ($(img).attr("alt") || "").trim()).get().join(" ").trim();
|
|
10018
|
+
const svgTitle = $(el).find("svg title").text().trim();
|
|
10019
|
+
const hasAccessibleText = text.length > 0 || ariaLabel || title || imgAlt.length > 0 || svgTitle.length > 0;
|
|
10000
10020
|
if (!hasAccessibleText) {
|
|
10001
10021
|
emptyAnchors++;
|
|
10002
10022
|
if (emptyAnchorUrls.length < 5) {
|
|
@@ -10401,6 +10421,121 @@ async function checkInternalRedirects(internalLinks, batchSize = 10) {
|
|
|
10401
10421
|
return { redirectLinks };
|
|
10402
10422
|
}
|
|
10403
10423
|
|
|
10424
|
+
// src/audit/checks/crawler-access.ts
|
|
10425
|
+
init_http();
|
|
10426
|
+
var CRAWLERS = [
|
|
10427
|
+
{
|
|
10428
|
+
name: "Googlebot",
|
|
10429
|
+
userAgent: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
|
|
10430
|
+
matters: "Google Search indexing",
|
|
10431
|
+
critical: true
|
|
10432
|
+
},
|
|
10433
|
+
{
|
|
10434
|
+
name: "bingbot",
|
|
10435
|
+
userAgent: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",
|
|
10436
|
+
matters: "Bing indexing, and Copilot answers",
|
|
10437
|
+
critical: true
|
|
10438
|
+
},
|
|
10439
|
+
{
|
|
10440
|
+
name: "GPTBot",
|
|
10441
|
+
userAgent: "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)",
|
|
10442
|
+
matters: "ChatGPT being able to read and cite the page",
|
|
10443
|
+
critical: false
|
|
10444
|
+
},
|
|
10445
|
+
{
|
|
10446
|
+
name: "ClaudeBot",
|
|
10447
|
+
userAgent: "Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)",
|
|
10448
|
+
matters: "Claude being able to read and cite the page",
|
|
10449
|
+
critical: false
|
|
10450
|
+
},
|
|
10451
|
+
{
|
|
10452
|
+
name: "PerplexityBot",
|
|
10453
|
+
userAgent: "Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)",
|
|
10454
|
+
matters: "Perplexity being able to read and cite the page",
|
|
10455
|
+
critical: false
|
|
10456
|
+
}
|
|
10457
|
+
];
|
|
10458
|
+
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";
|
|
10459
|
+
function isRefusal(status) {
|
|
10460
|
+
return status === 401 || status === 403 || status === 429 || status === 503;
|
|
10461
|
+
}
|
|
10462
|
+
async function statusFor(url, userAgent) {
|
|
10463
|
+
try {
|
|
10464
|
+
const response = await httpGet(url, {
|
|
10465
|
+
headers: { "User-Agent": userAgent },
|
|
10466
|
+
timeout: 15e3,
|
|
10467
|
+
// We want the status itself, not an exception on 4xx/5xx.
|
|
10468
|
+
validateStatus: () => true
|
|
10469
|
+
});
|
|
10470
|
+
return response.status;
|
|
10471
|
+
} catch {
|
|
10472
|
+
return null;
|
|
10473
|
+
}
|
|
10474
|
+
}
|
|
10475
|
+
async function analyzeCrawlerAccess(url) {
|
|
10476
|
+
const issues = [];
|
|
10477
|
+
const baselineStatus = await statusFor(url, BROWSER_UA);
|
|
10478
|
+
const data = { baselineStatus, crawlers: [] };
|
|
10479
|
+
if (baselineStatus === null || baselineStatus >= 400) {
|
|
10480
|
+
return { issues, data };
|
|
10481
|
+
}
|
|
10482
|
+
const results = await Promise.all(
|
|
10483
|
+
CRAWLERS.map(async (crawler) => ({
|
|
10484
|
+
crawler,
|
|
10485
|
+
status: await statusFor(url, crawler.userAgent)
|
|
10486
|
+
}))
|
|
10487
|
+
);
|
|
10488
|
+
const blocked = results.filter(({ status }) => status !== null && isRefusal(status));
|
|
10489
|
+
data.crawlers = results.map(({ crawler, status }) => ({
|
|
10490
|
+
name: crawler.name,
|
|
10491
|
+
status,
|
|
10492
|
+
blocked: status !== null && isRefusal(status)
|
|
10493
|
+
}));
|
|
10494
|
+
if (blocked.length === 0) return { issues, data };
|
|
10495
|
+
const search = blocked.filter(({ crawler }) => crawler.critical);
|
|
10496
|
+
const ai = blocked.filter(({ crawler }) => !crawler.critical);
|
|
10497
|
+
if (search.length > 0) {
|
|
10498
|
+
const names = search.map(({ crawler }) => crawler.name).join(", ");
|
|
10499
|
+
issues.push({
|
|
10500
|
+
code: "SEARCH_CRAWLER_BLOCKED_AT_EDGE",
|
|
10501
|
+
severity: "error",
|
|
10502
|
+
category: "crawlability",
|
|
10503
|
+
title: `${names} refused at the edge, not by robots.txt`,
|
|
10504
|
+
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.`,
|
|
10505
|
+
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.",
|
|
10506
|
+
howToFix: `Check your CDN or WAF bot rules rather than robots.txt.
|
|
10507
|
+
|
|
10508
|
+
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.
|
|
10509
|
+
|
|
10510
|
+
Confirm with Search Console's URL Inspection, which fetches as the verified Googlebot.`,
|
|
10511
|
+
affectedUrls: [url],
|
|
10512
|
+
details: {
|
|
10513
|
+
baselineStatus,
|
|
10514
|
+
blocked: search.map(({ crawler, status }) => ({ crawler: crawler.name, status })),
|
|
10515
|
+
note: "Observed with the crawler\u2019s user-agent. Real crawlers are also verified by IP, so a site may treat them differently."
|
|
10516
|
+
}
|
|
10517
|
+
});
|
|
10518
|
+
}
|
|
10519
|
+
if (ai.length > 0) {
|
|
10520
|
+
const names = ai.map(({ crawler }) => crawler.name).join(", ");
|
|
10521
|
+
issues.push({
|
|
10522
|
+
code: "AI_CRAWLER_BLOCKED_AT_EDGE",
|
|
10523
|
+
severity: "warning",
|
|
10524
|
+
category: "ai-readiness",
|
|
10525
|
+
title: `${names} refused at the edge, not by robots.txt`,
|
|
10526
|
+
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.`,
|
|
10527
|
+
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.",
|
|
10528
|
+
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.`,
|
|
10529
|
+
affectedUrls: [url],
|
|
10530
|
+
details: {
|
|
10531
|
+
baselineStatus,
|
|
10532
|
+
blocked: ai.map(({ crawler, status }) => ({ crawler: crawler.name, status }))
|
|
10533
|
+
}
|
|
10534
|
+
});
|
|
10535
|
+
}
|
|
10536
|
+
return { issues, data };
|
|
10537
|
+
}
|
|
10538
|
+
|
|
10404
10539
|
// src/audit/checks/ai-readiness.ts
|
|
10405
10540
|
init_http();
|
|
10406
10541
|
var cheerio13 = __toESM(require("cheerio"));
|
|
@@ -14450,6 +14585,107 @@ function analyzeResponsiveImages(html, url) {
|
|
|
14450
14585
|
// src/audit/checks/bing-optimization.ts
|
|
14451
14586
|
var cheerio28 = __toESM(require("cheerio"));
|
|
14452
14587
|
init_http();
|
|
14588
|
+
|
|
14589
|
+
// src/audit/checks/site-verification.ts
|
|
14590
|
+
init_http();
|
|
14591
|
+
var UNCHECKED = {
|
|
14592
|
+
dnsChecked: false,
|
|
14593
|
+
googleDns: false,
|
|
14594
|
+
bingDns: false,
|
|
14595
|
+
bingSiteAuthFile: false
|
|
14596
|
+
};
|
|
14597
|
+
var DNS_TIMEOUT_MS = 2e3;
|
|
14598
|
+
var FILE_TIMEOUT_MS = 5e3;
|
|
14599
|
+
var CACHE_TTL_MS = 10 * 60 * 1e3;
|
|
14600
|
+
var cache = /* @__PURE__ */ new Map();
|
|
14601
|
+
function lookupSiteVerification(url) {
|
|
14602
|
+
let parsed;
|
|
14603
|
+
try {
|
|
14604
|
+
parsed = new URL(url);
|
|
14605
|
+
} catch {
|
|
14606
|
+
return Promise.resolve(UNCHECKED);
|
|
14607
|
+
}
|
|
14608
|
+
const host = parsed.hostname.toLowerCase();
|
|
14609
|
+
const now = Date.now();
|
|
14610
|
+
const cached = cache.get(host);
|
|
14611
|
+
if (cached && cached.expires > now) return cached.result;
|
|
14612
|
+
const result = Promise.all([readTxtRecords(host), fetchBingSiteAuth(parsed.origin)]).then(
|
|
14613
|
+
([txt, bingSiteAuthFile]) => ({
|
|
14614
|
+
dnsChecked: txt.checked,
|
|
14615
|
+
googleDns: txt.records.some((record) => /^google-site-verification=/i.test(record)),
|
|
14616
|
+
bingDns: txt.records.some((record) => /^MS=/i.test(record)),
|
|
14617
|
+
bingSiteAuthFile
|
|
14618
|
+
})
|
|
14619
|
+
);
|
|
14620
|
+
cache.set(host, { expires: now + CACHE_TTL_MS, result });
|
|
14621
|
+
return result;
|
|
14622
|
+
}
|
|
14623
|
+
var resolveTxtLoader;
|
|
14624
|
+
function loadResolveTxt() {
|
|
14625
|
+
resolveTxtLoader ??= (async () => {
|
|
14626
|
+
if (typeof process === "undefined" || !process.versions?.node) return null;
|
|
14627
|
+
try {
|
|
14628
|
+
const specifier = "node:dns";
|
|
14629
|
+
const dns = await import(specifier);
|
|
14630
|
+
const resolveTxt = dns?.promises?.resolveTxt ?? dns?.default?.promises?.resolveTxt;
|
|
14631
|
+
return typeof resolveTxt === "function" ? resolveTxt : null;
|
|
14632
|
+
} catch {
|
|
14633
|
+
return null;
|
|
14634
|
+
}
|
|
14635
|
+
})();
|
|
14636
|
+
return resolveTxtLoader;
|
|
14637
|
+
}
|
|
14638
|
+
function domainCandidates(host) {
|
|
14639
|
+
if (/^[\d.]+$/.test(host) || host.includes(":")) return [];
|
|
14640
|
+
const labels = host.split(".").filter(Boolean);
|
|
14641
|
+
const names = [];
|
|
14642
|
+
for (let n = 2; n <= labels.length && names.length < 4; n++) {
|
|
14643
|
+
names.push(labels.slice(-n).join("."));
|
|
14644
|
+
}
|
|
14645
|
+
return names.length > 0 ? names : [host];
|
|
14646
|
+
}
|
|
14647
|
+
async function readTxtRecords(host) {
|
|
14648
|
+
const resolveTxt = await loadResolveTxt();
|
|
14649
|
+
if (!resolveTxt) return { checked: false, records: [] };
|
|
14650
|
+
const lookups = await Promise.all(domainCandidates(host).map((name) => resolveTxtWithin(resolveTxt, name)));
|
|
14651
|
+
return {
|
|
14652
|
+
checked: lookups.every((records) => records !== null),
|
|
14653
|
+
records: lookups.flatMap((records) => records ?? [])
|
|
14654
|
+
};
|
|
14655
|
+
}
|
|
14656
|
+
var NO_RECORD_CODES = /* @__PURE__ */ new Set(["ENODATA", "ENOTFOUND"]);
|
|
14657
|
+
async function resolveTxtWithin(resolveTxt, name) {
|
|
14658
|
+
let timer;
|
|
14659
|
+
const timeout = new Promise((resolve) => {
|
|
14660
|
+
timer = setTimeout(() => resolve(null), DNS_TIMEOUT_MS);
|
|
14661
|
+
});
|
|
14662
|
+
try {
|
|
14663
|
+
return await Promise.race([
|
|
14664
|
+
resolveTxt(name).then(
|
|
14665
|
+
(records) => records.map((chunks) => chunks.join("").trim()),
|
|
14666
|
+
(error) => NO_RECORD_CODES.has(error?.code ?? "") ? [] : null
|
|
14667
|
+
),
|
|
14668
|
+
timeout
|
|
14669
|
+
]);
|
|
14670
|
+
} catch {
|
|
14671
|
+
return null;
|
|
14672
|
+
} finally {
|
|
14673
|
+
clearTimeout(timer);
|
|
14674
|
+
}
|
|
14675
|
+
}
|
|
14676
|
+
async function fetchBingSiteAuth(origin) {
|
|
14677
|
+
try {
|
|
14678
|
+
const response = await httpGet(`${origin}/BingSiteAuth.xml`, {
|
|
14679
|
+
timeout: FILE_TIMEOUT_MS,
|
|
14680
|
+
maxBytes: 64 * 1024
|
|
14681
|
+
});
|
|
14682
|
+
return response.status === 200 && typeof response.data === "string" && !looksLikeHtmlFallback(response.data, response.headers["content-type"]) && /<users[\s>]/i.test(response.data);
|
|
14683
|
+
} catch {
|
|
14684
|
+
return false;
|
|
14685
|
+
}
|
|
14686
|
+
}
|
|
14687
|
+
|
|
14688
|
+
// src/audit/checks/bing-optimization.ts
|
|
14453
14689
|
async function analyzeBingOptimization(html, url) {
|
|
14454
14690
|
const issues = [];
|
|
14455
14691
|
const $ = cheerio28.load(html);
|
|
@@ -14458,12 +14694,9 @@ async function analyzeBingOptimization(html, url) {
|
|
|
14458
14694
|
const detectedIssues = [];
|
|
14459
14695
|
const bingMetaTag = $('meta[name="msvalidate.01"]').attr("content");
|
|
14460
14696
|
const hasBingMetaTag = !!bingMetaTag && bingMetaTag.length > 0;
|
|
14461
|
-
|
|
14462
|
-
|
|
14463
|
-
|
|
14464
|
-
hasBingSiteAuth = authResponse.status === 200 && authResponse.data.includes("<?xml") && authResponse.data.toLowerCase().includes("users");
|
|
14465
|
-
} catch {
|
|
14466
|
-
}
|
|
14697
|
+
const verification = await lookupSiteVerification(url);
|
|
14698
|
+
const hasBingSiteAuth = verification.bingSiteAuthFile;
|
|
14699
|
+
const hasBingDnsRecord = verification.bingDns;
|
|
14467
14700
|
let hasBingIndexNowKey = false;
|
|
14468
14701
|
try {
|
|
14469
14702
|
const keyPatterns = [
|
|
@@ -14511,22 +14744,25 @@ async function analyzeBingOptimization(html, url) {
|
|
|
14511
14744
|
bingSitemapAccessible = sitemapResponse.status === 200 && sitemapResponse.data.includes("<?xml");
|
|
14512
14745
|
} catch {
|
|
14513
14746
|
}
|
|
14514
|
-
if (!hasBingMetaTag && !hasBingSiteAuth) {
|
|
14747
|
+
if (!hasBingMetaTag && !hasBingSiteAuth && !hasBingDnsRecord) {
|
|
14515
14748
|
detectedIssues.push("No Bing site verification");
|
|
14516
14749
|
issues.push({
|
|
14517
14750
|
code: "BING_NOT_VERIFIED",
|
|
14518
14751
|
severity: "warning",
|
|
14519
14752
|
category: "ai-readiness",
|
|
14520
|
-
title: "Site not verified with Bing Webmaster Tools",
|
|
14521
|
-
description: "ChatGPT Search uses Bing's index. Verifying your site with Bing Webmaster Tools ensures proper indexing and access to Bing-specific SEO tools.",
|
|
14753
|
+
title: verification.dnsChecked ? "Site not verified with Bing Webmaster Tools" : "Bing Webmaster Tools verification not visible in the HTML",
|
|
14754
|
+
description: verification.dnsChecked ? "No msvalidate.01 meta tag in the HTML, no MS= TXT record on the domain and no /BingSiteAuth.xml file. ChatGPT Search uses Bing's index. Verifying your site with Bing Webmaster Tools ensures proper indexing and access to Bing-specific SEO tools." : "No msvalidate.01 meta tag in the HTML and no /BingSiteAuth.xml file. The site may be verified through a DNS record instead, which this run could not check. ChatGPT Search uses Bing's index. Verifying your site with Bing Webmaster Tools ensures proper indexing and access to Bing-specific SEO tools.",
|
|
14522
14755
|
impact: "May reduce visibility in ChatGPT Search results and miss Bing-specific optimization opportunities.",
|
|
14523
|
-
howToFix: "
|
|
14756
|
+
howToFix: "If the site is not yet in Bing Webmaster Tools: 1) Go to Bing Webmaster Tools, 2) Add your site (or import it from Google Search Console), 3) Choose a verification method (meta tag, DNS record or XML file), 4) Add the verification code to your site.",
|
|
14524
14757
|
affectedUrls: [url],
|
|
14525
14758
|
details: {
|
|
14526
14759
|
hasBingMetaTag,
|
|
14527
14760
|
hasBingSiteAuth,
|
|
14761
|
+
hasBingDnsRecord,
|
|
14762
|
+
dnsChecked: verification.dnsChecked,
|
|
14528
14763
|
verificationMethods: [
|
|
14529
14764
|
'Add <meta name="msvalidate.01" content="YOUR_CODE"> to <head>',
|
|
14765
|
+
"Add an MS=YOUR_CODE TXT record to the domain's DNS",
|
|
14530
14766
|
"Upload BingSiteAuth.xml to root directory"
|
|
14531
14767
|
]
|
|
14532
14768
|
}
|
|
@@ -14586,6 +14822,8 @@ async function analyzeBingOptimization(html, url) {
|
|
|
14586
14822
|
data: {
|
|
14587
14823
|
hasBingSiteAuth,
|
|
14588
14824
|
hasBingMetaTag,
|
|
14825
|
+
hasBingDnsRecord,
|
|
14826
|
+
dnsChecked: verification.dnsChecked,
|
|
14589
14827
|
hasBingIndexNowKey,
|
|
14590
14828
|
bingPlacesDetected,
|
|
14591
14829
|
bingSitemapAccessible,
|
|
@@ -16571,6 +16809,10 @@ function analyzePageResources(html, url) {
|
|
|
16571
16809
|
// src/audit/checks/responsive-css.ts
|
|
16572
16810
|
init_http();
|
|
16573
16811
|
var cheerio35 = __toESM(require("cheerio"));
|
|
16812
|
+
var MAX_STYLESHEETS = 3;
|
|
16813
|
+
var MAX_STYLESHEET_BYTES = 1024 * 1024;
|
|
16814
|
+
var STYLESHEET_TIMEOUT_MS = 3e3;
|
|
16815
|
+
var RESPONSIVE_SIGNAL = /@media\b|@container\b|\bclamp\(|(?<![\w-])(?:min|max)\(/i;
|
|
16574
16816
|
function extractMediaQueries(css) {
|
|
16575
16817
|
const mediaQueries = [];
|
|
16576
16818
|
const regex = /@media\s*([^{]+)/g;
|
|
@@ -16580,36 +16822,31 @@ function extractMediaQueries(css) {
|
|
|
16580
16822
|
}
|
|
16581
16823
|
return mediaQueries;
|
|
16582
16824
|
}
|
|
16825
|
+
var WIDTH_RULE = /(?:(?:min|max)-width\s*:|width\s*(?:<=|>=|<|>))\s*(\d+(?:\.\d+)?)\s*(px|em|rem)?|(\d+(?:\.\d+)?)\s*(px|em|rem)?\s*(?:<=|>=|<|>)\s*width/gi;
|
|
16583
16826
|
function classifyBreakpoints(mediaQueries) {
|
|
16584
16827
|
const breakpoints = /* @__PURE__ */ new Set();
|
|
16585
16828
|
let hasMobile = false;
|
|
16586
16829
|
let hasTablet = false;
|
|
16587
16830
|
let hasDesktop = false;
|
|
16588
16831
|
for (const query of mediaQueries) {
|
|
16589
|
-
const widthMatches = query.match(/(?:min|max)-width\s*:\s*(\d+)(?:px|em|rem)?/gi);
|
|
16590
|
-
if (widthMatches) {
|
|
16591
|
-
for (const match of widthMatches) {
|
|
16592
|
-
const valueMatch = match.match(/(\d+)/);
|
|
16593
|
-
if (valueMatch) {
|
|
16594
|
-
const value = parseInt(valueMatch[1], 10);
|
|
16595
|
-
breakpoints.add(`${value}px`);
|
|
16596
|
-
if (value <= 480) {
|
|
16597
|
-
hasMobile = true;
|
|
16598
|
-
} else if (value <= 768) {
|
|
16599
|
-
hasMobile = true;
|
|
16600
|
-
} else if (value <= 1024) {
|
|
16601
|
-
hasTablet = true;
|
|
16602
|
-
} else {
|
|
16603
|
-
hasDesktop = true;
|
|
16604
|
-
}
|
|
16605
|
-
}
|
|
16606
|
-
}
|
|
16607
|
-
}
|
|
16608
|
-
if (/screen\s+and/i.test(query)) {
|
|
16609
|
-
}
|
|
16610
16832
|
if (/print/i.test(query)) {
|
|
16611
16833
|
continue;
|
|
16612
16834
|
}
|
|
16835
|
+
for (const match of query.matchAll(WIDTH_RULE)) {
|
|
16836
|
+
const raw = match[1] ?? match[3];
|
|
16837
|
+
const unit = (match[2] ?? match[4] ?? "px").toLowerCase();
|
|
16838
|
+
const value = Math.round(parseFloat(raw) * (unit === "px" ? 1 : 16));
|
|
16839
|
+
breakpoints.add(`${value}px`);
|
|
16840
|
+
if (value <= 480) {
|
|
16841
|
+
hasMobile = true;
|
|
16842
|
+
} else if (value <= 768) {
|
|
16843
|
+
hasMobile = true;
|
|
16844
|
+
} else if (value <= 1024) {
|
|
16845
|
+
hasTablet = true;
|
|
16846
|
+
} else {
|
|
16847
|
+
hasDesktop = true;
|
|
16848
|
+
}
|
|
16849
|
+
}
|
|
16613
16850
|
}
|
|
16614
16851
|
return {
|
|
16615
16852
|
hasMobile,
|
|
@@ -16627,6 +16864,7 @@ async function analyzeResponsiveCss(html, url) {
|
|
|
16627
16864
|
const allMediaQueries = [];
|
|
16628
16865
|
const externalSources = [];
|
|
16629
16866
|
let inlineCount = 0;
|
|
16867
|
+
let hasResponsiveSignals = false;
|
|
16630
16868
|
$("style").each((_, el) => {
|
|
16631
16869
|
const css = $(el).text();
|
|
16632
16870
|
const queries = extractMediaQueries(css);
|
|
@@ -16634,39 +16872,43 @@ async function analyzeResponsiveCss(html, url) {
|
|
|
16634
16872
|
allMediaQueries.push(...queries);
|
|
16635
16873
|
inlineCount += queries.length;
|
|
16636
16874
|
}
|
|
16875
|
+
if (RESPONSIVE_SIGNAL.test(css)) hasResponsiveSignals = true;
|
|
16637
16876
|
});
|
|
16638
|
-
const
|
|
16639
|
-
|
|
16877
|
+
const sameOrigin = [];
|
|
16878
|
+
let crossOrigin = 0;
|
|
16879
|
+
$("link[rel][href]").each((_, el) => {
|
|
16880
|
+
if (!/\bstylesheet\b/i.test($(el).attr("rel") ?? "")) return;
|
|
16881
|
+
const href = $(el).attr("href");
|
|
16882
|
+
if (!href || href.startsWith("data:")) return;
|
|
16640
16883
|
try {
|
|
16641
|
-
const
|
|
16642
|
-
|
|
16884
|
+
const resolved = new URL(href, url);
|
|
16885
|
+
if (resolved.hostname === baseUrl.hostname) sameOrigin.push(resolved.href);
|
|
16886
|
+
else crossOrigin++;
|
|
16643
16887
|
} catch {
|
|
16644
|
-
|
|
16888
|
+
crossOrigin++;
|
|
16645
16889
|
}
|
|
16646
16890
|
});
|
|
16647
|
-
|
|
16648
|
-
|
|
16649
|
-
|
|
16650
|
-
|
|
16651
|
-
|
|
16652
|
-
|
|
16653
|
-
|
|
16654
|
-
|
|
16655
|
-
|
|
16891
|
+
let fetchFailed = 0;
|
|
16892
|
+
await Promise.all(
|
|
16893
|
+
sameOrigin.slice(0, MAX_STYLESHEETS).map(async (cssUrl) => {
|
|
16894
|
+
try {
|
|
16895
|
+
const response = await httpGet(cssUrl, {
|
|
16896
|
+
timeout: STYLESHEET_TIMEOUT_MS,
|
|
16897
|
+
maxBytes: MAX_STYLESHEET_BYTES
|
|
16898
|
+
});
|
|
16899
|
+
if (typeof response.data !== "string") throw new Error("not text");
|
|
16656
16900
|
const queries = extractMediaQueries(response.data);
|
|
16657
|
-
if (queries.length > 0)
|
|
16658
|
-
|
|
16901
|
+
if (queries.length > 0) allMediaQueries.push(...queries);
|
|
16902
|
+
if (RESPONSIVE_SIGNAL.test(response.data)) {
|
|
16903
|
+
hasResponsiveSignals = true;
|
|
16659
16904
|
externalSources.push(cssUrl);
|
|
16660
16905
|
}
|
|
16906
|
+
} catch {
|
|
16907
|
+
fetchFailed++;
|
|
16661
16908
|
}
|
|
16662
|
-
}
|
|
16663
|
-
|
|
16664
|
-
|
|
16665
|
-
await Promise.race([
|
|
16666
|
-
Promise.all(cssPromises),
|
|
16667
|
-
new Promise((resolve) => setTimeout(resolve, 6e3))
|
|
16668
|
-
// 6s overall timeout
|
|
16669
|
-
]);
|
|
16909
|
+
})
|
|
16910
|
+
);
|
|
16911
|
+
const stylesheetsCovered = crossOrigin === 0 && fetchFailed === 0 && sameOrigin.length <= MAX_STYLESHEETS;
|
|
16670
16912
|
const hasMediaQueries = allMediaQueries.length > 0;
|
|
16671
16913
|
const { hasMobile, hasTablet, hasDesktop, breakpoints } = classifyBreakpoints(allMediaQueries);
|
|
16672
16914
|
if (!hasViewport) {
|
|
@@ -16681,21 +16923,22 @@ async function analyzeResponsiveCss(html, url) {
|
|
|
16681
16923
|
affectedUrls: [url]
|
|
16682
16924
|
});
|
|
16683
16925
|
}
|
|
16684
|
-
if (!
|
|
16926
|
+
if (!hasResponsiveSignals && stylesheetsCovered) {
|
|
16685
16927
|
issues.push({
|
|
16686
16928
|
code: "RESPONSIVE_NO_MEDIA_QUERIES",
|
|
16687
16929
|
severity: "warning",
|
|
16688
16930
|
category: "mobile",
|
|
16689
16931
|
title: "No CSS media queries detected",
|
|
16690
|
-
description: "No @media queries found in inline styles or first-party stylesheets.",
|
|
16932
|
+
description: "No @media or @container queries and no fluid sizing (clamp, min, max) found in inline styles or first-party stylesheets.",
|
|
16691
16933
|
impact: "Without media queries, the site likely uses fixed layouts that do not adapt to different screen sizes. This hurts mobile usability and SEO.",
|
|
16692
16934
|
howToFix: "Add CSS media queries to adapt layout for different screen sizes. Consider using a mobile-first approach with min-width breakpoints.",
|
|
16693
16935
|
affectedUrls: [url],
|
|
16694
16936
|
details: {
|
|
16695
|
-
suggestion: "Common breakpoints: 480px (mobile), 768px (tablet), 1024px (desktop)"
|
|
16937
|
+
suggestion: "Common breakpoints: 480px (mobile), 768px (tablet), 1024px (desktop)",
|
|
16938
|
+
stylesheetsScanned: sameOrigin.length
|
|
16696
16939
|
}
|
|
16697
16940
|
});
|
|
16698
|
-
} else if (!hasMobile && !hasTablet) {
|
|
16941
|
+
} else if (hasMediaQueries && !hasMobile && !hasTablet) {
|
|
16699
16942
|
issues.push({
|
|
16700
16943
|
code: "RESPONSIVE_NO_MOBILE_BREAKPOINT",
|
|
16701
16944
|
severity: "warning",
|
|
@@ -16721,6 +16964,8 @@ async function analyzeResponsiveCss(html, url) {
|
|
|
16721
16964
|
inline: inlineCount,
|
|
16722
16965
|
external: externalSources
|
|
16723
16966
|
},
|
|
16967
|
+
hasResponsiveSignals,
|
|
16968
|
+
stylesheetsCovered,
|
|
16724
16969
|
breakpoints,
|
|
16725
16970
|
hasMobileBreakpoint: hasMobile,
|
|
16726
16971
|
hasTabletBreakpoint: hasTablet,
|
|
@@ -17359,7 +17604,22 @@ var urlSafetyDatabase = {
|
|
|
17359
17604
|
|
|
17360
17605
|
// src/audit/checks/tracking-verification.ts
|
|
17361
17606
|
var cheerio36 = __toESM(require("cheerio"));
|
|
17362
|
-
|
|
17607
|
+
var OTHER_ANALYTICS = [
|
|
17608
|
+
{ name: "Cloudflare Web Analytics", pattern: /cloudflareinsights\.com\/beacon(?:\.min)?\.js/i },
|
|
17609
|
+
{ name: "Plausible", pattern: /plausible\.io\/js\/|<script[^>]*data-domain=[^>]*plausible|<script[^>]*plausible[^>]*data-domain=/i },
|
|
17610
|
+
{ name: "Fathom", pattern: /cdn\.usefathom\.com/i },
|
|
17611
|
+
// data-website-id is Umami's attribute, on the hosted script and on self-hosted ones alike.
|
|
17612
|
+
{ name: "Umami", pattern: /<script[^>]*\bdata-website-id=/i },
|
|
17613
|
+
{ name: "PostHog", pattern: /posthog\.init\s*\(|\.posthog\.com\//i },
|
|
17614
|
+
{ name: "Matomo", pattern: /matomo\.js|piwik\.js|_paq\s*(?:=|\.push)/i },
|
|
17615
|
+
{ name: "Simple Analytics", pattern: /scripts\.simpleanalyticscdn\.com/i },
|
|
17616
|
+
{ name: "Mixpanel", pattern: /cdn\.mxpnl\.com|mixpanel\.init\s*\(/i },
|
|
17617
|
+
{ name: "Segment", pattern: /cdn\.segment\.com/i }
|
|
17618
|
+
];
|
|
17619
|
+
function detectOtherAnalytics(html) {
|
|
17620
|
+
return OTHER_ANALYTICS.filter(({ pattern }) => pattern.test(html)).map(({ name }) => name);
|
|
17621
|
+
}
|
|
17622
|
+
async function analyzeTrackingVerification(html, url) {
|
|
17363
17623
|
const issues = [];
|
|
17364
17624
|
const $ = cheerio36.load(html);
|
|
17365
17625
|
const ga4Match = html.match(/gtag\s*\(\s*['"]config['"]\s*,\s*['"](G-[A-Z0-9]+)['"]/i) || html.match(/googletagmanager\.com\/gtag\/js\?id=(G-[A-Z0-9]+)/i);
|
|
@@ -17369,10 +17629,16 @@ function analyzeTrackingVerification(html, url) {
|
|
|
17369
17629
|
const hasGtm = !!gtmMatch;
|
|
17370
17630
|
const gtmContainerId = gtmMatch ? gtmMatch[1] : void 0;
|
|
17371
17631
|
const hasGtmNoscript = /googletagmanager\.com\/ns\.html\?id=GTM-[A-Z0-9]+/i.test(html);
|
|
17632
|
+
const otherAnalytics = detectOtherAnalytics(html);
|
|
17633
|
+
const verification = await lookupSiteVerification(url);
|
|
17372
17634
|
const gscMeta = $('meta[name="google-site-verification"]').attr("content");
|
|
17373
|
-
const
|
|
17635
|
+
const hasGscMeta = !!(gscMeta && gscMeta.length > 10);
|
|
17636
|
+
const hasGscVerification = hasGscMeta || verification.googleDns;
|
|
17637
|
+
const gscSource = hasGscMeta ? "meta" : verification.googleDns ? "dns" : void 0;
|
|
17374
17638
|
const bingMeta = $('meta[name="msvalidate.01"]').attr("content");
|
|
17375
|
-
const
|
|
17639
|
+
const hasBingMeta = !!(bingMeta && bingMeta.length > 10);
|
|
17640
|
+
const hasBingVerification = hasBingMeta || verification.bingDns || verification.bingSiteAuthFile;
|
|
17641
|
+
const bingSource = hasBingMeta ? "meta" : verification.bingDns ? "dns" : verification.bingSiteAuthFile ? "file" : void 0;
|
|
17376
17642
|
const jsonLdScripts = $('script[type="application/ld+json"]');
|
|
17377
17643
|
const hasMicrodata = $('[itemtype*="schema.org"]').length > 0;
|
|
17378
17644
|
const hasJsonLd = jsonLdScripts.length > 0;
|
|
@@ -17394,13 +17660,15 @@ function analyzeTrackingVerification(html, url) {
|
|
|
17394
17660
|
const data = {
|
|
17395
17661
|
ga4: { detected: hasGa4, measurementId: ga4MeasurementId },
|
|
17396
17662
|
gtm: { detected: hasGtm, containerId: gtmContainerId, hasNoscript: hasGtmNoscript },
|
|
17397
|
-
|
|
17398
|
-
|
|
17663
|
+
otherAnalytics,
|
|
17664
|
+
gsc: { verified: hasGscVerification, verificationCode: gscMeta, source: gscSource },
|
|
17665
|
+
bing: { verified: hasBingVerification, verificationCode: bingMeta, source: bingSource },
|
|
17666
|
+
dnsChecked: verification.dnsChecked,
|
|
17399
17667
|
schemaOrg: { detected: hasJsonLd || hasMicrodata, types: schemaTypes, hasValidJson },
|
|
17400
17668
|
sitemap: { inRobotsTxt: false }
|
|
17401
17669
|
// Set later if we check robots.txt
|
|
17402
17670
|
};
|
|
17403
|
-
if (!hasGa4 && !hasGtm) {
|
|
17671
|
+
if (!hasGa4 && !hasGtm && otherAnalytics.length === 0) {
|
|
17404
17672
|
issues.push({
|
|
17405
17673
|
code: "GA4_MISSING",
|
|
17406
17674
|
severity: "warning",
|
|
@@ -17413,40 +17681,46 @@ function analyzeTrackingVerification(html, url) {
|
|
|
17413
17681
|
});
|
|
17414
17682
|
}
|
|
17415
17683
|
if (!hasGscVerification) {
|
|
17684
|
+
const dnsChecked = verification.dnsChecked;
|
|
17416
17685
|
issues.push({
|
|
17417
17686
|
code: "GSC_VERIFICATION_MISSING",
|
|
17418
17687
|
severity: "warning",
|
|
17419
17688
|
category: "on-page",
|
|
17420
|
-
title: "Google Search Console verification not found",
|
|
17421
|
-
description: "No GSC verification meta tag
|
|
17689
|
+
title: dnsChecked ? "Google Search Console verification not found" : "Google Search Console verification not visible in the HTML",
|
|
17690
|
+
description: dnsChecked ? "No google-site-verification meta tag in the HTML and no google-site-verification TXT record on the domain. GSC provides essential data about your search performance." : "No google-site-verification meta tag in the HTML. The site may be verified through a DNS TXT record instead, which this run could not check. GSC provides essential data about your search performance.",
|
|
17422
17691
|
impact: "Missing critical insights into Google search visibility, impressions, and clicks.",
|
|
17423
|
-
howToFix: "
|
|
17424
|
-
affectedUrls: [url]
|
|
17692
|
+
howToFix: dnsChecked ? "Verify the site in Google Search Console: add the meta tag to <head>, or a TXT record to the domain's DNS. Get it from search.google.com/search-console." : "If the site is not yet verified in Google Search Console, add the meta tag to <head> or a TXT record to the domain's DNS. Get it from search.google.com/search-console.",
|
|
17693
|
+
affectedUrls: [url],
|
|
17694
|
+
details: { dnsChecked }
|
|
17425
17695
|
});
|
|
17426
17696
|
}
|
|
17427
17697
|
if (!hasBingVerification) {
|
|
17698
|
+
const dnsChecked = verification.dnsChecked;
|
|
17428
17699
|
issues.push({
|
|
17429
17700
|
code: "BING_VERIFICATION_MISSING",
|
|
17430
17701
|
severity: "notice",
|
|
17431
17702
|
category: "on-page",
|
|
17432
|
-
title: "Bing Webmaster Tools verification not found",
|
|
17433
|
-
description: "No Bing
|
|
17703
|
+
title: dnsChecked ? "Bing Webmaster Tools verification not found" : "Bing Webmaster Tools verification not visible in the HTML",
|
|
17704
|
+
description: dnsChecked ? "No msvalidate.01 meta tag in the HTML, no MS= TXT record on the domain and no /BingSiteAuth.xml file. Bing/Yahoo account for 5-8% of search traffic." : "No msvalidate.01 meta tag in the HTML and no /BingSiteAuth.xml file. The site may be verified through a DNS record instead, which this run could not check. Bing/Yahoo account for 5-8% of search traffic.",
|
|
17434
17705
|
impact: "Missing insights on Bing/Yahoo search traffic.",
|
|
17435
|
-
howToFix: "
|
|
17436
|
-
affectedUrls: [url]
|
|
17706
|
+
howToFix: "If the site is not yet in Bing Webmaster Tools, add it there: import it from Google Search Console, or verify with the meta tag, a DNS record or BingSiteAuth.xml. Get it from bing.com/webmasters.",
|
|
17707
|
+
affectedUrls: [url],
|
|
17708
|
+
details: { dnsChecked }
|
|
17437
17709
|
});
|
|
17438
17710
|
}
|
|
17439
17711
|
if (!hasGtm) {
|
|
17440
|
-
|
|
17441
|
-
|
|
17442
|
-
|
|
17443
|
-
|
|
17444
|
-
|
|
17445
|
-
|
|
17446
|
-
|
|
17447
|
-
|
|
17448
|
-
|
|
17449
|
-
|
|
17712
|
+
if (otherAnalytics.length === 0) {
|
|
17713
|
+
issues.push({
|
|
17714
|
+
code: "GTM_MISSING",
|
|
17715
|
+
severity: "notice",
|
|
17716
|
+
category: "on-page",
|
|
17717
|
+
title: "Google Tag Manager not detected",
|
|
17718
|
+
description: "GTM not found. While not required, GTM makes it easier to manage tracking tags without code changes.",
|
|
17719
|
+
impact: "Tag management requires developer involvement for each change.",
|
|
17720
|
+
howToFix: "Consider using GTM for easier tag management. It allows marketing to add tags without developer help.",
|
|
17721
|
+
affectedUrls: [url]
|
|
17722
|
+
});
|
|
17723
|
+
}
|
|
17450
17724
|
} else if (!hasGtmNoscript) {
|
|
17451
17725
|
issues.push({
|
|
17452
17726
|
code: "GTM_NOSCRIPT_MISSING",
|
|
@@ -17586,7 +17860,6 @@ async function runFullAudit(options) {
|
|
|
17586
17860
|
const urlSafetyResult = analyzeUrlSafety(url);
|
|
17587
17861
|
const domSizeResult = analyzeDomSize(html, url);
|
|
17588
17862
|
const imageDimensionsResult = analyzeImageDimensions(html, url);
|
|
17589
|
-
const trackingResult = analyzeTrackingVerification(html, url);
|
|
17590
17863
|
const anchorResult = runExtendedChecks ? analyzeAnchorText(html, url) : { issues: [], data: {} };
|
|
17591
17864
|
const localSeoResult = runExtendedChecks ? analyzeLocalSEO(html, url) : { issues: [], data: {} };
|
|
17592
17865
|
const modernImageResult = runExtendedChecks ? analyzeModernImages(html, url) : { issues: [], data: {} };
|
|
@@ -17661,8 +17934,7 @@ async function runFullAudit(options) {
|
|
|
17661
17934
|
...colorContrastResult.issues,
|
|
17662
17935
|
...pageResourcesResult.issues,
|
|
17663
17936
|
...urlSafetyResult.issues,
|
|
17664
|
-
...paginationResult.issues
|
|
17665
|
-
...trackingResult.issues
|
|
17937
|
+
...paginationResult.issues
|
|
17666
17938
|
);
|
|
17667
17939
|
if (!structuredDataResult.data.hasSchema) {
|
|
17668
17940
|
const suggestions = suggestSchemaTypes(html, url);
|
|
@@ -17708,6 +17980,8 @@ async function runFullAudit(options) {
|
|
|
17708
17980
|
asyncChecks.push(safeAsyncIssues("Certificate", async () => (await checkCertificate(url)).issues));
|
|
17709
17981
|
asyncChecks.push(safeAsync("Redirects", () => analyzeRedirects(url)));
|
|
17710
17982
|
asyncChecks.push(safeAsync("Robots Validation", () => validateRobotsTxt(url)));
|
|
17983
|
+
asyncChecks.push(safeAsync("Tracking Verification", () => analyzeTrackingVerification(html, url)));
|
|
17984
|
+
asyncChecks.push(safeAsyncIssues("Crawler Access", async () => (await analyzeCrawlerAccess(url)).issues));
|
|
17711
17985
|
asyncChecks.push(safeAsync("Canonical Domain", () => analyzeCanonicalDomain(url)));
|
|
17712
17986
|
asyncChecks.push(safeAsyncIssues("Framework Detection", async () => {
|
|
17713
17987
|
const frameworkInfo = detectFramework({ html, headers });
|
|
@@ -33095,9 +33369,37 @@ function jsonLdConcerns(html) {
|
|
|
33095
33369
|
}
|
|
33096
33370
|
return bad;
|
|
33097
33371
|
}
|
|
33372
|
+
var PLACEHOLDERS = [
|
|
33373
|
+
{ pattern: /\+?1[-\s.]?\(?800\)?[-\s.]?555[-\s.]?\d{4}/i, what: "a reserved 555 phone number" },
|
|
33374
|
+
{ pattern: /\b555[-\s.]?01\d{2}\b/, what: "a reserved 555 phone number" },
|
|
33375
|
+
{ pattern: /\bAnytown\b/i, what: '"Anytown"' },
|
|
33376
|
+
{ pattern: /\b123 Main (Street|St)\b/i, what: '"123 Main Street"' },
|
|
33377
|
+
{ pattern: /your_[a-z_]+/i, what: 'an unfilled "your_..." placeholder' },
|
|
33378
|
+
{ pattern: /\b(yourdomain|your-domain|example)\.(com|org|net)\b/i, what: "a placeholder domain" },
|
|
33379
|
+
{ pattern: /\blorem ipsum\b/i, what: "lorem ipsum" },
|
|
33380
|
+
{ pattern: /\bXXX-XXX-XXXX\b/i, what: "an unfilled phone template" }
|
|
33381
|
+
];
|
|
33382
|
+
function isSharedShell(html) {
|
|
33383
|
+
return /<div[^>]+id=["'](root|app|__next|__nuxt)["']/i.test(html);
|
|
33384
|
+
}
|
|
33098
33385
|
function assessReplacement(path3, original, replacement) {
|
|
33099
33386
|
const concerns = [];
|
|
33100
33387
|
const add = (code, detail) => concerns.push({ code, detail });
|
|
33388
|
+
for (const { pattern, what } of PLACEHOLDERS) {
|
|
33389
|
+
const hit = replacement.match(pattern);
|
|
33390
|
+
if (hit && !(original && pattern.test(original))) {
|
|
33391
|
+
add("placeholder_content", `contains ${what} (${hit[0]})`);
|
|
33392
|
+
}
|
|
33393
|
+
}
|
|
33394
|
+
if (/\.html?$/i.test(path3) && isSharedShell(replacement)) {
|
|
33395
|
+
const addsCanonical = /<link[^>]+rel=["']canonical["']/i.test(replacement) && !(original && /<link[^>]+rel=["']canonical["']/i.test(original));
|
|
33396
|
+
if (addsCanonical) {
|
|
33397
|
+
add(
|
|
33398
|
+
"canonical_in_shared_shell",
|
|
33399
|
+
`${path3} mounts a single-page app, so a canonical here applies to every route`
|
|
33400
|
+
);
|
|
33401
|
+
}
|
|
33402
|
+
}
|
|
33101
33403
|
if (/\.json$/i.test(path3) || /\.webmanifest$/i.test(path3)) {
|
|
33102
33404
|
try {
|
|
33103
33405
|
JSON.parse(replacement);
|