diff --git a/package-lock.json b/package-lock.json index da51c998..230c0800 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8967,7 +8967,7 @@ }, "packages/browser": { "name": "@harperfast/prerender-browser", - "version": "1.16.0", + "version": "1.17.0", "license": "Apache-2.0", "dependencies": { "mqtt": "^5.10.4", diff --git a/packages/browser/package.json b/packages/browser/package.json index 04025af5..e078f4c3 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -1,6 +1,6 @@ { "name": "@harperfast/prerender-browser", - "version": "1.16.0", + "version": "1.17.0", "type": "module", "description": "Headless-browser render library for Harper Prerender: claims render jobs from the @harperfast/prerender queue, renders pages in headless Chrome (Puppeteer), and posts the HTML back. Embedded by a render service and configured entirely via startWorker() options.", "keywords": [ diff --git a/packages/browser/src/RenderJob.ts b/packages/browser/src/RenderJob.ts index c1aa93fd..6e9af546 100644 --- a/packages/browser/src/RenderJob.ts +++ b/packages/browser/src/RenderJob.ts @@ -113,7 +113,9 @@ export default class RenderJob { /** * Why this render produced no cacheable content — one slug across every no-content class, * so the plugin logs/tracks a single field: 'noindex' (robots meta/header), - * 'canonical-mismatch' (page canonicalizes elsewhere), 'http-error' (non-200 document), + * 'canonical-mismatch' (page canonicalizes to a different URL), 'canonical-variant' + * (canonicalizes to the same URL RE-SPELLED — one document, two cache keys), 'http-error' + * (non-200 document), * 'redirect-loop', 'redirect' (bailed at navigation), or 'error' (renderer threw — details * ride in the posted `error`). Unset when content was produced; sendResult derives the * redirect/error fallbacks so callers only set the values they alone can know. diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index 3d5447be..ed2131b5 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -198,6 +198,31 @@ export type WaitForRule = { pathPattern?: string; }; +/** + * How to read a page's `` — see `canonicalVerdict` in util/url.ts. + * + * A canonical that names a DIFFERENT document always makes the page non-indexable; that much is + * invariable. The open question is the re-spelling: a canonical that names this very document + * under a different cache key ('variant'), which happens when a site writes a space as `+` where + * its sitemap writes `%20`, or vice versa. Whether those two spellings are one resource is a fact + * about the SITE's query parsing, not about the URLs — a form-decoding origin cannot tell them + * apart, an RFC-3986 one can — so it is config, not a hardcoded assumption. + * + * `strict: false` (default) reproduces the historical reading exactly: a re-spelling counts as + * self-canonical and gets its own target. `strict: true` calls it a duplicate key and reports + * `canonical-variant`, so the plugin retires it instead of rendering the same bytes twice forever. + * + * Turn it on only for a site whose origin form-decodes its query. One request settles that for a + * given parameter, for every URL: ask for a value containing a literal `+` (`?f=A%2BB`) and then + * the same value with a raw `+` (`?f=A+B`). If the origin resolves the second as a SPACE — its + * canonical comes back `A%20B`, or it simply serves what `A B` names — it form-decodes, and the + * two spellings can never name different resources. + */ +export type CanonicalConfig = { + /** Treat a re-spelled self-canonical as a duplicate cache key (non-indexable). Default false. */ + strict: boolean; +}; + export type PrerenderConfig = { /** Device profiles keyed by the job's `deviceType`; unknown types fall back to `defaultDevice`. */ devices: Record; @@ -206,6 +231,7 @@ export type PrerenderConfig = { navigation: NavigationConfig; scroll: ScrollConfig; postProcess: PostProcessConfig; + canonical: CanonicalConfig; /** * Optional declarative "wait for content" rules applied after scroll/settle and before the * snapshot (see {@link WaitForRule}). Absent by default → a complete no-op, so existing @@ -255,6 +281,7 @@ export const defaultConfig = (): PrerenderConfig => ({ stripBlockedResources: false, resolveLazyImages: false, }, + canonical: { strict: false }, injectWebComponentsPolyfill: true, extraHeaders: {}, }); diff --git a/packages/browser/src/renderOnce.ts b/packages/browser/src/renderOnce.ts index 1e056dbb..c188f9d0 100644 --- a/packages/browser/src/renderOnce.ts +++ b/packages/browser/src/renderOnce.ts @@ -95,7 +95,7 @@ export interface RenderResult { outcome: RenderOutcome; statusCode: number | undefined; isIndexable: boolean | undefined; - /** Why no cacheable content: 'noindex' | 'canonical-mismatch' | 'http-error' | 'redirect-loop' | 'redirect' | 'error'. */ + /** Why no cacheable content: 'noindex' | 'canonical-mismatch' | 'canonical-variant' | 'http-error' | 'redirect-loop' | 'redirect' | 'error'. */ reason: string | undefined; /** Normalized redirect target (job.redirectedTo), if the final URL canonically differs. */ redirectedTo: string | undefined; diff --git a/packages/browser/src/renderer.ts b/packages/browser/src/renderer.ts index 5b9b43ed..4e3fbd31 100644 --- a/packages/browser/src/renderer.ts +++ b/packages/browser/src/renderer.ts @@ -3,7 +3,7 @@ import type { RenderTimings } from './RenderJob.js'; import { settings } from './settings.js'; import { CACHE_REPLAY_HEADER, getResourceCache } from './ResourceCache.js'; import type { PostProcessConfig } from './config.js'; -import { canonicalizeUrl, canonicalAllowsIndex } from './util/url.js'; +import { canonicalizeUrl, canonicalVerdict } from './util/url.js'; import { markRenderPhase } from './util/renderPhase.js'; const noop = () => {}; @@ -450,8 +450,19 @@ const renderer: Renderer = async (page, job) => { if (statusCode === 200) { const { canonicalHref, noindex } = await page.evaluate(extractIndexSignals); - job.isIndexable = !noindex && canonicalAllowsIndex(canonicalHref, rawPageUrl); - if (!job.isIndexable) job.reason = noindex ? 'noindex' : 'canonical-mismatch'; + // A canonical naming a DIFFERENT document always disowns the page — invariable, every + // site. A canonical naming this very document RE-SPELLED as another cache key + // ('variant') is only a duplicate if the site's origin cannot tell the two spellings + // apart, which is a property of its query parser, not of the URLs — so that half is + // config, defaulting to the historical lenient reading. See config.canonical.strict. + // Either way the reason slug is distinct, so duplicate spellings stay legible next to + // genuine mismatches. + const verdict = canonicalVerdict(canonicalHref, rawPageUrl); + const disowned = verdict === 'elsewhere' || (verdict === 'variant' && config.canonical.strict); + job.isIndexable = !noindex && !disowned; + if (!job.isIndexable) { + job.reason = noindex ? 'noindex' : verdict === 'variant' ? 'canonical-variant' : 'canonical-mismatch'; + } if (job.isIndexable || job.isFromSitemap) { const ppStart = Date.now(); diff --git a/packages/browser/src/util/url.ts b/packages/browser/src/util/url.ts index 5f5dd28d..0c6eb4ce 100644 --- a/packages/browser/src/util/url.ts +++ b/packages/browser/src/util/url.ts @@ -79,11 +79,16 @@ export const normalizeUrlForCompare = (url: string | URL): string => { }; /** - * Normalize a URL for canonical self-reference comparison. Like {@link normalizeUrlForCompare} - * but also drops the hash and a trailing slash — neither changes which document a canonical - * names. Sorting re-serializes the query with uniform (form) encoding, so a reserved char - * that is percent-encoded in the request (`%3A`) and literal in the canonical (`:`) compare - * equal; `decodeURI` alone can't do that, as it leaves reserved-char escapes intact. + * Normalize a URL down to the DOCUMENT it names, ignoring how that name is spelled. Like + * {@link normalizeUrlForCompare} but also drops the hash and a trailing slash — neither changes + * which document a canonical names. Sorting re-serializes the query with uniform (form) + * encoding, so a reserved char that is percent-encoded in the request (`%3A`) and literal in + * the canonical (`:`) compare equal; `decodeURI` alone can't do that, as it leaves + * reserved-char escapes intact. + * + * That form-encoding round-trip also collapses `%20` and `+` — which makes this the wrong + * question for indexability (see {@link canonicalVerdict}) and exactly the right one for + * telling a re-spelling of one document apart from a pointer at a different document. */ export const normalizeCanonicalUrl = (url: string | URL): string => { const parsed = new URL(url); @@ -97,18 +102,54 @@ export const normalizeCanonicalUrl = (url: string | URL): string => { return safeDecodeURI(parsed.href); }; +/** What a page's `` says about the URL that was rendered. */ +export type CanonicalVerdict = 'self' | 'variant' | 'elsewhere'; + /** - * Whether a page's canonical link permits indexing: true when there is no canonical, or the - * canonical resolves to the same URL as the page. A relative canonical is resolved against - * the current URL. Comparison is on the normalized form, so encoding, param order, hash, and - * trailing-slash differences are not treated as a mismatch. A malformed canonical fails open - * (does not drop indexability over a broken tag). + * Read a page's canonical link in the only unit the plugin can act on: the CACHE KEY. + * + * 'self' — no canonical, or it canonicalizes to this very key. Indexable. + * 'variant' — it names the same document RE-SPELLED as a different key: `%20` where the + * canonical writes `+`, or any other difference that survives the cache key but + * vanishes under form-encoding. Rendering it fills a second key with bytes + * identical to the first. + * 'elsewhere' — it names a different URL outright (different path, different params, values + * in a different order). Often still the same page — a faceted origin ignores a + * decorative slug and reorders facets for you — but nothing about the URLs says + * so, and the answer is the same either way: don't index this one. + * + * Both non-self verdicts are non-indexable and the plugin suppresses them identically; they + * are separated only so an operator can read "N duplicate spellings" apart from "N pages that + * canonicalize to another page". An origin that writes canonicals in a different encoding from + * its own sitemap would surface as a wave of 'variant' — that is the signal to investigate, + * not a reason to loosen the comparison back. What such an origin CANNOT lose is its declared + * corpus: a sitemap-listed url is serialized even when non-indexable, so its result posts with + * content and `rendered` wins in {@link RenderJob.outcome}, which keeps it out of the plugin's + * suppression branch entirely. Only urls the plugin discovered are retirable this way. + * + * WHY THE KEY, NOT THE DOCUMENT. The consequence of this verdict is `Target.suppress`, and a + * Target IS a cache key: two spellings of one document that key differently are two targets, + * two recurring render slots, and two copies of the same bytes, forever. So "does this name + * the same document" is not the question worth asking here — "does this name the same key" is. + * Asking it through `canonicalizeUrl` also means the answer cannot drift from what the plugin + * actually stores, since that is the same function the key is built with. + * + * The current URL's own param NAMES are the allowlist for both sides. The rendered URL is + * already route-filtered (it is a canonical half), so a param the route drops must never be + * able to manufacture a mismatch, while a param the route keeps is compared byte-for-byte. + * + * A relative canonical resolves against the current URL. A malformed one fails open ('self'): + * a broken tag must not cost a page its indexability. */ -export const canonicalAllowsIndex = (canonicalHref: string | null | undefined, currentUrl: string): boolean => { - if (!canonicalHref) return true; +export const canonicalVerdict = (canonicalHref: string | null | undefined, currentUrl: string): CanonicalVerdict => { + if (!canonicalHref) return 'self'; try { - return normalizeCanonicalUrl(new URL(canonicalHref, currentUrl)) === normalizeCanonicalUrl(currentUrl); + const current = new URL(currentUrl); + const canonical = new URL(canonicalHref, current); + const allowlist = [...current.searchParams.keys()]; + if (canonicalizeUrl(canonical, allowlist) === canonicalizeUrl(current, allowlist)) return 'self'; + return normalizeCanonicalUrl(canonical) === normalizeCanonicalUrl(current) ? 'variant' : 'elsewhere'; } catch { - return true; + return 'self'; } }; diff --git a/packages/browser/test/redirect.test.ts b/packages/browser/test/redirect.test.ts index 45c1749e..ce910b2d 100644 --- a/packages/browser/test/redirect.test.ts +++ b/packages/browser/test/redirect.test.ts @@ -78,6 +78,14 @@ before(async () => { return res.end( `tx` ); + // Serves one page under any spelling of its query, and canonicalizes to the `+` one — + // what a faceted origin does. Requested as `%20`, it is the same document under a + // different cache key. + case '/canonical-respelled': + res.setHeader('content-type', 'text/html'); + return res.end( + `tx` + ); case '/destination': res.setHeader('content-type', 'text/html'); return page('DESTINATION'); @@ -167,3 +175,79 @@ test('reason distinguishes noindex from canonical-mismatch', async () => { assert.equal(canonical.isIndexable, false); assert.equal(canonical.reason, 'canonical-mismatch'); }); + +// A canonical that names THIS document under another spelling is still a second cache key, so +// it is non-indexable — with its own reason, because "you already render this page under a +// different key" is a different operational story from "this page disowns itself". +test('a re-spelled self-canonical is non-indexable as canonical-variant — under canonical.strict', async () => { + const result = await renderOnce({ + url: `${base}/canonical-respelled?f=A%20B`, + config: { scroll: { enabled: false }, canonical: { strict: true } }, + resourceCache: { enabled: false }, + captureNonIndexable: false, // production's gate: a DISCOVERED url, not a sitemap-listed one + }); + await result.close(); + + assert.equal(result.isIndexable, false); + assert.equal(result.reason, 'canonical-variant'); + assert.equal(result.outcome, 'non-indexable'); + assert.equal(result.html, undefined); +}); + +// Whether two spellings are one resource is a fact about the SITE's query parser, so the default +// must be the historical reading: the re-spelling keeps its own target and renders. A site only +// opts in after establishing that its origin form-decodes (one request, see CanonicalConfig). +test('by default a re-spelled self-canonical is still indexable (no behavior change on upgrade)', async () => { + const result = await renderOnce({ + url: `${base}/canonical-respelled?f=A%20B`, + config: { scroll: { enabled: false } }, + resourceCache: { enabled: false }, + captureNonIndexable: false, + }); + await result.close(); + + assert.equal(result.isIndexable, true); + assert.equal(result.reason, undefined); + assert.ok(result.html); +}); + +// The invariable half is NOT configurable: a canonical naming a different document disowns the +// page whatever `strict` says. +test('a canonical pointing elsewhere is non-indexable regardless of canonical.strict', async () => { + for (const strict of [false, true]) { + const result = await renderOnce({ + url: `${base}/canonical-elsewhere`, + config: { scroll: { enabled: false }, canonical: { strict } }, + resourceCache: { enabled: false }, + captureNonIndexable: false, + }); + await result.close(); + assert.equal(result.isIndexable, false, `strict=${strict}`); + assert.equal(result.reason, 'canonical-mismatch', `strict=${strict}`); + } +}); + +// THE BLAST-RADIUS GUARANTEE, pinned so a refactor cannot quietly remove it. A sitemap-listed +// url serializes even when non-indexable, and content wins in RenderJob.outcome — so the result +// posts as 'rendered' and the plugin's suppression branch is never reached. Whatever a canonical +// verdict decides, it can only ever retire urls we DISCOVERED; the corpus the site itself +// declared is structurally out of reach. An origin whose canonicals are spelled differently from +// its own sitemap therefore loses nothing but the isIndexable flag on the stored page. +test('a sitemap-listed url survives any canonical verdict (content wins → rendered)', async () => { + for (const path of ['/canonical-respelled?f=A%20B', '/canonical-elsewhere']) { + const result = await renderOnce({ + url: `${base}${path}`, + // strict on, i.e. the harshest reading available — the guarantee must hold even there. + config: { scroll: { enabled: false }, canonical: { strict: true } }, + resourceCache: { enabled: false }, + captureNonIndexable: true, // === isFromSitemap in production + // The POSTED outcome — what the plugin actually branches on — not renderOnce's own slug. + probes: { posted: ({ job }) => job.outcome }, + }); + await result.close(); + + assert.equal(result.isIndexable, false, `${path}: the verdict itself is unchanged`); + assert.ok(result.html, `${path}: but the page is serialized anyway`); + assert.equal(result.probes.posted, 'rendered', `${path}: so content wins and nothing suppresses it`); + } +}); diff --git a/packages/browser/test/url.test.ts b/packages/browser/test/url.test.ts index 7796b502..67a84a62 100644 --- a/packages/browser/test/url.test.ts +++ b/packages/browser/test/url.test.ts @@ -1,12 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { - canonicalizeUrl, - normalizeUrlForCompare, - normalizeCanonicalUrl, - canonicalAllowsIndex, -} from '../dist/util/url.js'; +import { canonicalizeUrl, normalizeUrlForCompare, normalizeCanonicalUrl, canonicalVerdict } from '../dist/util/url.js'; // The single shared vector, asserted by BOTH the browser (this file) and the plugin suite, // so the two canonicalizeUrl copies (TS here, JS in the plugin) cannot drift. @@ -31,42 +26,92 @@ test('canonicalizeUrl does not flag a non-redirect (encoding / trailing slash / assert.equal(canonicalizeUrl('https://example.com/a?f=X:Y'), canonicalizeUrl('https://example.com/a?f=X%3AY')); }); -// --- indexability (self-canonical) comparison — separate from the cache key --- +// --- canonical verdict: read against the CACHE KEY, not the document --- // The request carries %3A (encoded colon) while the page's canonical uses the literal ':'. -// They name the same resource, so the page is self-canonical → indexable. +// canonicalizeUrl decodes both to ':', so they are one key → self-canonical → indexable. test('canonical with a decoded reserved char (: vs %3A) counts as self-canonical', () => { const requested = 'https://example.com/c/page.jsp?f=A%3AB+g%3AC'; const canonical = 'https://example.com/c/page.jsp?f=A:B+g:C'; - assert.equal(canonicalAllowsIndex(canonical, requested), true); + assert.equal(canonicalVerdict(canonical, requested), 'self'); }); -test('a genuinely different canonical is NOT self-canonical (stays non-indexable)', () => { +test('a genuinely different canonical points elsewhere (stays non-indexable)', () => { const requested = 'https://example.com/c/page-a.jsp?f=A%3AB'; const canonical = 'https://example.com/c/page-b.jsp?f=X%3AY'; - assert.equal(canonicalAllowsIndex(canonical, requested), false); + assert.equal(canonicalVerdict(canonical, requested), 'elsewhere'); }); -test('no canonical → indexable', () => { - assert.equal(canonicalAllowsIndex(null, 'https://example.com/x'), true); - assert.equal(canonicalAllowsIndex(undefined, 'https://example.com/x'), true); - assert.equal(canonicalAllowsIndex('', 'https://example.com/x'), true); +test('no canonical → self (indexable)', () => { + assert.equal(canonicalVerdict(null, 'https://example.com/x'), 'self'); + assert.equal(canonicalVerdict(undefined, 'https://example.com/x'), 'self'); + assert.equal(canonicalVerdict('', 'https://example.com/x'), 'self'); }); test('param order, trailing slash, and hash differences do not break self-canonical', () => { const current = 'https://example.com/p?b=2&a=1'; - assert.equal(canonicalAllowsIndex('https://example.com/p?a=1&b=2', current), true); // order - assert.equal(canonicalAllowsIndex('https://example.com/p/?b=2&a=1', current), true); // trailing slash - assert.equal(canonicalAllowsIndex('https://example.com/p?b=2&a=1#frag', current), true); // hash + assert.equal(canonicalVerdict('https://example.com/p?a=1&b=2', current), 'self'); // order + assert.equal(canonicalVerdict('https://example.com/p/?b=2&a=1', current), 'self'); // trailing slash + assert.equal(canonicalVerdict('https://example.com/p?b=2&a=1#frag', current), 'self'); // hash }); test('a relative canonical resolves against the current URL', () => { const current = 'https://example.com/c/page.jsp?f=A%3AB'; - assert.equal(canonicalAllowsIndex('/c/page.jsp?f=A:B', current), true); + assert.equal(canonicalVerdict('/c/page.jsp?f=A:B', current), 'self'); }); test('a malformed canonical fails open (does not drop indexability)', () => { - assert.equal(canonicalAllowsIndex('http://[bad', 'https://example.com/x'), true); + assert.equal(canonicalVerdict('http://[bad', 'https://example.com/x'), 'self'); +}); + +// THE CASE THIS VERDICT EXISTS FOR. A faceted origin resolves `+`, `%2B` and `%20` between +// facets to the same page and canonicalizes all of them to the `+` spelling — but each spelling +// is its own cache key, so without this verdict every crawler-invented re-encoding becomes a +// second recurring target holding byte-identical content. The form-encoding comparison cannot +// see it: `%20` and `+` collapse to the same string there, which is exactly why the verdict is +// taken on canonicalizeUrl instead. +test('a %20-for-+ separator re-spelling is a duplicate KEY, not a self-canonical page', () => { + const canonical = 'https://example.com/c/page.jsp?f=Color:Black+Size:Large'; + const respelled = 'https://example.com/c/page.jsp?f=Color:Black%20Size:Large'; + assert.notEqual(canonicalizeUrl(respelled, ['f']), canonicalizeUrl(canonical, ['f'])); + assert.equal(normalizeCanonicalUrl(respelled), normalizeCanonicalUrl(canonical)); // same document… + assert.equal(canonicalVerdict(canonical, respelled), 'variant'); // …different key ⇒ suppress +}); + +test('a %2B-for-+ separator re-spelling is also non-indexable', () => { + const canonical = 'https://example.com/c/page.jsp?f=Color:Black+Size:Large'; + const respelled = 'https://example.com/c/page.jsp?f=Color:Black%2BSize:Large'; + assert.notEqual(canonicalVerdict(canonical, respelled), 'self'); +}); + +test('facet values in another order are not self-canonical', () => { + const canonical = 'https://example.com/c/page.jsp?f=Color:Black+Size:Large'; + const reordered = 'https://example.com/c/page.jsp?f=Size:Large+Color:Black'; + assert.equal(canonicalVerdict(canonical, reordered), 'elsewhere'); +}); + +// The mirror image, and the reason this must not be solved by folding `+` and `%2B` in the +// cache key: in VALUE position they are different values (`BLACK+DECKER` the brand vs the two +// facets `BLACK` and `DECKER`). A self-canonical page keeping `%2B` must stay indexable. +test('a %2B inside a facet VALUE is self-canonical and must not be flagged', () => { + const url = 'https://example.com/c/page.jsp?f=Brand:ACME%2BCO'; + assert.equal(canonicalVerdict(url, url), 'self'); + assert.equal(canonicalVerdict('/c/page.jsp?f=Brand:ACME%2BCO', url), 'self'); +}); + +// The rendered URL is already route-filtered, so params the route drops must not be able to +// manufacture a mismatch — the plugin would never have keyed them in the first place. +test('a param the route dropped cannot create a mismatch', () => { + const current = 'https://example.com/c/page.jsp?f=A:B'; + assert.equal(canonicalVerdict('https://example.com/c/page.jsp?f=A:B&utm_source=x', current), 'self'); + // …and a query-less request (a route that drops everything) ignores the canonical's query. + assert.equal(canonicalVerdict('https://example.com/p/prd-1?color=red', 'https://example.com/p/prd-1'), 'self'); +}); + +// The reverse is a real verdict: the origin saying "the canonical of this filtered page is the +// unfiltered one" means this key duplicates that one. +test('a canonical that drops a param the request kept is not self', () => { + assert.notEqual(canonicalVerdict('https://example.com/c/page.jsp', 'https://example.com/c/page.jsp?f=A:B'), 'self'); }); test('normalizeCanonicalUrl canonicalizes encoding, param order, hash, and trailing slash', () => { @@ -93,5 +138,5 @@ test('malformed percent-encoding falls back to raw instead of throwing', () => { assert.doesNotThrow(() => normalizeCanonicalUrl(bad)); assert.doesNotThrow(() => canonicalizeUrl(bad)); } - assert.doesNotThrow(() => canonicalAllowsIndex('https://x.com/a', 'https://x.com/%E0%A0/a')); + assert.doesNotThrow(() => canonicalVerdict('https://x.com/a', 'https://x.com/%E0%A0/a')); }); diff --git a/packages/plugin/src/resources/RenderQueue.js b/packages/plugin/src/resources/RenderQueue.js index aaba7c0f..562d5bb7 100644 --- a/packages/plugin/src/resources/RenderQueue.js +++ b/packages/plugin/src/resources/RenderQueue.js @@ -421,7 +421,19 @@ export class RenderQueue extends Resource { } else if (outcome === 'non-indexable') { // `reason` (browser ≥ v1.16.0) says WHY: 'noindex', 'canonical-mismatch', 'http-error', // or 'redirect-loop' — the difference between "the site asked us not to" and "the - // render is broken", which read identically without it. The verdict SUPPRESSES the + // render is broken", which read identically without it. Browser ≥ v1.17.0 adds + // 'canonical-variant': the canonical names this very document RE-SPELLED as a + // different cache key, so the target duplicates one we already render rather than + // being a page that disowns itself. Suppressed identically — the split exists so a + // wave of duplicate spellings is legible as such rather than reading as an origin + // that stopped believing in its own pages. + // + // Note which urls can reach here at all: a sitemap-listed one is serialized even when + // non-indexable, so its result arrives with content and `rendered` wins the outcome + // above — the declared corpus is structurally out of this branch's reach, and only + // urls we DISCOVERED can be suppressed by a canonical verdict. + // + // The verdict SUPPRESSES the // target (state + recheck schedule) rather than deleting it — see Target.suppress, // which also grades http-error verdicts by status (404/410 recheck less, die sooner). //