fix(seo): canonical store sitemap with lastmod behind one indexability policy - #236
Open
BSalaeddin wants to merge 1 commit into
Open
BSalaeddin wants to merge 1 commit into
BSalaeddin wants to merge 1 commit into
Conversation
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.
Why
grabcaramel.com: 920 clicks / 16,409 impressions in 90 days, but GSC's sitemap audit says 4,289 of 4,317 sitemap URLs are unknown to Google and the submitted sitemap was last read 2025-07-12 (14 months ago). Root causes, from the 2026-09-11 audit in
C:\Users\alaed\Documents\Github\caramel-artifact\seo-2026-09-11\(route-inventory.md§3 "Sitemap composition",audit-findings.md§"Sitemap" and §"Store-page sample"):sitemap.tsemitted raw DISTINCTcoupons.sitevalues (vialistStoreOptions), not the canonical store slug the page canonicalizes to (resolveStoreDomain). Served sitemap 2026-09-11: 4,311 store slugs collapse to 4,262 canonical bases; 86 slugs differ from their base (84 subdomains —athleta.gap.com→gap.com,au.shein.com→shein.com; 2 mixed-case —Brooklinen.com,eNasco.com); 40 bases appear under several slugs (gap.com×4,shein.com×4,att.com×3); 38 canonical targets (mattel.com,nfl.com,enasco.com,w3schools.com,timeout.com, …) were not in the sitemap at all; 1 slug is not a domain (dhl.com-us-en-home.html). Every one of those<loc>s carried a<link rel="canonical">pointing somewhere else.listStoreCoupons/listCouponsmatchsite = $base OR site LIKE '%.' || $basecase-sensitively,resolveStoreDomainlowercases, so/coupons/enasco.comAND/coupons/eNasco.comboth rendered the empty state withnoindexwhile theeNasco.comrows exist — and the sitemap listed that noindexed page.<lastmod>— Google had no freshness signal to re-read a sitemap it last read 14 months ago./supportis indexable, canonical'd and header-linked but was absent from the sitemap;/sourceswas in the sitemap but prod has 0 active sources (/api/sources→[]), so it is an empty shell GSC reports as crawled-not-indexed.What (the BioFlow shape: ONE pure policy consulted by BOTH the sitemap and page metadata)
Policy contract —
src/lib/seo/storeIndexability.ts, pure (no DB, no env):base=resolveStoreDomain()output;null/''→not-a-store; count< 1→no-coupons; otherwise indexable. A store page is in the sitemap iff its own metadata would notnoindexit — same function, both sides.src/lib/seo/storeIndexability.ts+tests/unit/storeIndexability.test.ts(real prod cases: gap/roborock/pandora/enasco,dhl.com-us-en-home.html,co.uk, mixed-case slugs through the realresolveStoreDomain).src/lib/seo/sitemapStores.ts— purecollapseStoreRows(rows): groups byresolveStoreDomain(site)(drops nulls such asdhl.com-us-en-home.html, bare public suffixes likeco.uk), sums counts, keeps the maxlast_updated, applies the policy, returns sorted{ base, couponCount, lastModified }[].tests/unit/sitemapStores.test.ts:athleta.gap.com + gap.com + bananarepublic.gap.com→ onegap.comwith summed count + max date;Brooklinen.com→brooklinen.com;dhl.com-us-en-home.html/co.uk/ null / 0-count dropped.src/lib/couponsRepo.ts— new readlistStoreSitemapEntries(limit):SELECT site, COUNT(*)::int AS coupon_count, MAX(updated_at) AS last_updated FROM coupons WHERE <visibleCouponsWhere()> AND site IS NOT NULL GROUP BY site ORDER BY site ASC LIMIT $limit, parsed through the newSiteAggregateRowSchemainsrc/lib/couponsDb.ts(z.coerce.date()likeRecentStoreRowSchema.added_at).listStoreOptionsunchanged (autocomplete). Unit pin intests/unit/couponsRepo.test.ts(query shape + bound LIMIT + drift throw), integration pin intests/integration/coupons-read.itest.ts(real pg: sorted,::int,Date, count equalslistCouponstotal for the same base, LIMIT bound).src/app/sitemap.ts— stores fromlistStoreSitemapEntries→collapseStoreRows, emitting${origin}/coupons/${encodeURIComponent(base)}withlastModifiedwhen present;/supportadded toSTATIC_ROUTES(monthly, 0.5);/sourcesemitted only whenlistActiveSources()returns ≥1 row (Promise.allwith the store read). 5000 cap +force-dynamiccomment kept. Newtests/unit/sitemap.test.ts(mocks@/lib/couponsRepo+@/lib/env.client): static routes incl./support;/sourcesabsent when empty / present otherwise; store URLs canonical + lowercase + deduped;lastModifiedcarried through (and omitted when null); no entry for a 0-count or unresolvable site; cap passed as 5000.(marketing)/coupons/[store]/page.tsxgenerateMetadata— both ad-hoc branches now take their verdict from the policy (robots: indexable ? undefined : { index: false, follow: true });tests/unit/coupons-store-page.test.tspasses unchanged (every existing assertion holds).(marketing)/sources/page.tsx—generateMetadatasetsrobots: { index: false, follow: true }when the active-source list is empty; the read is wrapped in Reactcache()so the body shares it (same pattern as the store page'sfetchStoreCoupons).tests/unit/sources-page.test.ts.src/lib/catalog/applyCatalogRows.tslowercasessiteon write (normalizeSite, the one write-side normalization; null stays null; status/discount_type/code stay RAW peringestSchemas.ts). Covers both the ingest route andbridge:sync(same engine). Unit pintests/unit/applyCatalogRows-site-case.test.ts(asserts the bound INSERT values); integration pin intests/integration/ingest-catalog.itest.ts(mixed-case push stored lowercase, found bylistStoreCouponsunder both spellings and bylistStoreSitemapEntriesunder the lowercase site).prisma/migrations/20260912120000_lowercase_coupon_sites/migration.sql:UPDATE "public"."coupons" SET site = lower(site) WHERE site IS NOT NULL AND site <> lower(site);— no schema change;updated_atdeliberately NOT bumped (the ingest only-if-newer rule keys on it).coupons_site_idx);listStoreCoupons/listCouponslowercase the incoming base before binding it (one-line guard, pinned by asserting the bound values intests/unit/couponsRepo.test.ts).e2e/seo-a11y.spec.ts"Coupon pages — crawler-visible SEO" gains (a) every store<loc>in/sitemap.xmlequals its ownresolveStoreDomainand is lowercase, and there are no duplicate<loc>s (imports../src/lib/storeDomainrelatively — pure, tldts only, no prisma, no@/alias, so collection survives the deployed context); (b)/supportis in the sitemap.e2e/seo-regression.spec.ts:{ path: '/support', minVisibleChars: 380 }added toROUTES(547 visible chars measured on prod, −30%), and the sitemap test now asserts/sourcesconditionally against/api/sources(present iff ≥1 active source) instead of unconditionally, since the deployed site may legitimately have 0.Migration blast radius
UPDATE coupons SET site = lower(site) WHERE site IS NOT NULL AND site <> lower(site)touches only rows whosesitecarries uppercase — 2 known sites on prod as of 2026-09-11 (Brooklinen.com,eNasco.com), i.e. their coupon rows. Idempotent; a re-run is a no-op. No DDL, so CI's schema-drift job is unaffected — verified locally with the CI-exact commands (below). It applies cleanly to an empty DB (prisma migrate deployon a fresh compose Postgres, then bothmigrate diffvariants → "No difference detected").Dry-run numbers
From the served sitemap on 2026-09-11 (the DB's DISTINCT visible sites): 4,311 raw slugs → 4,262 canonical entries after this PR (−49 duplicates, −1 non-domain; the +38 previously-missing canonical targets are already counted inside the 4,262), and the 2 mixed-case slugs become reachable after the migration. Prod DB read access was unavailable for a direct dry-run against live rows — the numbers are derived from the served sitemap, not from a query against prod.
Verification done locally (worktree
C:\wt-caramel-index-policy, freshpnpm install --frozen-lockfile, compose Postgres on :58005 brought up for this run)pnpm turbo run lint --forceno-consolewarnings inapps/caramel-extensionfiles untouched here). Note:pnpm lint -- --forceforwards--forceinto the extension's eslint and exits 2 — that is the invocation, not the code.pnpm lint:oxlintno-underscore-danglewarnings in extension files, untouched)pnpm turbo run prettier-check --forcepnpm --filter caramel-app knippnpm -r run type-checkArray.from)pnpm --filter caramel-app test(vitest unit)[vitest-pool] Failed to start forks worker … Timeout waiting for worker to respondon jsdom.tsxfiles (support-form, recently-added-section, coupon-card, coupons-section, faq-section, error — none touched by this PR; transform alone took 325 s on a saturated box). Those 6 files run alone: "Test Files 6 passed (6) / Tests 23 passed (23)", exit 0. An earlier full run on the same tree reported "78 passed (78) / 749 passed (749)". The 8 touched/new unit files run together: 8 passed / 67 tests. Flagged verbatim rather than hidden; CI's runner should see the clean exit.pnpm --filter caramel-app exec vitest run --config vitest.integration.config.ts(real pg :58005, afterprisma migrate deploy)listStoreSitemapEntriesand lowercase-site casesprisma migrate deploy(fresh DB, all 9 migrations incl.20260912120000_lowercase_coupon_sites)prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --shadow-database-url … --exit-code(CI-exact)prisma migrate diff --from-migrations prisma/migrations --to-url $DATABASE_URL --shadow-database-url … --exit-code(CI-exact)pnpm --filter caramel-app exec prisma validatee2e/seo-a11y.spec.ts+e2e/seo-regression.spec.tsagainst this branch's ownnext devon :58271 (compose pg :58005, migrated + seeded;PLAYWRIGHT_START_SERVER=false,--timeout=120000)read ECONNRESET(dev server dropped the socket under load), then the 2 re-run alone: "2 passed", exit 0 — so 27/27 green including the new "every store<loc>is its own canonical base" and "/support is listed" tests and the conditional/sourcesassertion. Direct curl on the same server: sitemap = 7 static (incl./support,/sourcesbecause the seed has 2 ACTIVE sources) + 5 canonical store<loc>s, 5<lastmod>(e.g.<loc>…/coupons/codecademy.com</loc><lastmod>2026-07-13T07:45:00.000Z</lastmod>), 0 duplicate / 0 uppercase<loc>;/coupons/CodeCademy.com→ canonical…/coupons/codecademy.com, "currently lists 5 active";/coupons/no-coupons-here-zz.example→noindex, follow; robots non-prod branch. Caveat: the first attempt reused a stranger'snext startalready on :58000 (another worktree,reuseExistingServer) and is not evidence either way; the rest of the e2e suite was not run locally.What CI covers
checks-app.yml: lint / oxlint / prettier / knip / type-check / unit; the schema-drift job (bothmigrate diffvariants + "Apply migrations" on an empty DB); the integration job (real Postgres);e2e-pr(hermetic seeded DB — exercises the new sitemap/canonical//supportassertions and, because the seed has 2 ACTIVE sources, the/sources-present branch) ande2e-push(deployed dev site — exercises the/sources-absent branch if dev has 0 active sources). Note fore2e-push: the new "every store<loc>is its own canonical" assertion only holds once the deployed dev site is running this code (it fails by design against the current sitemap).Owner questions
<store>coupon code", and counts move daily — raising the bar would churn thousands of URLs in and out of the sitemap as codes expire and reappear, worse for crawl budget than a few thin-but-true pages. The threshold is one number inevaluateStorePageIndexabilityif you want it higher.<lastmod>values give it a reason to keep coming back./sourceswill drop out of the sitemap and carrynoindexon prod until at least one source is ACTIVE — intended; flip a source to ACTIVE and both revert automatically.