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).
//