Skip to content

Commit 8739270

Browse files
trialclaude
andcommitted
feat(browser): a canonical verdict posts the canonical the page declared (declaredCanonical), so the plugin learns where the product lives now; v1.40.0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 91ebecc commit 8739270

5 files changed

Lines changed: 110 additions & 1 deletion

File tree

‎packages/browser/src/RenderJob.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,14 @@ export default class RenderJob {
219219
* redirect/error fallbacks so callers only set the values they alone can know.
220220
*/
221221
reason: string | undefined;
222+
/**
223+
* The URL the page's canonical link names, when a non-indexable render's canonical names ANOTHER URL
224+
* (resolved against the rendered URL, fragment dropped). Unset otherwise, so the key is absent from
225+
* every result that has nothing to say and an older plugin never sees it. The plugin's entity registry
226+
* records it: on a site where every spelling of a product is one document, this is the origin naming
227+
* the product's canonical.
228+
*/
229+
declaredCanonical: string | undefined;
222230

223231
_httpResponse: OriginHttpResponse | null = null;
224232

@@ -366,6 +374,8 @@ export default class RenderJob {
366374
reason: this.content
367375
? undefined
368376
: (this.reason ?? (attemptError ? 'error' : this.redirectedTo ? 'redirect' : undefined)),
377+
// Under the same rule as `reason`: a render that produced content has no verdict to explain.
378+
declaredCanonical: this.content ? undefined : this.declaredCanonical,
369379
// The failed attempt's detail — without it the plugin can only log "unknown
370380
// prerender error". `phase` separates a navigation that never completed (slow
371381
// origin) from a failure in the settle/serialize work. Anything can be thrown, so
@@ -425,6 +435,7 @@ export default class RenderJob {
425435
documentReused: this.documentReused || undefined,
426436
documentPrefetched: this.documentPrefetched || undefined,
427437
reason: 'result-build-failed',
438+
declaredCanonical: undefined,
428439
error: {
429440
name: (error as Error)?.name ?? 'Error',
430441
message: (error as Error)?.message ?? String(error),
@@ -534,6 +545,8 @@ export type VariantMetadata = {
534545
documentReused: true | undefined;
535546
documentPrefetched: true | undefined;
536547
reason: string | undefined;
548+
/** See `RenderJob.declaredCanonical`: present only on a non-indexable result whose canonical names another URL. */
549+
declaredCanonical: string | undefined;
537550
error: { name: string; message: string; phase: string | undefined } | undefined;
538551
};
539552

‎packages/browser/src/renderOnce.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ export interface RenderResult {
9797
isIndexable: boolean | undefined;
9898
/** Why no cacheable content: 'noindex' | 'canonical-mismatch' | 'canonical-variant' | 'http-error' | 'redirect-loop' | 'redirect' | 'error'. */
9999
reason: string | undefined;
100+
/** The URL a non-indexable page's canonical names, when it names another URL (job.declaredCanonical). */
101+
declaredCanonical: string | undefined;
100102
/** Normalized redirect target (job.redirectedTo), if the final URL canonically differs. */
101103
redirectedTo: string | undefined;
102104
/** Raw page.url() at snapshot time (complements the normalized redirectedTo). */
@@ -290,6 +292,7 @@ export async function renderOnce(options: RenderOnceOptions): Promise<RenderResu
290292
statusCode: job.httpResponse?.statusCode,
291293
isIndexable: job.isIndexable,
292294
reason: job.reason ?? (error ? 'error' : !html && job.redirectedTo ? 'redirect' : undefined),
295+
declaredCanonical: job.declaredCanonical,
293296
redirectedTo: job.redirectedTo,
294297
finalUrl,
295298
responseHeaders: job.httpResponse?.headers,

‎packages/browser/src/renderer.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ const renderer: Renderer = async (page, job) => {
546546
job.httpResponse = { statusCode, headers: finalRes.headers() };
547547
job.isIndexable = false;
548548
job.reason = verdict.reason;
549+
job.declaredCanonical = 'declaredCanonical' in verdict ? verdict.declaredCanonical : undefined;
549550
return;
550551
}
551552
}
@@ -1142,6 +1143,7 @@ const renderer: Renderer = async (page, job) => {
11421143
job.isIndexable = verdict.isIndexable;
11431144
if (!verdict.isIndexable) {
11441145
job.reason = verdict.reason;
1146+
job.declaredCanonical = verdict.declaredCanonical;
11451147
}
11461148

11471149
if (job.isIndexable || job.isFromSitemap) {
@@ -1300,16 +1302,39 @@ export function indexVerdict(
13001302
signals: { canonicalHref: string | null; noindex: boolean },
13011303
pageUrl: string,
13021304
strict: boolean
1303-
): { isIndexable: boolean; reason?: 'noindex' | 'canonical-variant' | 'canonical-mismatch' } {
1305+
): {
1306+
isIndexable: boolean;
1307+
reason?: 'noindex' | 'canonical-variant' | 'canonical-mismatch';
1308+
declaredCanonical?: string;
1309+
} {
13041310
const verdict = canonicalVerdict(signals.canonicalHref, pageUrl);
13051311
const disowned = verdict === 'elsewhere' || (verdict === 'variant' && strict);
13061312
if (!signals.noindex && !disowned) return { isIndexable: true };
1313+
const declaredCanonical = verdict === 'self' ? undefined : declaredCanonicalOf(signals.canonicalHref, pageUrl);
13071314
return {
13081315
isIndexable: false,
13091316
reason: signals.noindex ? 'noindex' : verdict === 'variant' ? 'canonical-variant' : 'canonical-mismatch',
1317+
...(declaredCanonical ? { declaredCanonical } : {}),
13101318
};
13111319
}
13121320

1321+
/**
1322+
* The URL a page's canonical link names, resolved exactly as {@link canonicalVerdict} resolved it to reach
1323+
* its verdict (against the rendered URL), without its fragment. Posted with a non-indexable result whose
1324+
* canonical names another URL: on a site where every spelling of a product is one document, the page that
1325+
* disowned this spelling has just said where the product lives now, and the plugin records it.
1326+
*/
1327+
function declaredCanonicalOf(canonicalHref: string | null, pageUrl: string): string | undefined {
1328+
if (!canonicalHref) return undefined;
1329+
try {
1330+
const url = new URL(canonicalHref, pageUrl);
1331+
url.hash = '';
1332+
return url.href;
1333+
} catch {
1334+
return undefined;
1335+
}
1336+
}
1337+
13131338
function extractIndexSignals(): { canonicalHref: string | null; noindex: boolean } {
13141339
let canonicalHref: string | null = null;
13151340
let noindex = false;

‎packages/browser/test/earlySettleSkip.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,19 @@ test('a canonical naming another document skips the settle phase', async () => {
9494
assert.equal(result.isIndexable, false);
9595
assert.equal(result.reason, 'canonical-mismatch');
9696
assert.equal(result.timings.settle, undefined);
97+
assert.equal(result.declaredCanonical, `${base}/other`, 'the bail still says where the page points');
98+
});
99+
100+
test('the post-settle verdict reports the declared canonical too, and an indexable page reports none', async () => {
101+
const settled = await render('/canonical-elsewhere', { navigation: { skipSettleWhenNonIndexable: false } });
102+
await settled.close();
103+
assert.notEqual(settled.timings.settle, undefined, 'this one reached its verdict after the settle');
104+
assert.equal(settled.reason, 'canonical-mismatch');
105+
assert.equal(settled.declaredCanonical, `${base}/other`);
106+
107+
const fine = await render('/fine');
108+
await fine.close();
109+
assert.equal(fine.declaredCanonical, undefined);
97110
});
98111

99112
// A 404's settle is skipped BY THE BAIL, not by the `status >= 400` abort: that abort only stops
@@ -178,11 +191,13 @@ test('indexVerdict: the shared verdict both paths use', () => {
178191
assert.deepEqual(indexVerdict({ canonicalHref: 'https://x.test/other', noindex: false }, page, false), {
179192
isIndexable: false,
180193
reason: 'canonical-mismatch',
194+
declaredCanonical: 'https://x.test/other',
181195
});
182196
// noindex outranks a canonical verdict when both fire, so the reason stays the stronger signal.
183197
assert.deepEqual(indexVerdict({ canonicalHref: 'https://x.test/other', noindex: true }, page, false), {
184198
isIndexable: false,
185199
reason: 'noindex',
200+
declaredCanonical: 'https://x.test/other',
186201
});
187202
});
188203

@@ -209,3 +224,32 @@ test('with the flag on, a client-side redirect to an INDEXABLE page still render
209224
assert.equal(new URL(result.redirectedTo!).pathname, '/fine');
210225
assert.notEqual(result.timings.settle, undefined, 'and it must still settle');
211226
});
227+
228+
test('indexVerdict: a canonical naming another URL is reported, resolved against the page and without its fragment', () => {
229+
const page = 'https://x.test/product/prd-1/old-slug.jsp?color=red';
230+
assert.equal(
231+
indexVerdict({ canonicalHref: 'https://x.test/product/prd-1/new-slug.jsp', noindex: false }, page, false)
232+
.declaredCanonical,
233+
'https://x.test/product/prd-1/new-slug.jsp'
234+
);
235+
// Resolved exactly as the verdict resolved it, so a path-absolute href names the page's own origin.
236+
assert.equal(
237+
indexVerdict({ canonicalHref: '/product/prd-1/new-slug.jsp#top', noindex: false }, page, false).declaredCanonical,
238+
'https://x.test/product/prd-1/new-slug.jsp'
239+
);
240+
// A noindex page that also names another canonical still says where it points.
241+
assert.equal(
242+
indexVerdict({ canonicalHref: 'https://x.test/other', noindex: true }, page, false).declaredCanonical,
243+
'https://x.test/other'
244+
);
245+
// A strict variant names this document re-spelled: reported too, it is still the origin's spelling.
246+
assert.equal(
247+
indexVerdict({ canonicalHref: 'https://x.test/p%20q', noindex: false }, 'https://x.test/p+q', true)
248+
.declaredCanonical,
249+
'https://x.test/p%20q'
250+
);
251+
// Nothing to report: indexable, no canonical, or a noindex page whose canonical is itself.
252+
assert.equal('declaredCanonical' in indexVerdict({ canonicalHref: page, noindex: false }, page, false), false);
253+
assert.equal('declaredCanonical' in indexVerdict({ canonicalHref: null, noindex: true }, page, false), false);
254+
assert.equal('declaredCanonical' in indexVerdict({ canonicalHref: page, noindex: true }, page, false), false);
255+
});

‎packages/browser/test/jobResult.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,30 @@ test('a noindex page posts outcome=non-indexable with the reason', async () => {
111111
assert.equal(meta.reason, 'noindex');
112112
});
113113

114+
test('a canonical verdict posts the canonical the page declared; a render with content never does', async () => {
115+
const job = makeJob();
116+
job.attemptStarted();
117+
job.httpResponse = { statusCode: 200, headers: {} };
118+
job.isIndexable = false;
119+
job.reason = 'canonical-mismatch';
120+
job.declaredCanonical = 'https://site.example.com/product/x-new-slug';
121+
job.attemptEnded(undefined, undefined);
122+
const meta = await send(job);
123+
assert.equal(meta.outcome, 'non-indexable');
124+
assert.equal(meta.declaredCanonical, 'https://site.example.com/product/x-new-slug');
125+
126+
// Absent, not null, when there is nothing to say: an older plugin never sees the key.
127+
const plain = makeJob();
128+
plain.attemptStarted();
129+
plain.httpResponse = { statusCode: 200, headers: { 'content-type': 'text/html' } };
130+
plain.isIndexable = true;
131+
plain.declaredCanonical = 'https://site.example.com/product/x-new-slug'; // left over from an earlier attempt
132+
plain.attemptEnded(undefined, '<html>ok</html>');
133+
const stored = await send(plain);
134+
assert.equal(stored.outcome, 'rendered');
135+
assert.equal('declaredCanonical' in stored, false);
136+
});
137+
114138
test('a failed render posts outcome=error with the attempt error and derived reason', async () => {
115139
const job = makeJob();
116140
job.attemptStarted();

0 commit comments

Comments
 (0)