@goflag/cli 0.2.10 → 0.2.11
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 +84 -42
- package/dist/{chunk-DKGBLJ4H.js → chunk-CWEJ6EXT.js} +298 -66
- package/dist/chunk-CWEJ6EXT.js.map +1 -0
- package/dist/cli.js +25 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/rules.json +47 -9
- package/dist/chunk-DKGBLJ4H.js.map +0 -1
|
@@ -2741,6 +2741,7 @@ function rowOf(site, url, pathname) {
|
|
|
2741
2741
|
}
|
|
2742
2742
|
function sitemapLocalesByRoute(site) {
|
|
2743
2743
|
const byRoute = /* @__PURE__ */ new Map();
|
|
2744
|
+
const axis = new Set(site.localeAxis.locales.map((l) => l.toLowerCase()));
|
|
2744
2745
|
for (const entry of site.discovery?.urls ?? []) {
|
|
2745
2746
|
let pathname;
|
|
2746
2747
|
try {
|
|
@@ -2750,6 +2751,7 @@ function sitemapLocalesByRoute(site) {
|
|
|
2750
2751
|
}
|
|
2751
2752
|
const { locale } = splitRoute(pathname);
|
|
2752
2753
|
if (locale === "x-default") continue;
|
|
2754
|
+
if (!axis.has(locale.toLowerCase())) continue;
|
|
2753
2755
|
const route = rowOf(site, entry.loc, pathname);
|
|
2754
2756
|
const set = byRoute.get(route) ?? /* @__PURE__ */ new Set();
|
|
2755
2757
|
set.add(locale.toLowerCase());
|
|
@@ -2764,6 +2766,8 @@ var hreflangMissing = {
|
|
|
2764
2766
|
id: "hreflang.missing",
|
|
2765
2767
|
severity: "error",
|
|
2766
2768
|
summary: "Pages on a multilingual site must advertise their locale alternates",
|
|
2769
|
+
rigor: "vendor-spec",
|
|
2770
|
+
sources: ["google-hreflang", "whatwg-html-link-types"],
|
|
2767
2771
|
appliesTo: isMultilingual,
|
|
2768
2772
|
check: ({ site, issue }) => {
|
|
2769
2773
|
const locales = site.localeAxis.locales.join(", ");
|
|
@@ -2797,62 +2801,57 @@ var hreflangMissing = {
|
|
|
2797
2801
|
);
|
|
2798
2802
|
}
|
|
2799
2803
|
};
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
let pathname;
|
|
2812
|
-
try {
|
|
2813
|
-
pathname = new URL(page.fetch.finalUrl).pathname;
|
|
2814
|
-
} catch {
|
|
2815
|
-
continue;
|
|
2816
|
-
}
|
|
2817
|
-
const route = rowOf(site, page.fetch.finalUrl, pathname);
|
|
2818
|
-
const inSitemap = bySitemap.get(route);
|
|
2819
|
-
if (!inSitemap || inSitemap.size === 0) continue;
|
|
2820
|
-
const onlyInSitemap = sorted(new Set([...inSitemap].filter((l) => !head.has(l))));
|
|
2821
|
-
const onlyInHead = sorted(new Set([...head].filter((l) => !inSitemap.has(l))));
|
|
2822
|
-
if (onlyInSitemap.length === 0 && onlyInHead.length === 0) continue;
|
|
2823
|
-
const parts = [];
|
|
2824
|
-
if (onlyInSitemap.length > 0) {
|
|
2825
|
-
parts.push(
|
|
2826
|
-
`the sitemap lists ${onlyInSitemap.join(", ")} but the \`<head>\` does not advertise ${onlyInSitemap.length > 1 ? "them" : "it"}`
|
|
2827
|
-
);
|
|
2828
|
-
}
|
|
2829
|
-
if (onlyInHead.length > 0) {
|
|
2830
|
-
parts.push(
|
|
2831
|
-
`the \`<head>\` advertises ${onlyInHead.join(", ")} but the sitemap has no entry for ${onlyInHead.length > 1 ? "them" : "it"}`
|
|
2832
|
-
);
|
|
2833
|
-
}
|
|
2834
|
-
findings.push(
|
|
2835
|
-
issue({
|
|
2836
|
-
pageUrl: page.fetch.finalUrl,
|
|
2837
|
-
message: `Route \`${route}\`: ${parts.join("; ")}. Both are derived from the same intent and must not disagree.`,
|
|
2838
|
-
origin: { kind: "link", rel: "alternate" },
|
|
2839
|
-
fix: {
|
|
2840
|
-
title: "Derive both from one locale-availability source",
|
|
2841
|
-
snippet: [
|
|
2842
|
-
"// Compute availability once, feed both the <head> and the sitemap.",
|
|
2843
|
-
"const localesFor = (slug: string) =>",
|
|
2844
|
-
" allDocs.filter((d) => d.slug === slug && !d.draft).map((d) => d.locale);",
|
|
2845
|
-
"",
|
|
2846
|
-
"// generateMetadata(): alternates.languages \u2190 localesFor(slug)",
|
|
2847
|
-
"// sitemap.ts: alternates.languages \u2190 localesFor(slug)"
|
|
2848
|
-
].join("\n"),
|
|
2849
|
-
language: "ts"
|
|
2850
|
-
}
|
|
2851
|
-
})
|
|
2852
|
-
);
|
|
2804
|
+
function coverageGaps(site) {
|
|
2805
|
+
const bySitemap = sitemapLocalesByRoute(site);
|
|
2806
|
+
const gaps = [];
|
|
2807
|
+
for (const page of site.pages) {
|
|
2808
|
+
const head = declaredLocales(page);
|
|
2809
|
+
if (head.size === 0) continue;
|
|
2810
|
+
let pathname;
|
|
2811
|
+
try {
|
|
2812
|
+
pathname = new URL(page.fetch.finalUrl).pathname;
|
|
2813
|
+
} catch {
|
|
2814
|
+
continue;
|
|
2853
2815
|
}
|
|
2854
|
-
|
|
2816
|
+
const route = rowOf(site, page.fetch.finalUrl, pathname);
|
|
2817
|
+
const inSitemap = bySitemap.get(route);
|
|
2818
|
+
if (!inSitemap || inSitemap.size === 0) continue;
|
|
2819
|
+
gaps.push({
|
|
2820
|
+
page,
|
|
2821
|
+
route,
|
|
2822
|
+
onlyInSitemap: sorted(new Set([...inSitemap].filter((l) => !head.has(l))))
|
|
2823
|
+
});
|
|
2855
2824
|
}
|
|
2825
|
+
return gaps;
|
|
2826
|
+
}
|
|
2827
|
+
var ONE_SOURCE_FIX = {
|
|
2828
|
+
title: "Derive both from one locale-availability source",
|
|
2829
|
+
snippet: [
|
|
2830
|
+
"// Compute availability once, feed both the <head> and the sitemap.",
|
|
2831
|
+
"const localesFor = (slug: string) =>",
|
|
2832
|
+
" allDocs.filter((d) => d.slug === slug && !d.draft).map((d) => d.locale);",
|
|
2833
|
+
"",
|
|
2834
|
+
"// generateMetadata(): alternates.languages \u2190 localesFor(slug)",
|
|
2835
|
+
"// sitemap.ts: alternates.languages \u2190 localesFor(slug)"
|
|
2836
|
+
].join("\n"),
|
|
2837
|
+
language: "ts"
|
|
2838
|
+
};
|
|
2839
|
+
var bothApply = (site) => isMultilingual(site) && (site.discovery?.urls.length ?? 0) > 0;
|
|
2840
|
+
var hreflangClusterIncomplete = {
|
|
2841
|
+
id: "hreflang.cluster-incomplete",
|
|
2842
|
+
severity: "warning",
|
|
2843
|
+
summary: "Every locale the sitemap publishes must appear in the route's `hreflang` cluster",
|
|
2844
|
+
rigor: "vendor-spec",
|
|
2845
|
+
sources: ["google-hreflang"],
|
|
2846
|
+
appliesTo: bothApply,
|
|
2847
|
+
check: ({ site, issue }) => coverageGaps(site).filter((gap) => gap.onlyInSitemap.length > 0).map(
|
|
2848
|
+
({ page, route, onlyInSitemap }) => issue({
|
|
2849
|
+
pageUrl: page.fetch.finalUrl,
|
|
2850
|
+
message: `Route \`${route}\`: the sitemap lists ${onlyInSitemap.join(", ")} but the \`<head>\` does not advertise ${onlyInSitemap.length > 1 ? "them" : "it"}. The site publishes ${onlyInSitemap.length > 1 ? "those versions" : "that version"} and leaves ${onlyInSitemap.length > 1 ? "them" : "it"} outside the cluster, so ${onlyInSitemap.length > 1 ? "they compete" : "it competes"} with this page instead of consolidating with it.`,
|
|
2851
|
+
origin: { kind: "link", rel: "alternate" },
|
|
2852
|
+
fix: ONE_SOURCE_FIX
|
|
2853
|
+
})
|
|
2854
|
+
)
|
|
2856
2855
|
};
|
|
2857
2856
|
function metaRobotsTokens(page) {
|
|
2858
2857
|
const raw = page.meta.robots?.value ?? "";
|
|
@@ -3156,6 +3155,84 @@ var sitemapEntryInvalidUrl = {
|
|
|
3156
3155
|
});
|
|
3157
3156
|
}
|
|
3158
3157
|
};
|
|
3158
|
+
var DOCUMENT_URL_LIMIT = 5e4;
|
|
3159
|
+
var DOCUMENT_BYTE_LIMIT = 52428800;
|
|
3160
|
+
function declaredCount(doc) {
|
|
3161
|
+
return doc.kind === "index" ? doc.childLocs.length : doc.urlCount;
|
|
3162
|
+
}
|
|
3163
|
+
function oversizedDocuments(site) {
|
|
3164
|
+
return (site.discovery?.documents ?? []).filter(
|
|
3165
|
+
(doc) => doc.kind !== "unparsable" && (declaredCount(doc) > DOCUMENT_URL_LIMIT || doc.byteLength > DOCUMENT_BYTE_LIMIT)
|
|
3166
|
+
);
|
|
3167
|
+
}
|
|
3168
|
+
var sitemapLimitsExceeded = {
|
|
3169
|
+
id: "sitemap.limits.exceeded",
|
|
3170
|
+
severity: "error",
|
|
3171
|
+
summary: "No single sitemap document may exceed 50,000 entries or 50 MB",
|
|
3172
|
+
rigor: "spec-required",
|
|
3173
|
+
sources: ["sitemaps-protocol"],
|
|
3174
|
+
appliesTo: (site) => oversizedDocuments(site).length > 0,
|
|
3175
|
+
check: ({ site, issue }) => oversizedDocuments(site).map((doc) => {
|
|
3176
|
+
const count2 = declaredCount(doc);
|
|
3177
|
+
const noun = doc.kind === "index" ? "child sitemaps" : "URLs";
|
|
3178
|
+
const over = [];
|
|
3179
|
+
if (count2 > DOCUMENT_URL_LIMIT) {
|
|
3180
|
+
over.push(`${count2.toLocaleString("en-US")} ${noun} against a ceiling of 50,000`);
|
|
3181
|
+
}
|
|
3182
|
+
if (doc.byteLength > DOCUMENT_BYTE_LIMIT) {
|
|
3183
|
+
over.push(
|
|
3184
|
+
`${(doc.byteLength / 1048576).toFixed(1)} MB uncompressed against a ceiling of 50 MB`
|
|
3185
|
+
);
|
|
3186
|
+
}
|
|
3187
|
+
return issue({
|
|
3188
|
+
pageUrl: doc.url,
|
|
3189
|
+
message: `This sitemap document declares ${over.join(" and ")}. A consumer may stop reading at the limit, so everything past it is published in name only \u2014 split the document and reference the parts from an index.`,
|
|
3190
|
+
origin: { kind: "computed" }
|
|
3191
|
+
});
|
|
3192
|
+
})
|
|
3193
|
+
};
|
|
3194
|
+
function outOfScopeEntries(site) {
|
|
3195
|
+
const out = [];
|
|
3196
|
+
for (const entry of site.discovery?.urls ?? []) {
|
|
3197
|
+
if (!entry.documentUrl) continue;
|
|
3198
|
+
let scope;
|
|
3199
|
+
let loc;
|
|
3200
|
+
try {
|
|
3201
|
+
scope = new URL(entry.documentUrl);
|
|
3202
|
+
loc = new URL(entry.loc);
|
|
3203
|
+
} catch {
|
|
3204
|
+
continue;
|
|
3205
|
+
}
|
|
3206
|
+
const prefix = scope.pathname.replace(/[^/]*$/, "");
|
|
3207
|
+
if (prefix === "/") continue;
|
|
3208
|
+
if (loc.origin !== scope.origin) continue;
|
|
3209
|
+
if (loc.pathname.startsWith(prefix)) continue;
|
|
3210
|
+
out.push({ loc: entry.loc, from: entry.documentUrl });
|
|
3211
|
+
}
|
|
3212
|
+
return out;
|
|
3213
|
+
}
|
|
3214
|
+
var sitemapEntryOutOfScope = {
|
|
3215
|
+
id: "sitemap.entry.out-of-scope",
|
|
3216
|
+
severity: "error",
|
|
3217
|
+
summary: "A sitemap may only list URLs under its own directory",
|
|
3218
|
+
rigor: "spec-required",
|
|
3219
|
+
sources: ["sitemaps-protocol"],
|
|
3220
|
+
appliesTo: (site) => outOfScopeEntries(site).length > 0,
|
|
3221
|
+
check: ({ site, issue }) => {
|
|
3222
|
+
const bad = outOfScopeEntries(site);
|
|
3223
|
+
const byDocument = /* @__PURE__ */ new Map();
|
|
3224
|
+
for (const { loc, from } of bad) {
|
|
3225
|
+
byDocument.set(from, [...byDocument.get(from) ?? [], loc]);
|
|
3226
|
+
}
|
|
3227
|
+
return [...byDocument].map(
|
|
3228
|
+
([from, locs]) => issue({
|
|
3229
|
+
pageUrl: from,
|
|
3230
|
+
message: `${locs.length} entr${locs.length === 1 ? "y is" : "ies are"} outside this sitemap's directory \`${new URL(from).pathname.replace(/[^/]*$/, "")}\`: ${sample(locs)}. A sitemap only speaks for the path it is served from, so a consumer may drop ${locs.length === 1 ? "it" : "them"} \u2014 serve the document from the root, or move the entries into a sitemap that covers them.`,
|
|
3231
|
+
origin: { kind: "computed" }
|
|
3232
|
+
})
|
|
3233
|
+
);
|
|
3234
|
+
}
|
|
3235
|
+
};
|
|
3159
3236
|
var sitemapEntryCrossHost = {
|
|
3160
3237
|
id: "sitemap.entry.cross-host",
|
|
3161
3238
|
severity: "error",
|
|
@@ -3485,7 +3562,7 @@ function isAbsolute(value) {
|
|
|
3485
3562
|
}
|
|
3486
3563
|
var SITE_RULES = [
|
|
3487
3564
|
hreflangMissing,
|
|
3488
|
-
|
|
3565
|
+
hreflangClusterIncomplete,
|
|
3489
3566
|
iconsIcoMissing,
|
|
3490
3567
|
robotsBlocksPage,
|
|
3491
3568
|
robotsBlocksSite,
|
|
@@ -3501,12 +3578,14 @@ var SITE_RULES = [
|
|
|
3501
3578
|
sitemapEntryInvalidUrl,
|
|
3502
3579
|
sitemapEntryNoindex,
|
|
3503
3580
|
sitemapEntryNonCanonical,
|
|
3581
|
+
sitemapEntryOutOfScope,
|
|
3504
3582
|
sitemapEntryProtocolMismatch,
|
|
3505
3583
|
sitemapEntryRedirects,
|
|
3506
3584
|
sitemapEntryUnreachable,
|
|
3507
3585
|
sitemapFieldInvalid,
|
|
3508
3586
|
sitemapIndexChildError,
|
|
3509
3587
|
sitemapLastmodInvalid,
|
|
3588
|
+
sitemapLimitsExceeded,
|
|
3510
3589
|
sitemapMissing,
|
|
3511
3590
|
sitemapOrphans,
|
|
3512
3591
|
sitemapUnparsable
|
|
@@ -3778,9 +3857,23 @@ async function discoverSitemap(baseUrl, options = {}) {
|
|
|
3778
3857
|
diagnostics.atWellKnownPath = isWellKnownPath(candidate.url);
|
|
3779
3858
|
const collected = [];
|
|
3780
3859
|
const seen = /* @__PURE__ */ new Set();
|
|
3860
|
+
const documents = [];
|
|
3781
3861
|
let truncated = false;
|
|
3862
|
+
documents.push({
|
|
3863
|
+
url: candidate.url,
|
|
3864
|
+
status: doc.status,
|
|
3865
|
+
byteLength: doc.byteLength,
|
|
3866
|
+
gzipped: doc.gzipped,
|
|
3867
|
+
kind: parsed.kind === "index" ? "index" : "urlset",
|
|
3868
|
+
childLocs: parsed.kind === "index" ? [...parsed.sitemaps] : [],
|
|
3869
|
+
// What the document declares, not what survived dedupe and the cap: the
|
|
3870
|
+
// ceiling is on the file's contents, and a post-dedupe count would answer
|
|
3871
|
+
// a question the protocol never asked.
|
|
3872
|
+
urlCount: parsed.kind === "urlset" ? parsed.urls.length : 0,
|
|
3873
|
+
declaredInRobots: candidate.declaredInRobots
|
|
3874
|
+
});
|
|
3782
3875
|
if (parsed.kind === "urlset") {
|
|
3783
|
-
truncated = pushEntries(collected, seen, parsed.urls, maxUrls);
|
|
3876
|
+
truncated = pushEntries(collected, seen, parsed.urls, maxUrls, candidate.url);
|
|
3784
3877
|
} else {
|
|
3785
3878
|
diagnostics.isIndex = true;
|
|
3786
3879
|
const children = parsed.sitemaps.slice(0, maxSitemaps);
|
|
@@ -3798,17 +3891,44 @@ async function discoverSitemap(baseUrl, options = {}) {
|
|
|
3798
3891
|
}
|
|
3799
3892
|
const childDoc = await fetchDoc(childUrl, options);
|
|
3800
3893
|
if (!childDoc.body) {
|
|
3894
|
+
documents.push({
|
|
3895
|
+
url: childUrl,
|
|
3896
|
+
status: childDoc.status,
|
|
3897
|
+
byteLength: childDoc.byteLength,
|
|
3898
|
+
gzipped: childDoc.gzipped,
|
|
3899
|
+
kind: "unparsable",
|
|
3900
|
+
childLocs: [],
|
|
3901
|
+
urlCount: 0,
|
|
3902
|
+
declaredInRobots: declaredUrls.includes(childUrl),
|
|
3903
|
+
parentUrl: candidate.url
|
|
3904
|
+
});
|
|
3801
3905
|
diagnostics.childSitemapErrors += 1;
|
|
3802
3906
|
diagnostics.warnings.push(`Child sitemap unreachable: ${childUrl}`);
|
|
3803
3907
|
continue;
|
|
3804
3908
|
}
|
|
3805
3909
|
const childParsed = parseSitemap(childDoc.body);
|
|
3910
|
+
documents.push({
|
|
3911
|
+
url: childUrl,
|
|
3912
|
+
status: childDoc.status,
|
|
3913
|
+
byteLength: childDoc.byteLength,
|
|
3914
|
+
gzipped: childDoc.gzipped,
|
|
3915
|
+
// A child that is itself an index is recorded as one. This run still
|
|
3916
|
+
// treats it as an error below, because that is what it has always
|
|
3917
|
+
// done and replacing that verdict needs a source no specification
|
|
3918
|
+
// provides — see `docs/sitemap-robots-plan.md` §4.3. The tree now
|
|
3919
|
+
// says which of the two it was, which is the part that was lost.
|
|
3920
|
+
kind: childParsed.kind === "unknown" ? "unparsable" : childParsed.kind,
|
|
3921
|
+
childLocs: childParsed.kind === "index" ? [...childParsed.sitemaps] : [],
|
|
3922
|
+
urlCount: childParsed.kind === "urlset" ? childParsed.urls.length : 0,
|
|
3923
|
+
declaredInRobots: declaredUrls.includes(childUrl),
|
|
3924
|
+
parentUrl: candidate.url
|
|
3925
|
+
});
|
|
3806
3926
|
if (childParsed.kind !== "urlset") {
|
|
3807
3927
|
diagnostics.childSitemapErrors += 1;
|
|
3808
3928
|
diagnostics.warnings.push(`Child sitemap not a urlset: ${childUrl}`);
|
|
3809
3929
|
continue;
|
|
3810
3930
|
}
|
|
3811
|
-
if (pushEntries(collected, seen, childParsed.urls, maxUrls)) truncated = true;
|
|
3931
|
+
if (pushEntries(collected, seen, childParsed.urls, maxUrls, childUrl)) truncated = true;
|
|
3812
3932
|
}
|
|
3813
3933
|
}
|
|
3814
3934
|
diagnostics.urlCount = collected.length;
|
|
@@ -3820,6 +3940,7 @@ async function discoverSitemap(baseUrl, options = {}) {
|
|
|
3820
3940
|
baseUrl,
|
|
3821
3941
|
source: candidate.source,
|
|
3822
3942
|
urls: collected,
|
|
3943
|
+
documents,
|
|
3823
3944
|
diagnostics,
|
|
3824
3945
|
truncated
|
|
3825
3946
|
};
|
|
@@ -3828,7 +3949,15 @@ async function discoverSitemap(baseUrl, options = {}) {
|
|
|
3828
3949
|
return crawlSite(baseUrl, origin, options, diagnostics);
|
|
3829
3950
|
}
|
|
3830
3951
|
diagnostics.warnings.push("No sitemap found.");
|
|
3831
|
-
return {
|
|
3952
|
+
return {
|
|
3953
|
+
origin,
|
|
3954
|
+
baseUrl,
|
|
3955
|
+
source: "well-known",
|
|
3956
|
+
urls: [],
|
|
3957
|
+
documents: [],
|
|
3958
|
+
diagnostics,
|
|
3959
|
+
truncated: false
|
|
3960
|
+
};
|
|
3832
3961
|
}
|
|
3833
3962
|
async function crawlSite(baseUrl, origin, options, diagnostics) {
|
|
3834
3963
|
const result = await crawl({
|
|
@@ -3852,16 +3981,20 @@ async function crawlSite(baseUrl, origin, options, diagnostics) {
|
|
|
3852
3981
|
baseUrl,
|
|
3853
3982
|
source: "crawl",
|
|
3854
3983
|
urls,
|
|
3984
|
+
// No document declared these: the crawl found them by following links. An
|
|
3985
|
+
// empty tree is the honest answer, and the rules that read it are the ones
|
|
3986
|
+
// that judge documents — which this run has none of.
|
|
3987
|
+
documents: [],
|
|
3855
3988
|
diagnostics,
|
|
3856
3989
|
truncated: result.truncated
|
|
3857
3990
|
};
|
|
3858
3991
|
}
|
|
3859
|
-
function pushEntries(out, seen, entries, cap) {
|
|
3992
|
+
function pushEntries(out, seen, entries, cap, documentUrl) {
|
|
3860
3993
|
for (const entry of entries) {
|
|
3861
3994
|
if (out.length >= cap) return true;
|
|
3862
3995
|
if (seen.has(entry.loc)) continue;
|
|
3863
3996
|
seen.add(entry.loc);
|
|
3864
|
-
out.push(entry);
|
|
3997
|
+
out.push({ ...entry, documentUrl });
|
|
3865
3998
|
}
|
|
3866
3999
|
return out.length >= cap;
|
|
3867
4000
|
}
|
|
@@ -3890,26 +4023,29 @@ async function fetchDoc(url, options) {
|
|
|
3890
4023
|
accept: "application/xml,text/xml,application/gzip;q=0.9,*/*;q=0.5"
|
|
3891
4024
|
}
|
|
3892
4025
|
});
|
|
3893
|
-
if (!res.ok) return { status: res.status };
|
|
4026
|
+
if (!res.ok) return { status: res.status, byteLength: 0, gzipped: false };
|
|
3894
4027
|
const encoding = res.headers.get("content-encoding")?.toLowerCase() ?? "";
|
|
3895
4028
|
const looksGzip = url.toLowerCase().endsWith(".gz") || encoding.includes("gzip");
|
|
3896
4029
|
if (looksGzip) {
|
|
3897
4030
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
3898
4031
|
try {
|
|
3899
|
-
return
|
|
4032
|
+
return withLength(res.status, gunzipSync(buf).toString("utf8"), true);
|
|
3900
4033
|
} catch {
|
|
3901
|
-
return
|
|
4034
|
+
return withLength(res.status, buf.toString("utf8"), true);
|
|
3902
4035
|
}
|
|
3903
4036
|
}
|
|
3904
|
-
return
|
|
4037
|
+
return withLength(res.status, await res.text(), false);
|
|
3905
4038
|
} catch (err) {
|
|
3906
4039
|
const reason = err instanceof Error ? err.message : String(err);
|
|
3907
|
-
return { status: 0, error: reason || "network error" };
|
|
4040
|
+
return { status: 0, error: reason || "network error", byteLength: 0, gzipped: false };
|
|
3908
4041
|
} finally {
|
|
3909
4042
|
cleanup();
|
|
3910
4043
|
restoreTls();
|
|
3911
4044
|
}
|
|
3912
4045
|
}
|
|
4046
|
+
function withLength(status, body, gzipped) {
|
|
4047
|
+
return { status, body, byteLength: Buffer.byteLength(body, "utf8"), gzipped };
|
|
4048
|
+
}
|
|
3913
4049
|
function relaxTlsIfRequested2(enabled) {
|
|
3914
4050
|
if (!enabled) return () => void 0;
|
|
3915
4051
|
const previous = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
|
|
@@ -4794,6 +4930,89 @@ var PROSE_RULES = [
|
|
|
4794
4930
|
];
|
|
4795
4931
|
var PROSE_RULE_BY_ID = new Map(PROSE_RULES.map((r) => [r.id, r]));
|
|
4796
4932
|
|
|
4933
|
+
// src/lib/rules/site-prose.ts
|
|
4934
|
+
var hreflangSitemapMismatch = {
|
|
4935
|
+
id: "hreflang.sitemap-mismatch",
|
|
4936
|
+
summary: "`<head>` alternates advertise a locale the sitemap does not list",
|
|
4937
|
+
prose: "Your `<head>` advertises a translation your sitemap does not list. Is the sitemap missing an entry, or is the alternate pointing at a page you did not mean to publish?",
|
|
4938
|
+
why: "The two are derived from one intent by different code paths, so a disagreement means one of them is wrong. No specification says which: the declaration methods are equivalent, and a page can legitimately be cross-linked and kept out of the sitemap.",
|
|
4939
|
+
appliesTo: (site) => site.localeAxis.multilingual && (site.discovery?.urls.length ?? 0) > 0,
|
|
4940
|
+
ask: (site) => {
|
|
4941
|
+
const axis = new Set(site.localeAxis.locales.map((l) => l.toLowerCase()));
|
|
4942
|
+
const bySitemap = /* @__PURE__ */ new Map();
|
|
4943
|
+
for (const entry of site.discovery?.urls ?? []) {
|
|
4944
|
+
let pathname;
|
|
4945
|
+
try {
|
|
4946
|
+
pathname = new URL(entry.loc).pathname;
|
|
4947
|
+
} catch {
|
|
4948
|
+
continue;
|
|
4949
|
+
}
|
|
4950
|
+
const { locale } = splitRoute(pathname);
|
|
4951
|
+
if (locale === "x-default") continue;
|
|
4952
|
+
if (!axis.has(locale.toLowerCase())) continue;
|
|
4953
|
+
const route = site.clusterRouteOf?.(entry.loc) ?? splitRoute(pathname).route;
|
|
4954
|
+
const set = bySitemap.get(route) ?? /* @__PURE__ */ new Set();
|
|
4955
|
+
set.add(locale.toLowerCase());
|
|
4956
|
+
bySitemap.set(route, set);
|
|
4957
|
+
}
|
|
4958
|
+
const drafts = [];
|
|
4959
|
+
for (const page of site.pages) {
|
|
4960
|
+
const head = /* @__PURE__ */ new Set();
|
|
4961
|
+
for (const alt of page.links.alternates) {
|
|
4962
|
+
if (alt.isXDefault) continue;
|
|
4963
|
+
const tag = alt.hreflang?.trim().toLowerCase();
|
|
4964
|
+
if (tag) head.add(tag);
|
|
4965
|
+
}
|
|
4966
|
+
if (head.size === 0) continue;
|
|
4967
|
+
let pathname;
|
|
4968
|
+
try {
|
|
4969
|
+
pathname = new URL(page.fetch.finalUrl).pathname;
|
|
4970
|
+
} catch {
|
|
4971
|
+
continue;
|
|
4972
|
+
}
|
|
4973
|
+
const route = site.clusterRouteOf?.(page.fetch.finalUrl) ?? splitRoute(pathname).route;
|
|
4974
|
+
const inSitemap = bySitemap.get(route);
|
|
4975
|
+
if (!inSitemap || inSitemap.size === 0) continue;
|
|
4976
|
+
const unlisted = [...head].filter((l) => !inSitemap.has(l)).sort();
|
|
4977
|
+
if (unlisted.length === 0) continue;
|
|
4978
|
+
drafts.push({
|
|
4979
|
+
pageUrl: page.fetch.finalUrl,
|
|
4980
|
+
evidence: {
|
|
4981
|
+
route,
|
|
4982
|
+
// Both sides, verbatim and sorted, so an agent can see the shape of
|
|
4983
|
+
// the disagreement rather than take our word for its size.
|
|
4984
|
+
headAdvertises: [...head].sort(),
|
|
4985
|
+
sitemapLists: [...inSitemap].sort(),
|
|
4986
|
+
advertisedButUnlisted: unlisted
|
|
4987
|
+
}
|
|
4988
|
+
});
|
|
4989
|
+
}
|
|
4990
|
+
return drafts;
|
|
4991
|
+
}
|
|
4992
|
+
};
|
|
4993
|
+
var SITE_PROSE_RULES = [hreflangSitemapMismatch];
|
|
4994
|
+
function collectSiteAdvisories(site, rules = SITE_PROSE_RULES) {
|
|
4995
|
+
const out = [];
|
|
4996
|
+
for (const rule of rules) {
|
|
4997
|
+
if (rule.appliesTo && !rule.appliesTo(site)) continue;
|
|
4998
|
+
for (const draft of rule.ask(site)) {
|
|
4999
|
+
out.push({
|
|
5000
|
+
ruleId: rule.id,
|
|
5001
|
+
kind: "prose",
|
|
5002
|
+
prose: rule.prose,
|
|
5003
|
+
// `null` where nothing supports the question, which is exactly the
|
|
5004
|
+
// state this rule is in and the reason it stopped being a verdict.
|
|
5005
|
+
rigor: rule.rigor ?? null,
|
|
5006
|
+
sources: rule.sources ?? [],
|
|
5007
|
+
evidence: draft.evidence,
|
|
5008
|
+
verdict: "needs-judgment",
|
|
5009
|
+
pageUrl: draft.pageUrl
|
|
5010
|
+
});
|
|
5011
|
+
}
|
|
5012
|
+
}
|
|
5013
|
+
return out;
|
|
5014
|
+
}
|
|
5015
|
+
|
|
4797
5016
|
// src/lib/core/links/extract.ts
|
|
4798
5017
|
import * as cheerio3 from "cheerio";
|
|
4799
5018
|
var ANCHOR_SPEC = { selector: "a[href]", attr: "href", source: "a" };
|
|
@@ -5501,6 +5720,14 @@ async function runAudit(inputUrl, options = {}) {
|
|
|
5501
5720
|
sources: draft.sources
|
|
5502
5721
|
};
|
|
5503
5722
|
});
|
|
5723
|
+
if (options.advisories) {
|
|
5724
|
+
for (const advisory of collectSiteAdvisories(siteContext)) {
|
|
5725
|
+
advisories.push({
|
|
5726
|
+
...advisory,
|
|
5727
|
+
id: fingerprint("advisory", advisory.ruleId, routeKey(advisory.pageUrl))
|
|
5728
|
+
});
|
|
5729
|
+
}
|
|
5730
|
+
}
|
|
5504
5731
|
const brokenLinks = [];
|
|
5505
5732
|
for (const { pageUrl, broken } of linkReport.brokenByPage) {
|
|
5506
5733
|
for (const check of broken) {
|
|
@@ -5637,6 +5864,10 @@ function syntheticDiscovery(origin, baseUrl, pages, truncated) {
|
|
|
5637
5864
|
baseUrl,
|
|
5638
5865
|
source: "crawl",
|
|
5639
5866
|
urls: pages.map((p) => ({ loc: p.fetch.finalUrl })),
|
|
5867
|
+
// Crawl-derived: no sitemap document was read, so the tree is empty rather
|
|
5868
|
+
// than absent. The document rules ask "what did each document declare?" and
|
|
5869
|
+
// must see that nothing declared anything.
|
|
5870
|
+
documents: [],
|
|
5640
5871
|
diagnostics: {
|
|
5641
5872
|
found: false,
|
|
5642
5873
|
status: 0,
|
|
@@ -6277,6 +6508,7 @@ export {
|
|
|
6277
6508
|
PROFILES,
|
|
6278
6509
|
PROFILE_NAMES,
|
|
6279
6510
|
PROSE_RULES,
|
|
6511
|
+
SITE_PROSE_RULES,
|
|
6280
6512
|
routeKey,
|
|
6281
6513
|
targetKey,
|
|
6282
6514
|
fingerprint,
|
|
@@ -6289,4 +6521,4 @@ export {
|
|
|
6289
6521
|
summarize,
|
|
6290
6522
|
Logger
|
|
6291
6523
|
};
|
|
6292
|
-
//# sourceMappingURL=chunk-
|
|
6524
|
+
//# sourceMappingURL=chunk-CWEJ6EXT.js.map
|