feat(seo): A-Z store directory and neighbour links so no store page is orphaned - #238
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.
Stacked on #236 (
seo/index-policy) — merges after it. Base branch of this PR isseo/index-policy; once #236 lands, retarget todev.Why
grabcaramel.com has ~4,262 canonical
/coupons/<store>pages (after #236) and only 8 of them have any internal inbound link. The only crawlable links into store pages are the 4-item "Popular coupon stores" block (popular-stores.tsx— the same 4 stores on every page) and 8 tiles on/supported-stores(whose grid is otherwise a client-side search). GSC: 4,289 of 4,317 sitemap URLs are unknown to Google, and the sitemap was last read 2025-07-12. Orphan pages do not get crawled or ranked; the sitemap alone is not a discovery mechanism Google trusts here (caramel-artifact/seo-2026-09-11: STATE.md §P0, audit-findings.md §Interlinking, route-inventory.md inbound-link column).This PR gives every indexable store page crawlable inbound links, without thin pages.
What
/coupons/stores(letters with real store counts) and/coupons/stores/[letter](a–z+0-9): server-rendered<a href="/coupons/<base>">for every indexable store, with its live coupon count ("michaels.com · 12 codes"). Both read the SAME data the sitemap emits —listStoreSitemapEntries→collapseStoreRows(fix(seo): canonical store sitemap with lastmod behind one indexability policy #236) — so the directory and the sitemap can never disagree about which stores exist or which are indexable. No second SQL query: a rawsitefirst-character filter would splitathleta.gap.comaway fromgap.com(i.e. re-implement the PSL collapse in SQL). Instead the collapsed list is held in a 5-minute in-process cache (storeDirectoryCache.ts, same shape as fix(ops): stop healthcheck-flap outages — liveness probe, cached supported-stores, edge cache #223'ssupportedStoresCache.ts: in-flight de-dupe, stale-on-error) because the letter strip now sits on/coupons. A letter with zero stores isnotFound(), never an empty page. Metadata:Stores starting with M — coupon codes | Caramel/All stores with coupon codes A–Z | Caramel, self canonical, explicitindex, follow. Sitemap lists the index (weekly, 0.8) + only the letters that have stores (weekly, 0.6).listNeighbourStoreRows(base, n): two indexed range scanssite < $base ORDER BY site DESC LIMIT/site > $base ORDER BY site ASC LIMITover the same visible-coupon aggregate the sitemap reads, over-fetched 3× and passed throughcollapseStoreRowsso subdomain/mixed-case slugs fold and the current base is excluded) plus links to/coupons/stores/<letter>and/coupons/stores.PopularStoresstays. Only the JSX body of[store]/page.tsxis touched (notgenerateMetadata, which fix(copy): truthful catalog figures and privacy/Honey wording (claim integrity) #233/fix(seo): canonical store sitemap with lastmod behind one indexability policy #236 edit)./couponsand/supported-stores(outside the client search component, links only letters that have stores), a one-line "Store directory" footer link (Footer.tsx— one line, to ease the fix(seo): AI-crawler allow-list, cf-visitor https redirect, HSTS preload, llms-full.txt #234 rebase), and a one-line llms.txt "Key pages" entry.coupon-card.tsxdoes not render the store domain at all (the domain only appears as the store page's own sidebar title), so there was nothing to turn into a link — left untouched,coupon-card.test.tsxstill green.Link graph after this PR
Every indexable store is reachable in ≤3 clicks from home: footer "Store directory" →
/coupons/stores→/coupons/stores/<letter>→/coupons/<store>(also/couponsand/supported-stores→ letter strip → letter → store). On top of that, every store page links its 10 nearest neighbours and its letter page, so the store pages form one connected alphabetical crawl chain instead of ~4,262 sitemap-only islands. Per store page: 4 (popular) + up to 10 (neighbours) + 2 (directory) crawlable store/directory links, all in the raw HTML.Letter distribution (measured)
Prod's
/sitemap.xmlwas returning 404 at measurement time (2026-09-12 ~08:00 EDT — see "Observed" below), so the distribution was measured on dev.grabcaramel.com/sitemap.xml (4,124 store slugs, pre-#236 raw slugs):Every letter has stores (x has 6, q 18); the biggest bucket is b at 425, then c 400, s 367, a 313. No letter paginates today, but
bis at 85% of the cap and prod carries ~140 more bases than dev, so?page=Nsplitting is wired now (DIRECTORY_PAGE_SIZE = 500): page 1 is the un-paged URL; pages ≥2 carry?page=N,rel=prev/next, canonical to page 1,noindex, follow, and are absent from the sitemap. Out-of-range or malformed?page=is a 404.Largest letter page — HTML size
Measured locally with 501 synthetic
b…stores (one over the cap, so page 2 exists) against a production build (next build && next start):/coupons/stores/b(500 stores) = 172 KB raw (26.6 KB gzip over the wire) total HTML — page shell alone is 49 KB (/coupons/storesindex), so a store row costs ~0.25 KB; for reference the existing store page/coupons/ebay.comis 102 KB raw / 23 KB gzip on the same build. This is above the ~150 KB soft target;DIRECTORY_PAGE_SIZE = 500was kept on purpose because dropping to 400 would already splitb(425) into a 400-store page plus a 25-storenoindexpage 2 — one indexable page carrying all 425 links is the better crawl outcome, and 27 KB compressed is what a crawler actually fetches. Easy to lower later (one constant + one test fixture). The list<ul>itself is 37.9 KB; the rest is the page shell + the RSC payload twin of the list. Per-item markup is deliberately bare (<li><a href>base</a> · N codes</li>, styling via parent child-selectors, plain<a>notnext/linkso 500 dynamic routes are not prefetched per view).Verification
Gates (worktree
C:\wt-caramel-directory, freshpnpm install --frozen-lockfile+db:generate):pnpm -r run type-check— caramel-ui, caramel-app, caramel-extension: Done (0 errors)pnpm --filter caramel-app test— Test Files 86 passed (86), Tests 803 passed (803)pnpm lint— Tasks: 2 successful, 2 total (only the 3 pre-existingno-consolewarnings in caramel-extension)pnpm lint:oxlint— exit 0; no warnings in files added here (the two remaining hits under touched paths —sitemapStores.ts:90 no-array-sort,coupon-filters.tsx:20— are pre-existing)pnpm prettier-check— All matched files use Prettier code style! (both packages)pnpm --filter caramel-app knip— cleanpnpm --filter caramel-app exec prisma validate— The schema at prisma\schema.prisma is validtest:integration tests/integration/coupons-read.itest.tsagainst the compose Postgres (:58005, migrated + seeded) — 6 passed (6), including the newlistNeighbourStoreRowscase (ebay.com → before[codecademy.com, amazon.com], after[target.com, walmart.com]; edgeamazon.com→ empty before window)/coupons/stores200 / self canonical /index, follow/ 6 letter links;/coupons/stores/b200 / 500 store links /rel=next/ aria-current;/coupons/stores/b?page=2200 / canonical → page 1 /noindex, follow/ 1 store /rel=prev;/coupons/stores/b?page=3,/q,/zz→ 404;/coupons/b0250-dirtest.com→ "More stores" with exactly b0245–b0249 + b0251–b0255 and/coupons/stores/b; the subdomain fixtureus.b0001-dirtest.comfolded intob0001-dirtest.com(no self-link, no raw-slug link);/coupons+/supported-storescarry the strip; footer link present;/sitemap.xmllists/coupons/stores+ the 6 populated letters and no?page=URL;/llms.txtlists the directory.playwright test e2e/seo-a11y.spec.tsagainst the production build (PLAYWRIGHT_START_SERVER=false, noDATABASE_URL= the deployed-context mode): 18 passed, 1 failed — the failure is the pre-existing/support is listed in sitemap.xmlspec, which compares against the localbaseURL(:58021) while the sitemap emits.env'sBASE_URL(:58000); an artefact of the ad-hoc port, not of this PR (CI runs both on :58000). All four new directory specs and the amended canonical-<loc>spec passed.Tests added:
storeDirectory.test.ts(bucketing, param parsing, page splitting, neighbour collapse),storeDirectoryCache.test.ts(TTL, stampede de-dupe, stale-on-error),couponsRepo.test.ts(neighbour SQL shape + bound params, mocked-prisma style),coupons-store-directory.test.ts(index/letter metadata,notFound()on empty/invalid letter and bad?page=, page-2 noindex+canonical, strip and neighbours components),sitemap.test.ts(directory entries; store-URL assertions now exclude/coupons/stores),coupons-read.itest.ts(real-pg neighbour windows),e2e/seo-a11y.spec.ts(index 200 + canonical + ≥1 letter link; letter page discovered from the index has store links that are their own canonical base and show counts; store page discovered from a letter page has "More stores" with ≥1 neighbour and links back to the letter;/coupons/stores/zzis 404) — all deployment-safe (URLs discovered from served HTML, noDATABASE_URLgate, no@prisma/clientimport).What CI covers
checks-app.ymlvalidate: type-check, oxlint, eslint, prettier, knip, unit suite (all of the above).coupons-read.itest.tsagainst the CI Postgres — the new neighbour case runs there.E2E & Visual Regression (PR)runsseo-a11y.spec.tsagainst the hermetic seeded app (5 stores → letters a/c/e/t/w);(push)runs it against the deployed site with the real catalog — the new specs assert only what holds in both.Observed while measuring (not caused by this PR)
https://grabcaramel.com/couponsandhttps://grabcaramel.com/sitemap.xmlreturned 404Not Found(text/plain) at ~08:00 EDT 2026-09-12 with a Chrome UA, while/api/health/dbwasok(buildRev4da723b) and/robots.txtwas 200./api/coupons/filtersalso 404'd (Next's HTML 404). dev.grabcaramel.com served its sitemap normally. Worth a look before/independent of merging.Owner questions
/coupons/storespriority 0.8 and letters 0.6 in the sitemap — fine, or should letters sit at 0.5 below store pages (0.7)?Do not merge — stacked on #236.