Skip to content

fix(seo): single-brand titles + restore ADR 2025 + contract test (FAULT 15)#44

Merged
SoapyRED merged 4 commits into
mainfrom
fix/title-template-double-branding-2026-05-16
May 16, 2026
Merged

fix(seo): single-brand titles + restore ADR 2025 + contract test (FAULT 15)#44
SoapyRED merged 4 commits into
mainfrom
fix/title-template-double-branding-2026-05-16

Conversation

@SoapyRED
Copy link
Copy Markdown
Owner

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.mjs with a contract test that walks every app/**/page.tsx, fails on Rule A (double-brand) and Rule B (ADR detail titles missing "ADR 2025"), and gates the Vercel build via a new prebuild hook.

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 .com doubling on those pages. But a wider grep over app/**/page.tsx for title:.*FreightUtils found 12 OTHER pages with hardcoded — FreightUtils in the title string, which then receive | FreightUtils from the root template → rendered as X — FreightUtils | FreightUtils on prod. The symptom mutated from the old .com form to the new form; same root cause.

Prod-verified double-brands on 2026-05-16:

/account            -> Account — FreightUtils | FreightUtils
/contact            -> Contact — FreightUtils | FreightUtils
/docs/deprecation   -> Deprecation Policy — FreightUtils | FreightUtils
/docs/versioning    -> Versioning Policy — FreightUtils | FreightUtils
/dpa                -> Data Processing Agreement — FreightUtils | FreightUtils
/for-it             -> For IT Departments — FreightUtils | FreightUtils
/guides             -> Guides — FreightUtils | FreightUtils
/guides/[slug]      -> LHR Shed Codes: ... — FreightUtils Guide | FreightUtils
/refund-policy      -> Refund Policy — FreightUtils | FreightUtils
/roadmap            -> Roadmap — FreightUtils | FreightUtils
/signin             -> Sign in — FreightUtils | FreightUtils
/status             -> Status — FreightUtils | FreightUtils

Plus ADR detail titles lost the ADR 2025 edition signal:

/adr/un/1203 -> UN 1203 MOTOR SPIRIT or GASOLINE or… — ADR Class 3 PG II
                                                       ^^^^^^^^^^^ no "2025"

Phase 2 — Component-level fix

Three changes:

  1. scripts/lint-seo-titles.mjs — extended with two new rules and a generic walker over app/**/page.tsx:
    • Rule A (no double-brand): extracts title literals from metadata exports and generateMetadata bodies (including const title = ... shorthand patterns), composes the rendered title with the root template suffix where applicable, asserts "FreightUtils" appears at most once.
    • Rule B (ADR 2025): asserts every ADR builder fixture produces a title containing "ADR 2025" (or matching SITE_YEAR mapping).
  2. lib/seo/page-metadata.tsbuildAdrUnMetadata now emits ADR 2025 in the title. Title shape changes from UN X NAME — ADR Class C PG P to UN X NAME — ADR 2025 Class C PG P. Adds 5 chars to the suffix; existing truncation logic for properShippingName absorbs the difference. Year pinned via SITE_YEAR mapping consistent with the existing meta-description pattern.
  3. 12 page.tsx files — removed — FreightUtils from each per-page title: string. Root template adds | FreightUtils once. /guides/[slug] changed from ${meta.title} — FreightUtils Guide to ${meta.title} — Guide (template appends brand → Title — Guide | FreightUtils, retains content signal).

Pre-fix vs post-fix lint output

Pre-fix (main before Phase 2 commit)

lint-seo-titles: 17 violation(s):

  ✗ account/page.tsx:19: rendered title has "FreightUtils" 2× — "Account — FreightUtils | FreightUtils"
  ✗ contact/page.tsx:4:  rendered title has "FreightUtils" 2× — "Contact — FreightUtils | FreightUtils"
  ✗ docs/deprecation/page.tsx:8: "Deprecation Policy — FreightUtils | FreightUtils"
  ✗ docs/versioning/page.tsx:8: "Versioning Policy — FreightUtils | FreightUtils"
  ✗ dpa/page.tsx:4: "Data Processing Agreement — FreightUtils | FreightUtils"
  ✗ for-it/page.tsx:5: "For IT Departments — FreightUtils | FreightUtils"
  ✗ guides/page.tsx:12: "Guides — FreightUtils | FreightUtils"
  ✗ guides/[slug]/page.tsx:56: "X — FreightUtils Guide | FreightUtils"
  ✗ refund-policy/page.tsx:4: "Refund Policy — FreightUtils | FreightUtils"
  ✗ roadmap/page.tsx:12: "Roadmap — FreightUtils | FreightUtils"
  ✗ signin/page.tsx:18: "Sign in — FreightUtils | FreightUtils"
  ✗ status/page.tsx:13: "Status — FreightUtils | FreightUtils"
  ✗ ADR UN 3283: title missing "ADR 2025" — "UN 3283 Selenium compound, solid,… — ADR Class 6.1 PG II"
  ✗ ADR UN 1013: title missing "ADR 2025" — "UN 1013 Carbon dioxide — ADR Class 2.2"
  ✗ ADR UN 1203: title missing "ADR 2025" — "UN 1203 Petrol — ADR Class 3 PG II"
  ✗ ADR UN 3077: title missing "ADR 2025"
  ✗ ADR UN 1263: title missing "ADR 2025"

Post-fix (this branch)

lint-seo-titles: 4 detail templates wired + 4 index pages clean.
lint-seo-titles: Builders pass 21 fixtures.
lint-seo-titles: Rule A: scanned 103 title literals across 54 page files — no double-brand.
lint-seo-titles: Rule B: 5 ADR fixture titles include "ADR 2025".

17 → 0. Contract test passes.

Phase 3 — Baseline + roadmap

  • STATE.md gains 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.md Sprint cadence count bumped 18 → 19 with PR fix(seo): single-brand titles + restore ADR 2025 + contract test (FAULT 15) #44 entry.
  • No "GSC CTR improvement" roadmap card exists in app/roadmap/page.tsx — flagged as N/A in the audit doc.
  • GSC pull: pending Soap-manual (no GSC connector in Code session).

Phase 4 — FAULT prevention

  • FAULT 15 added to 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.
  • CI gate wired via a new prebuild script in package.json: "prebuild": "npm run lint:seo-titles". Vercel's npm run build step now runs the contract test before next build. Any future PR introducing a double-brand title or dropping the ADR edition signal fails the Vercel build → blocks merge.
  • Fault log row added for 2026-05-16 referencing this PR.

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-docs page — n/a
  • Nav dropdown — n/a
  • Homepage tool grid — n/a
  • CHANGELOG.md entry — added (2026-05-16 SEO entry at top)
  • /changelog page (lib/changelog-data.ts) — Bug Fix entry added at top of array; Rule A re-passes after the edit
  • MCP server tool registration — n/a
  • Footer links — n/a
  • SoapyRED/freightutils-mcp README — n/a
  • npm package version bumped — n/a
  • Postman collection — n/a
  • Tool page ≥ 200 words — n/a
  • withAuditRest — n/a
  • generateMetadata() — n/a (tightening existing)
  • IndexNow on prod deploy — runs via existing workflow

Test plan

  • Contract test fails on pre-fix main with 17 violations (capture above)
  • Contract test passes after fix (0 violations, 103 literals scanned across 54 pages)
  • ESLint clean on the 14 changed files (3 pre-existing unused-import warnings, 0 errors)
  • lint:api-casing, lint:audit, lint:sentry-redact all pass
  • Smoke test against prod: 38/40 (2 anon-rate-limit fails attributable to test-bucket exhaustion from earlier runs; Pro paths pass; SEO hygiene "detail titles — no double-suffix regression" PASSES)
  • Post-merge: /changelog renders the new entry on prod (cache-bust curl)
  • Post-merge: spot check 5 representative URLs render single-brand (one ADR detail, /account, /roadmap, /guides, /guides/[slug])
  • Post-merge: Sentry 5xx proxy quiet for 10 min across /changelog, /account, /roadmap

Exit criteria

  • Audit doc committed (commit 10596c0 + amendment 36af1fb)
  • Single-template fix landed — scripts/lint-seo-titles.mjs is the single source of enforced no-double-brand discipline; lib/seo/page-metadata.ts is the single source for ADR title shape
  • Contract test passes; verified to have failed on pre-fix main (logs above)
  • Manual curl spot check on 5 URLs — single brand suffix; ADR detail includes "ADR 2025" — post-deploy
  • STATE.md "Search performance baseline" subsection added
  • /changelog roadmap card "GSC CTR improvement" — N/A (no such card in app/roadmap/page.tsx); documented in audit
  • FAULT-HISTORY-AND-PREVENTION.md FAULT 15 entry added
  • Contract test wired into CI via prebuild hook
  • FAULT 5 checklist applied
  • Smoke test green on prod (38/40)
  • Sentry-quiet 5xx proxy: 0/5 5xx in 10 min post-merge

🤖 Generated with Claude Code

SoapyRED and others added 4 commits May 16, 2026 20:07
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>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 16, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
freighttools Ready Ready Preview, Comment May 16, 2026 7:37pm

Request Review

@SoapyRED SoapyRED merged commit a237ade into main May 16, 2026
2 checks passed
@SoapyRED SoapyRED deleted the fix/title-template-double-branding-2026-05-16 branch May 16, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant