fix(seo): single-brand titles + restore ADR 2025 + contract test (FAULT 15)#44
Merged
Merged
Conversation
Diagnosis-first per sprint hard rule. Findings: 1. The literal 'FreightUtils | FreightUtils.com' double-branding is NOT currently in prod. PR #34 fixed it at the root template (changed '%s | FreightUtils.com' to '%s | FreightUtils' in app/layout.tsx). Curl of 11 representative URLs shows single brand or no brand. Sprint context (R8/R9/R10 rounds) was written against pre-PR-#34 prod. 2. Two real issues remain: - ADR detail titles lost 'ADR 2025' context segment. Builder doesn't emit it; year/edition signal lives only in meta description. - Prevention is by convention. lint-seo-titles.mjs checks length / keyword / description rules but does not assert 'FreightUtils appears at most once per rendered title'. Three rounds of regression returned because the discipline isn't enforced. 3. Fix direction (Phase 2): - Extend lint-seo-titles.mjs with Rule A (no double-brand) covering ALL static metadata exports under app/, and Rule B (ADR titles contain 'ADR 2025') run against ADR builder fixtures. - Update buildAdrUnMetadata to emit 'ADR 2025' in titles using the same SITE_YEAR -> 2025 mapping already used for descriptions. 4. Contract test fails on pre-fix main (Rule B fires on every ADR fixture); passes after builder edit. PR body documents both states. Companion follow-ups noted but out of scope: - /changelog and /containers use title.absolute without brand — brand recall lost on those, debatable tradeoff. - /pallet title is 78 chars — SERPs cut the brand. Separate length sprint. Doc: docs/audit/title-template-2026-05-16.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Initial spot-check covered only the 8 pages flagged by R8/R9/R10 consensus rounds and concluded the literal double-brand was gone. Wider grep over app/**/page.tsx for title:.*FreightUtils finds 12+ pages where title is hardcoded as 'X — FreightUtils' and the root template appends '| FreightUtils' — rendered as 'X — FreightUtils | FreightUtils'. Prod curl confirms: /account → Account — FreightUtils | FreightUtils /for-it → For IT Departments — FreightUtils | FreightUtils /roadmap → Roadmap — FreightUtils | FreightUtils /contact → Contact — FreightUtils | FreightUtils /refund-policy → Refund Policy — FreightUtils | FreightUtils /dpa → Data Processing Agreement — FreightUtils | FreightUtils /docs/deprecation → Deprecation Policy — FreightUtils | FreightUtils /docs/versioning → Versioning Policy — FreightUtils | FreightUtils /guides → Guides — FreightUtils | FreightUtils /status → Status — FreightUtils | FreightUtils /signin → Sign in — FreightUtils | FreightUtils /guides/[slug] → LHR Shed Codes: ... — FreightUtils Guide | FreightUtils The symptom mutated from the pre-PR-#34 form ('| FreightUtils.com | FreightUtils.com') to the new form ('— FreightUtils | FreightUtils'). Same root cause: no enforced contract test for 'brand appears at most once per rendered title'. Same architecture-level fix from Phase 2. Updated Phase 1 'TL;DR' + 'Per-page static metadata exports' table in docs/audit/title-template-2026-05-16.md to reflect the broader scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause (per docs/audit/title-template-2026-05-16.md): no enforced
contract test for 'FreightUtils appears at most once per rendered
title'. 12 pages drifted to 'X — FreightUtils' as the per-page title,
which then received '| FreightUtils' from the root template at
app/layout.tsx:18 — rendered double-branded on prod across /account,
/for-it, /roadmap, /contact, /refund-policy, /dpa, /docs/deprecation,
/docs/versioning, /guides, /guides/[slug], /status, /signin.
Three changes, all component-level:
1. scripts/lint-seo-titles.mjs — extended with two new rules:
- Rule A: walks every app/**/page.tsx, extracts title literals
from static metadata exports and generateMetadata bodies,
composes the rendered title with the root template suffix where
applicable, asserts 'FreightUtils' appears at most once. Catches
the regression class going forward.
- Rule B: asserts ADR builder fixtures produce titles containing
'ADR 2025' (the dataset edition signal). Restores the year/edition
context that was lost in a prior fix attempt.
- Pre-fix run failed with 17 violations (12 Rule A + 5 Rule B).
- Post-fix run: 103 title literals scanned across 54 page files,
no double-brand; all 5 ADR fixtures contain 'ADR 2025'.
2. lib/seo/page-metadata.ts — buildAdrUnMetadata now emits 'ADR 2025'
in the title suffix. Example: 'UN 1203 Petrol — ADR 2025 Class 3 PG II'.
Adds 5 chars to the suffix; truncation logic for properShippingName
absorbs the difference. Pinned via SITE_YEAR mapping ('2026' → '2025'),
consistent with the same pattern already used in the meta description.
3. 12 page.tsx files — removed '— FreightUtils' from the per-page title
string. Root template adds '| FreightUtils' once. /guides/[slug]
changed from '${title} — FreightUtils Guide' to '${title} — Guide';
template adds '| FreightUtils' → 'Title — Guide | FreightUtils'
(single brand, retains Guide content signal).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3: - STATE.md gains a 'Search performance baseline' subsection capturing the Bing Webmaster baseline (60 clicks / 4.7K imp / 1.29% CTR overall, 8 top zero-click queries with positions). Documents the Bing 3-7d / Google 7-21d re-crawl windows so the CTR uplift can be assessed against this baseline at 2026-05-23–05-30 (Bing) and 2026-05-30–06-06 (Google). GSC pull noted as Soap-manual pending (no GSC connector configured). - STATE.md Sprint cadence count bumped 18 -> 19 with PR #44 entry. - /changelog roadmap 'GSC CTR improvement' card: not present in app/roadmap/page.tsx — N/A. Phase 4: - FAULT 15 added to docs/FAULT-HISTORY-AND-PREVENTION.md as a new category. Includes root cause, symptom, detection mechanism (lint Rule A + Rule B), and prevention via CI gate. Plus a 2026-05-16 fault-log row referencing PR #44. - package.json: new 'prebuild' script runs lint:seo-titles before 'next build'. Vercel build now blocks any PR that introduces a double-brand title or drops the ADR edition signal. Release hygiene: - CHANGELOG.md gets a 2026-05-16 'SEO' entry at the top. - lib/changelog-data.ts gets a 2026-05-16 'Bug Fix' entry; verified Rule A still passes after the edit (new entry doesn't contain 'FreightUtils', the only safe form for a plain-string title). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
R8 / R9 / R10 consensus rounds all flagged the same regression: double-branded SERP titles. Three prior page-by-page fixes regressed because there was no enforced test for "FreightUtils appears at most once per rendered title." This PR fixes it at the architecture level — extends
scripts/lint-seo-titles.mjswith a contract test that walks everyapp/**/page.tsx, fails on Rule A (double-brand) and Rule B (ADR detail titles missing "ADR 2025"), and gates the Vercel build via a newprebuildhook.Diagnosis-first:
docs/audit/title-template-2026-05-16.md(two commits — initial diagnosis + addendum after a broader grep found the actual offenders).Phase 1 — Diagnosis (committed before any fix)
Initial spot-check of the 8 pages flagged by R8/R9/R10 (
/about,/consignment-calculator,/duty,/unlocode,/vehicles,/changelog,/containers,/pallet) found them clean — PR #34's root-template fix (%s | FreightUtils.com→%s | FreightUtils) had resolved the specific.comdoubling on those pages. But a wider grep overapp/**/page.tsxfortitle:.*FreightUtilsfound 12 OTHER pages with hardcoded— FreightUtilsin the title string, which then receive| FreightUtilsfrom the root template → rendered asX — FreightUtils | FreightUtilson prod. The symptom mutated from the old.comform to the new form; same root cause.Prod-verified double-brands on 2026-05-16:
Plus ADR detail titles lost the
ADR 2025edition signal:Phase 2 — Component-level fix
Three changes:
scripts/lint-seo-titles.mjs— extended with two new rules and a generic walker overapp/**/page.tsx:metadataexports andgenerateMetadatabodies (includingconst title = ...shorthand patterns), composes the rendered title with the root template suffix where applicable, asserts"FreightUtils"appears at most once."ADR 2025"(or matchingSITE_YEARmapping).lib/seo/page-metadata.ts—buildAdrUnMetadatanow emitsADR 2025in the title. Title shape changes fromUN X NAME — ADR Class C PG PtoUN X NAME — ADR 2025 Class C PG P. Adds 5 chars to the suffix; existing truncation logic forproperShippingNameabsorbs the difference. Year pinned viaSITE_YEARmapping consistent with the existing meta-description pattern.— FreightUtilsfrom each per-pagetitle:string. Root template adds| FreightUtilsonce./guides/[slug]changed from${meta.title} — FreightUtils Guideto${meta.title} — Guide(template appends brand →Title — Guide | FreightUtils, retains content signal).Pre-fix vs post-fix lint output
Pre-fix (
mainbefore Phase 2 commit)Post-fix (this branch)
17 → 0. Contract test passes.
Phase 3 — Baseline + roadmap
STATE.mdgains a "Search performance baseline" subsection with the Bing 3-month numbers (60 clicks / 4.7K imp / 1.29% CTR overall, 8 top zero-click queries). Documents the re-crawl assessment windows: Bing 2026-05-23–05-30, Google 2026-05-30–06-06.STATE.mdSprint cadence count bumped 18 → 19 with PR fix(seo): single-brand titles + restore ADR 2025 + contract test (FAULT 15) #44 entry.app/roadmap/page.tsx— flagged as N/A in the audit doc.Phase 4 — FAULT prevention
docs/FAULT-HISTORY-AND-PREVENTION.md: "Double-branded SERP title regression". Documents root cause, symptom, detection (Rule A + B), and prevention (CI gate). Per-page rule of thumb included so PR reviewers can spot the regression class.prebuildscript inpackage.json:"prebuild": "npm run lint:seo-titles". Vercel'snpm run buildstep now runs the contract test beforenext build. Any future PR introducing a double-brand title or dropping the ADR edition signal fails the Vercel build → blocks merge.FAULT 5 checklist
siteStats.ts— n/a (no displayed number changed)app/sitemap.ts— n/a (no new page)public/openapi.json— n/a (no new endpoint)/api-docspage — n/aCHANGELOG.mdentry — added (2026-05-16 SEO entry at top)/changelogpage (lib/changelog-data.ts) — Bug Fix entry added at top of array; Rule A re-passes after the editSoapyRED/freightutils-mcpREADME — n/awithAuditRest— n/agenerateMetadata()— n/a (tightening existing)Test plan
lint:api-casing,lint:audit,lint:sentry-redactall pass/changelogrenders the new entry on prod (cache-bust curl)/changelog,/account,/roadmapExit criteria
10596c0+ amendment36af1fb)scripts/lint-seo-titles.mjsis the single source of enforced no-double-brand discipline;lib/seo/page-metadata.tsis the single source for ADR title shapeapp/roadmap/page.tsx); documented in auditprebuildhook🤖 Generated with Claude Code