From beb4ae78332ed95b147e089faacb3f7546bde983 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 23 Oct 2026 17:04:57 +0200 Subject: [PATCH 01/12] docs(seo): add spec 0018 and the storefront SEO scope Co-Authored-By: Claude Opus 5.5 --- docs/scope/scope.md | 15 +- docs/specs/0018-storefront-seo/index.md | 180 ++++++++++++++++++++ docs/specs/0018-storefront-seo/rationale.md | 58 +++++++ 3 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 docs/specs/0018-storefront-seo/index.md create mode 100644 docs/specs/0018-storefront-seo/rationale.md diff --git a/docs/scope/scope.md b/docs/scope/scope.md index c00fd9d..d96931e 100644 --- a/docs/scope/scope.md +++ b/docs/scope/scope.md @@ -31,7 +31,7 @@ _These are recommendations to keep your build orderly, not requirements. Skip an | 15 | Admin sales dashboard | Slice 9 | done | | 23 | Native controls in GET forms | Slice 9 follow up | done | | 16 | Legal pages & cookie consent | Launch | done | -| 17 | SEO for storefront pages | Launch | planned | +| 17 | SEO for storefront pages | Launch | in-progress | | 18 | Analytics & error tracking | Launch | planned | | 19 | Production deploy | Launch | planned | @@ -324,10 +324,18 @@ spec [0017](../specs/0017-legal-pages-cookie-consent/index.md) · code in [src/l - [x] Verify it: `/check verify legal pages & cookie consent` - [x] Test it: `/test legal pages & cookie consent` -### 17. SEO for storefront pages +### 17. SEO for storefront pages · in-progress Make products findable: titles, descriptions, canonical links, sitemap, product rich results, and social share cards on every public page. **Done when:** every product and category page has unique metadata and structured product data, the sitemap lists all visible products, hidden products and admin pages are never indexed. -- [ ] Build it: `/develop SEO for storefront pages` +spec [0018](../specs/0018-storefront-seo/index.md) · code in [src/lib/seo/](../../src/lib/seo/), [src/features/catalog/seo.ts](../../src/features/catalog/seo.ts), [app/sitemap.ts](../../app/sitemap.ts), [app/robots.ts](../../app/robots.ts) +- [x] Design it (spec): `/architect SEO for storefront pages` +- [x] Build it: `/develop SEO for storefront pages` + - [x] Thin thread: `metadataBase`, the shared metadata helper, full product metadata and Product JSON-LD with Offers and shipping, a products only sitemap, robots with the production rule (AC-1, AC-2, AC-5 to AC-11, AC-20) + - [x] Brand card, logo and icon; home, legal and search pages on the helper; Organization and WebSite JSON-LD (AC-3, AC-4, AC-15, AC-17) + - [x] Listing canonicals and paged titles, category descriptions and BreadcrumbList (AC-12 to AC-14, AC-16) + - [x] Full sitemap (home, legal, categories, images) and the hidden and private page checks (AC-18, AC-19, AC-21, AC-22) +- [ ] Verify it: `/check verify SEO for storefront pages` +- [ ] Test it: `/test SEO for storefront pages` ### 18. Analytics & error tracking · needs a decision See how customers move through the store and know when something breaks. @@ -351,6 +359,7 @@ Out of scope for the current build pass, kept so the plan stays honest. - **Several languages & currencies**: selling abroad · needs a decision - **Product reviews**: customer ratings on product pages · needs a decision - **Wishlist**: save products for later · needs a decision +- **Return window and delivery time as data**: real values so the product Offer can carry `hasMerchantReturnPolicy` and `deliveryTime` · from spec 0018 - **Dark mode**: a dark token set with its own contrast pass and a theme switch; the semantic tokens rule keeps it a token change · needs a decision · from spec 0003 - **Sweep abandoned product image uploads**: delete files in `product-images` that no image row and no order line references, older than a day, if storage cost grows · from spec 0009 - **Storefront listing in SQL**: move search, filters and counts from the cached in memory index to SQL per request (`pg_trgm`, `unaccent`) once the catalog passes about 2,000 active products or the index nears the cache entry limit · needs a decision · from spec 0013 diff --git a/docs/specs/0018-storefront-seo/index.md b/docs/specs/0018-storefront-seo/index.md new file mode 100644 index 0000000..a820e1b --- /dev/null +++ b/docs/specs/0018-storefront-seo/index.md @@ -0,0 +1,180 @@ +# 0018. Storefront SEO + +**Date**: 2026-10-23 +**Status**: In Progress + +## Summary + +Every public storefront page gets complete search and share metadata: a unique title and description, an absolute canonical link, and an Open Graph share card (the preview a link shows in chats and social apps). Product pages also get Product structured data (JSON-LD, a machine readable block search engines read), with one Offer per variant, the "Was" price as a strikethrough price, and the delivery fee. The store also gets a `robots.txt` that blocks all crawling outside production, a sitemap of every public page, a generated brand share card and logo, and a canonical rule for paged and filtered listings. Nothing in the database changes. + +## Requirements + +**User stories**: +- As a shopper searching the web, I want a product's result to show its real price, availability and sale, so I click through to something I can buy. +- As a shopper sharing a link, I want it to show the product photo (or the store card) and a clear title, so the person I send it to knows what it is. +- As the store owner, I want every public product and category found and nothing private or hidden indexed, so search traffic lands on pages that sell. +- As the developer, I want preview deploys kept out of search engines without remembering to do anything, so a test catalog never competes with the real store. + +**Acceptance criteria** (the contract, each independently checkable): + +*Site wide* +- **AC-1**: The root layout sets `metadataBase` to `NEXT_PUBLIC_SITE_URL`, so every canonical, Open Graph and sitemap URL is absolute on the site's own origin (never `localhost` in a deployed build). +- **AC-2**: Every indexable storefront page (home, category, product, the five legal pages) and `/search` declares its metadata through one shared helper, `pageMetadata` (it replaces `legalMetadata` and the category page's hand written metadata, no duplicates): `title`, `description` (when there is one), `alternates.canonical` (absolute; not on `/search`), and `openGraph` with `type: "website"`, `siteName` (brand name), `url` (equal to the canonical), `title`, `description`, and `images` always set explicitly (url, width, height, alt), plus `twitter.card: "summary_large_image"` and `twitter.images` set to the same image. `og:locale` is `STORE_LOCALE` written with an underscore (`pl_PL`) when the tag has a region, and omitted when it is a bare language (`en`). +- **AC-3**: A generated brand share card is served at the fixed path `/brand-card.png` (a route handler returning next/og `ImageResponse`, 1200x630 PNG, prerendered and cached since it reads no request data): the brand name in next/og's default font, on `brand.colors.background` in `brand.colors.foreground`. It is the image of the home page, every category page, every legal page, `/search` and any product with no photo, with alt text the brand name. `brand.colors` is a new field in `src/lib/brand/brand.ts` holding sRGB hex values, because next/og cannot read the `oklch` CSS tokens; a comment there says a rebrand updates them with the tokens. +- **AC-4**: A generated logo is served at the fixed path `/logo.png` (route handler, next/og, 512x512 PNG: the first letter of `brand.name`, uppercased, in `brand.colors.foreground` on `brand.colors.background`). `app/icon.tsx` renders the same image as the site's tab icon. `favicon.ico` stays. + +*Product pages* +- **AC-5**: A product page's title is `meta_title`, else the product name. Its description is `meta_description`, else the plain text of the Markdown description (AC-6), else none (the site default shows). Its canonical is `NEXT_PUBLIC_SITE_URL` plus `/products/`. Its `og:image` is the first gallery image by position (absolute Supabase public URL) with that image's alt text (the product name when the alt is empty) and its width and height when known; no image → the brand card (AC-3). +- **AC-6**: The description fallback is the Markdown rendered to plain text (no syntax, no HTML), whitespace collapsed to single spaces, cut at the last word boundary at or under 160 characters with `…` appended when cut. An empty result means no description. +- **AC-7**: A product page renders one `` can never close the tag. + +*Listings* +- **AC-12**: Home and category pages declare their canonical by one pure rule on the parsed `ListingState` (from `parseListingParams`), never on raw params. When the state differs from the default state in anything but `page` (any price bound, `inStock`, `onSale`, any option filter, or a sort other than `defaultSort(kind)`), the canonical is the plain path. Otherwise it is the plain path for page 1 and `?page=N` for page N, where N is the page actually shown: the parsed page clamped to the page count, computed by one function exported from `listing.ts` that `buildListing` also uses, so the two cannot drift. So `?page=0`, `?page=abc`, `?page=1`, `?sort=featured`, `?stock=0` and unknown params (`?utm_source=x`) all give the plain path. +- **AC-13**: For page N above 1 (the unfiltered case of AC-12), the title gains `, page N` (e.g. `Shoes, page 2 · BeStore`); filtered views keep the plain title. No search query or filter text ever appears in a title or any metadata (spec 0013 AC-24 still holds). +- **AC-14**: A category's title is `meta_title`, else its name (unchanged). Its description is `meta_description`, else its description cut by the AC-6 rule. +- **AC-15**: The home page renders JSON-LD, in its static shell, for an `Organization` (`name`, `url` the site URL, `logo` the absolute `/logo.png` URL) and a `WebSite` (`name`, `url`), with no `SearchAction`. +- **AC-16**: Each category page renders JSON-LD for a `BreadcrumbList` with two items: the brand name linking the site URL, then the category name (from `getCategoryBySlug`) linking the category canonical (plain path). The script mounts inside `CategoryHeader` after its not found check, so a hidden or unknown category renders none. +- **AC-17**: `/search` stays `noindex, follow` with no canonical, no JSON-LD and the brand card as its share image. + +*Sitemap and robots* +- **AC-18**: `/sitemap.xml` lists, with absolute URLs: the home page (no `lastModified`); the five legal pages from `legalDocuments` (`lastModified` each document's `updated`, passed as its `YYYY-MM-DD` string); every visible category (`lastModified` its `updated_at`); every `active` product with at least one live variant (`lastModified` its `updated_at`, plus `images` holding its gallery image URLs). It lists nothing else: no draft, archived or variant less product, no hidden category, no paged or filtered URL, no account, cart, checkout, search, admin or auth path. +- **AC-19**: The sitemap reads through `'use cache'` functions (one in `src/features/catalog/`, one in `src/features/categories/`, composed by `app/sitemap.ts`) tagged `catalog` and `categories` and returning dates as ISO strings, so a product or category write that already expires those tags (spec 0009, spec 0013 AC-22) shows in the sitemap on the next request, with no deploy. +- **AC-20**: When `VERCEL_ENV` is `production`, `/robots.txt` allows `/`, disallows `/admin`, `/api` and `/auth`, and names `/sitemap.xml`. In every other environment (preview, development, local, CI) it is `User-agent: *` / `Disallow: /` with no sitemap line. The rule is a pure `robotsRules(vercelEnv, siteUrl)` with unit tests for both branches (e2e only ever sees the blocking branch, since CI has no `VERCEL_ENV`). `robots.ts` prerenders at build, which is right because Vercel builds each production deployment with `VERCEL_ENV=production`. + +*Hidden and private pages* +- **AC-21**: A draft, archived or variant less product URL, and a hidden or unknown category URL, render the not found page with `` in the HTML (even though Cache Components answers 200), and emit no product or breadcrumb JSON-LD and no product metadata. This holds both for a slug that was missing at build (the prerendered `__none__` style miss) and one that disappears later (a product unpublished after build, a request time miss). +- **AC-22**: The cart, checkout, checkout complete, every account page and every admin and auth page keep `noindex` (they all do today); this feature adds no canonical, JSON-LD or sitemap entry to any of them. + +## Decision + +**Chosen option**: Option 1: Next.js metadata files plus one shared metadata helper and pure JSON-LD builders + +Use Next's built in metadata conventions (`generateMetadata`, `sitemap.ts`, `robots.ts`, `icon.tsx`) plus two fixed path image routes, driven by one helper in `src/lib/seo/` and pure functions that build each JSON-LD object from the existing cached reads, with no new service and no schema change. + +**Implementation skills**: `vercel:next-cache-components` (Vercel plugin) · `vercel:nextjs` (Vercel plugin) · `playwright-best-practices` (`.agents/skills/playwright-best-practices/`) + +**Calls made in this spec** (pick, why, runner up): +- **One Product with one Offer per variant, not ProductGroup.** Eligible for merchant listings with per variant price and availability, and needs no per variant URL (the product page has no `?variant=` param, and adding one would make the page read search params). Runner up: ProductGroup with `hasVariant`. +- **Low stock counts as `InStock`.** schema.org `LimitedAvailability` means "limited run", not "few left"; and the AC-8 rule keeps counts off the page (spec 0005 AC-7). Runner up: `LimitedAvailability` for the low state. +- **Shipping rate per offer from the variant price against the threshold.** Exact for a one item order, the case a search result describes, with no new data. Runner up: always the flat fee (wrong for items above the threshold). +- **No `deliveryTime` and no return policy in the Offer.** Neither has a source in the data today (the 14 day window lives only in the legal Markdown); inventing either in code risks contradicting the legal text. Runner up: hardcode 14 days. +- **Markdown to plain text with `mdast-util-from-markdown` plus `mdast-util-to-string`, added as direct dependencies.** Both already ship inside `react-markdown`'s tree, so nothing new runs; a regex strip breaks on links, emphasis and code. Runner up: regex. +- **JSON-LD typed with `schema-dts` (dev dependency, types only).** Catches a misspelled property at compile time with zero runtime cost. Runner up: hand written readonly types. +- **Canonical uses the clamped page.** `?page=99` on a 3 page listing shows page 3, so it says page 3; anything else declares a canonical for content it doesn't show. The clamp needs the in scope product count, from the same cached `getListingIndex()` read the page already makes. Runner up: the raw page param. +- **`, page N` in the title for N above 1.** Self canonical pages need distinct titles, or the "unique metadata" bar fails on paged listings. Runner up: identical titles. +- **robots disallows only `/admin`, `/api`, `/auth`.** Cart, checkout, account and search must stay crawlable so bots can read their `noindex`; a disallowed URL can still be indexed from links, without its noindex ever being seen. +- **Brand card and logo generated, not static files.** No asset exists; generating from `brand.ts` means a rebrand updates them. Runner up: a supplied PNG. +- **Fixed path route handlers, not the `opengraph-image` file convention.** The file convention gives a hashed URL that pages and the Organization logo can't name, and a child page's own `openGraph` can drop the inherited image (Next merges metadata objects shallowly). A fixed URL set explicitly on every page is deterministic and testable. Runner up: the file convention plus an e2e guard. +- **next/og's default font.** The `next/font` fonts in `fonts.ts` can't be loaded as raw font data by next/og without bundling the files a second time. Runner up: ship the font files for next/og. + +## Feature design + +**Data model sketch**: no change. Reads only existing columns: `products` (`name`, `slug`, `description`, `status`, `meta_title`, `meta_description`, `updated_at`), `product_variants` (`sku`, `price_cents`, `compare_at_price_cents`, `stock_quantity` turned into availability, `archived`, `position`), `product_images` (`storage_path`, `alt_text`, `width`, `height`, `position`), `categories` (`name`, `slug`, `description`, `visible`, `meta_title`, `meta_description`, `updated_at`), `store_settings` (`flat_shipping_cents`, `free_shipping_threshold_cents`), and the legal documents' front matter. + +**State transitions**: none. + +**API surface** (all public, all GET, no auth): +| Endpoint / function | Kind | Key inputs | Key outputs | Auth | Key errors | +|---|---|---|---|---|---| +| `/sitemap.xml` (`app/sitemap.ts`) | metadata route | none | XML urlset (AC-18) | public | a DB failure fails the render (no stale partial list) | +| `/robots.txt` (`app/robots.ts`) | metadata route | `VERCEL_ENV` | robots rules (AC-20) | public | none | +| `/brand-card.png` (`app/brand-card.png/route.tsx`) | GET route handler, `ImageResponse` | `brand.name`, `brand.colors` | 1200x630 PNG | public | none (prerendered) | +| `/logo.png` (`app/logo.png/route.tsx`) | GET route handler, `ImageResponse` | `brand.name`, `brand.colors` | 512x512 PNG | public | none (prerendered) | +| `app/icon.tsx` | metadata image | the same renderer as `/logo.png` | tab icon | public | none | +| `pageMetadata({ title, description, path, image? })` in `src/lib/seo/metadata.ts` | pure helper | page values | `Metadata` (AC-2) | n/a | none | +| `productJsonLd(view, settings, facts)` in `src/features/catalog/seo.ts` | pure | `ProductView`, `ShippingSettings`, currency, country, site URL | `WithContext` | n/a | none | +| `listingCanonical(path, state, kind, pageCount)` in `src/features/catalog/listing/` | pure | parsed `ListingState`, page count | absolute URL + page number for the title | n/a | none | +| `plainExcerpt(markdown, max)` in `src/lib/seo/text.ts` | pure | Markdown, limit | string or null | n/a | none | +| `jsonLdScript(data)` component in `src/lib/seo/` | server component | object | escaped `` renders valid, inert JSON-LD, verifies **AC-11** +- Failure case: `?page=99` on a 3 page category canonicalizes to `?page=3`; `?color=red&page=2` and `?sort=price-asc` canonicalize to the plain path; `?utm_source=x` is ignored, verifies **AC-12**, **AC-13** +- Hidden: a draft product URL shows the not found page with `noindex` and no JSON-LD, and is absent from the sitemap, verifies **AC-18**, **AC-21** +- Environment: `robotsRules` with `VERCEL_ENV` unset or `preview` is `Disallow: /`; with `production` it lists the sitemap and the three disallows (unit test); the e2e build serves the blocking branch, verifies **AC-20** +- Test note: e2e checks of the head on home and category pages send a Googlebot user agent (Next then blocks on metadata and puts it in the first chunk), or wait for the streamed tags, so they never flake on streaming + +## Build plan + +Tracer Bullet: the first task proves one product all the way from the database to the HTML a crawler reads, the sitemap and robots; the rest thicken it. + +1. Thin thread: `metadataBase` in the root layout; `src/lib/seo/` with `pageMetadata`, `plainExcerpt` and `jsonLdScript`; extend `getProductBySlug` with `metaTitle`, `metaDescription` and `sku`; the product page's full metadata and its Product JSON-LD with Offers (price, currency, availability, condition, url, strikethrough); `app/sitemap.ts` listing products only through a cached read; `app/robots.ts` with the production rule and the block everywhere else. Unit tests for the pure builders and the robots rule, one e2e reading a product page's head, `/sitemap.xml` and `/robots.txt`, satisfies **AC-1**, **AC-2**, **AC-5** to **AC-9**, **AC-11**, **AC-20** +2. Shipping details in each Offer from the cached settings read, with tests for under, at and above the threshold and for no threshold, satisfies **AC-10** +3. Brand card and logo: `brand.colors` in `brand.ts`, the `/brand-card.png` and `/logo.png` route handlers, `app/icon.tsx`; the home, legal and search pages move onto `pageMetadata` (`legalMetadata` removed); a product without photos uses the card; the home page's Organization and WebSite JSON-LD; `/search` keeps its noindex and gets the card, satisfies **AC-3**, **AC-4**, **AC-15**, **AC-17** +4. Listing canonicals: export the page count function from `listing.ts` and use it in `buildListing`; the pure `listingCanonical` with unit tests for every AC-12 case; the category page moves onto `pageMetadata`; home and category `generateMetadata` read `searchParams` and the cached index for the page count; the `, page N` title; category descriptions through `plainExcerpt`; the category BreadcrumbList, satisfies **AC-12**, **AC-13**, **AC-14**, **AC-16** +5. Complete the sitemap: home, legal pages, visible categories, product images; a `test:db` test that drafts, archived products, variant less products and hidden categories are left out; an e2e that a product write and a category write show on the next request, satisfies **AC-18**, **AC-19** +6. Hidden and private pages: e2e that a draft, an archived and an unknown product (one prerendered miss, one product unpublished after build), and a hidden category, render `noindex` with no JSON-LD; a check that the cart, checkout, account, admin and auth pages are still `noindex` and absent from the sitemap. Run Google's Rich Results Test by hand on one product page of a preview build, satisfies **AC-21**, **AC-22** + +## Consequences + +**Positive**: +- Products become eligible for price, availability and sale rich results, and every share shows a real image. +- Preview deploys and local builds can never be crawled, whatever domain they sit on. +- The sitemap follows the catalog with no deploy, through tags that writes already expire. + +**Negative / tradeoffs**: +- The home and category metadata now render at request time (they read `searchParams`), so for normal browsers their head streams in after the shell; crawlers still get it in the head (Next blocks metadata for known bots). +- `updated_at` on a product does not move when only a variant or image changes, so the sitemap's `lastModified` can lag a price change. Search engines treat it as a hint; accepted. +- No return policy or delivery time in the Offer, so Google's merchant listing report will show "missing field" warnings until those have real sources. +- Two direct dependencies (`mdast-util-from-markdown`, `mdast-util-to-string`) and one dev dependency (`schema-dts`) join `package.json`. + +**Neutral**: +- Spec 0013 AC-24's "canonical of their plain path, without any query" is narrowed by AC-12 here for unfiltered paged views. +- Organization `sameAs` (social profiles) and GTINs are left out: neither exists in the data. + +## Follow-up + +- [ ] Spec 0013 AC-24 is partly superseded by AC-12 (paged canonicals); `/sync` can note it in `src/features/catalog/AGENTS.md`. +- [ ] Add a return window and a delivery time as real settings (or brand constants checked against the legal text), then add `hasMerchantReturnPolicy` and `deliveryTime` to the Offer. +- [ ] Before launch, confirm the compare at price shown and marked up as StrikethroughPrice is the lowest price of the previous 30 days (EU Omnibus rule); see `rationale.md`. +- [ ] Feature 19 (production deploy): set `NEXT_PUBLIC_SITE_URL` to the real domain, then submit `/sitemap.xml` in Google Search Console. +- [ ] `vercel:next-cache-components` and `vercel:nextjs` are Vercel plugin skills not listed in root `AGENTS.md` `## Agent skills`; they belong there if they keep shaping builds. + +## Rationale + +Reasoning and options: see [rationale.md](rationale.md). diff --git a/docs/specs/0018-storefront-seo/rationale.md b/docs/specs/0018-storefront-seo/rationale.md new file mode 100644 index 0000000..b851472 --- /dev/null +++ b/docs/specs/0018-storefront-seo/rationale.md @@ -0,0 +1,58 @@ +# 0018. Storefront SEO: rationale + +The decision record behind [index.md](index.md). `/develop` builds from `index.md` and skips this file. + +## Context + +> ⚠️ Premise note: the StrikethroughPrice markup repeats the "Was" price to search engines, where it shows next to the price in results. Under the EU Omnibus rule (in force in Poland), a shown earlier price must be the lowest price of the previous 30 days, but `compare_at_price_cents` is free admin input with no history behind it. The markup does not create the risk (the product page already shows the "Was" price), it widens where the claim appears. The right framing is to keep the markup, which mirrors the page, and treat the 30 day rule as a pricing policy to confirm before launch (Follow-up). + +BeStore is a single brand shop selling in one country, built on Next.js 16 with Cache Components. Its catalog pages already render on the server and most of their shells prerender, so crawlers see real HTML. What is missing is everything that tells a search engine and a share preview what a page is: products have only a title, nothing has a share image, there is no sitemap or `robots.txt`, and there is no structured data. Spec 0013 already set titles and plain canonicals on home and category pages, made `/search` `noindex, follow`, and left open whether paged URLs canonicalize to themselves. + +The forces: the store must be findable at launch (scope feature 17); hidden products and private pages must never show up in search; preview deploys run against test catalogs on Vercel URLs and must not compete with the real store; and structured data must never contradict the page, because Google penalizes markup that disagrees with what the shopper sees. The catalog changes without deploys, so anything listing products must follow the cache tags writes already expire. Under Cache Components a page's `notFound()` answers 200, so a hidden product's URL relies on a `noindex` tag rather than a status code. + +The data has limits that shape the markup: variants share one URL (the picker has no URL param), the "Was" price is a free admin field, the delivery fee and free threshold live in `store_settings`, and there is no return window, delivery time, GTIN or logo in the data. Leaving this undecided means a launch with no rich results, text only share cards, and a real chance of preview catalogs being indexed. + +## Options considered + +### Option 1: Next.js metadata files plus one shared helper and pure JSON-LD builders + +Use `generateMetadata`, `sitemap.ts`, `robots.ts` and `icon.tsx`, plus two fixed path image routes (the brand card and the logo) built with next/og. One helper builds every page's metadata the same way; pure functions build JSON-LD from the cached reads the pages already make. + +**Pros**: +- Nothing new to run; every piece is a Next convention the build already prerenders and caches. +- Pure builders are trivial to unit test, and the helper keeps Open Graph consistent (Next replaces a child's `openGraph` object wholesale, so a helper avoids half filled cards). +- The sitemap follows the existing `catalog` and `categories` tags with no extra invalidation. + +**Cons**: +- Listing metadata reading `searchParams` defers to request time. +- Every page must set its share image explicitly, because Next merges metadata objects shallowly. + +### Option 2: A metadata library (such as `next-seo`) over the App Router + +Adopt a community package that wraps metadata and JSON-LD components. + +**Pros**: +- Ready made JSON-LD components for Product, Breadcrumb and Organization. + +**Cons**: +- The App Router's own metadata API now covers what these packages were built for; the package adds a layer that lags Next releases (this is Next 16 with breaking changes). +- Its components still need our data mapping, which is the real work. + +### Option 3: Static sitemap and robots generated at build + +A build script writes `public/sitemap.xml` and `public/robots.txt`. + +**Pros**: +- Dead simple files, easy to inspect. + +**Cons**: +- The sitemap goes stale on every catalog edit until the next deploy, which breaks the "no deploy" property the rest of the store holds. +- The environment rule has to be repeated in a script. + +## Rationale + +Option 1 wins on the force that matters most for a catalog managed without deploys: the sitemap and the structured data follow the same cached reads and tags as the pages, so they cannot drift from what shoppers see. It adds no service and no library beyond two small Markdown utilities and a types only package. Option 2 solves a problem the framework already solved, and Option 3 breaks freshness. + +Inside Option 1 the shape of the product markup follows the data. A ProductGroup would describe variants best, but Google expects a distinct URL per variant, and adding one would turn the product page into a request time page for a modest gain. One Product with one Offer per variant gives per variant price and availability, which is what rich results actually show. The strikethrough price mirrors the page exactly (the same rule as the "Was" label), and the shipping rate is computed per offer so a product above the free threshold is not shown with a fee it never pays. + +On canonicals, pointing page 2 at page 1 tells search engines that deeper pages are duplicates, which cuts the crawl path to products only reachable there; self canonical paged URLs fix that, while filtered and sorted views collapse to the plain path because their content is a subset in another order, not a page of their own. Blocking crawling by default everywhere but production makes the safe state the one that needs no action, with Vercel's own preview header as a second layer. From 9f79390f6caf7b053e734e87b68dc5d53337b15f Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 23 Oct 2026 17:05:05 +0200 Subject: [PATCH 02/12] feat(seo): add storefront metadata, structured data, sitemap and robots Co-Authored-By: Claude Opus 5.5 --- app/(store)/categories/[slug]/page.tsx | 31 +- app/(store)/cookies/page.tsx | 6 +- app/(store)/page.tsx | 27 +- app/(store)/privacy/page.tsx | 6 +- app/(store)/products/[slug]/page.tsx | 6 +- app/(store)/returns/page.tsx | 6 +- app/(store)/search/page.tsx | 13 +- app/(store)/shipping/page.tsx | 6 +- app/(store)/terms/page.tsx | 6 +- app/brand-card.png/route.tsx | 7 + app/icon.tsx | 10 + app/layout.tsx | 5 +- app/logo.png/route.tsx | 6 + app/robots.ts | 10 + app/sitemap.ts | 32 ++ package.json | 3 + pnpm-lock.yaml | 22 ++ .../catalog/components/product-details.tsx | 69 +++-- .../catalog/listing/canonical-query.ts | 20 ++ .../catalog/listing/canonical.test.ts | 65 ++++ src/features/catalog/listing/canonical.ts | 41 +++ src/features/catalog/listing/listing.ts | 41 ++- src/features/catalog/queries.ts | 46 ++- src/features/catalog/seo.test.ts | 212 +++++++++++++ src/features/catalog/seo.ts | 104 +++++++ .../categories/components/category-header.tsx | 8 + src/features/categories/queries.ts | 23 ++ src/features/legal/AGENTS.md | 4 +- .../legal/components/legal-page.test.ts | 7 +- src/features/legal/components/legal-page.tsx | 14 +- src/lib/brand/brand.test.ts | 20 ++ src/lib/brand/brand.ts | 5 + src/lib/seo/brand-images.tsx | 48 +++ src/lib/seo/json-ld.test.ts | 22 ++ src/lib/seo/json-ld.tsx | 21 ++ src/lib/seo/metadata.test.ts | 95 ++++++ src/lib/seo/metadata.ts | 92 ++++++ src/lib/seo/robots.test.ts | 22 ++ src/lib/seo/robots.ts | 14 + src/lib/seo/site.ts | 13 + src/lib/seo/structured-data.test.ts | 44 +++ src/lib/seo/structured-data.ts | 44 +++ src/lib/seo/text.test.ts | 58 ++++ src/lib/seo/text.ts | 52 ++++ tests/db/sitemap.db.test.ts | 82 +++++ tests/e2e/seo/seo.spec.ts | 287 ++++++++++++++++++ 46 files changed, 1684 insertions(+), 91 deletions(-) create mode 100644 app/brand-card.png/route.tsx create mode 100644 app/icon.tsx create mode 100644 app/logo.png/route.tsx create mode 100644 app/robots.ts create mode 100644 app/sitemap.ts create mode 100644 src/features/catalog/listing/canonical-query.ts create mode 100644 src/features/catalog/listing/canonical.test.ts create mode 100644 src/features/catalog/listing/canonical.ts create mode 100644 src/features/catalog/seo.test.ts create mode 100644 src/features/catalog/seo.ts create mode 100644 src/lib/brand/brand.test.ts create mode 100644 src/lib/seo/brand-images.tsx create mode 100644 src/lib/seo/json-ld.test.ts create mode 100644 src/lib/seo/json-ld.tsx create mode 100644 src/lib/seo/metadata.test.ts create mode 100644 src/lib/seo/metadata.ts create mode 100644 src/lib/seo/robots.test.ts create mode 100644 src/lib/seo/robots.ts create mode 100644 src/lib/seo/site.ts create mode 100644 src/lib/seo/structured-data.test.ts create mode 100644 src/lib/seo/structured-data.ts create mode 100644 src/lib/seo/text.test.ts create mode 100644 src/lib/seo/text.ts create mode 100644 tests/db/sitemap.db.test.ts create mode 100644 tests/e2e/seo/seo.spec.ts diff --git a/app/(store)/categories/[slug]/page.tsx b/app/(store)/categories/[slug]/page.tsx index 6c2464b..dcd4b8e 100644 --- a/app/(store)/categories/[slug]/page.tsx +++ b/app/(store)/categories/[slug]/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { Suspense } from "react"; import { storeContainer } from "@/components/layout/container"; +import { pagedTitle } from "@/features/catalog/listing/canonical"; +import { getListingCanonical } from "@/features/catalog/listing/canonical-query"; import { ListingSkeleton } from "@/features/catalog/listing/components/listing-skeleton"; import { ListingView } from "@/features/catalog/listing/components/listing-view"; import { @@ -10,7 +12,9 @@ import { } from "@/features/categories/components/category-header"; import { categoryPath } from "@/features/categories/paths"; import { getCategoryBySlug, getPrerenderedCategorySlugs } from "@/features/categories/queries"; -import { env } from "@/lib/env"; +import { pageMetadata } from "@/lib/seo/metadata"; +import { site } from "@/lib/seo/site"; +import { META_DESCRIPTION_MAX, plainExcerpt, presentText } from "@/lib/seo/text"; import { cn } from "@/lib/utils"; // Cannot match the slug pattern, so it renders the 404 page. Cache Components refuses an empty @@ -22,19 +26,30 @@ export async function generateStaticParams() { return slugs.length > 0 ? slugs.map((slug) => ({ slug })) : [{ slug: NO_CATEGORY_SLUG }]; } -// spec 0013, AC-24: never reads the search params, so filters never reach the metadata. +// spec 0018, AC-12 to AC-14: the search params decide only the canonical page, through the +// parsed listing state; no filter text ever reaches the metadata (spec 0013, AC-24). export async function generateMetadata({ params, + searchParams, }: PageProps<"/categories/[slug]">): Promise { const category = await getCategoryBySlug((await params).slug); + // AC-21: notFound() in the header adds the noindex. if (!category) return { title: "Page not found" }; - return { - title: category.metaTitle ?? category.name, - description: category.metaDescription ?? category.description ?? undefined, - alternates: { - canonical: new URL(categoryPath(category.slug), env.NEXT_PUBLIC_SITE_URL).toString(), + const canonical = await getListingCanonical( + { kind: "category", categoryId: category.id }, + categoryPath(category.slug), + searchParams, + ); + return pageMetadata( + { + title: pagedTitle(presentText(category.metaTitle) ?? category.name, canonical.page), + description: + presentText(category.metaDescription) ?? + plainExcerpt(category.description ?? "", META_DESCRIPTION_MAX), + path: canonical.path, }, - }; + site, + ); } // The page joins the two features: the category from categories, the listing from catalog. diff --git a/app/(store)/cookies/page.tsx b/app/(store)/cookies/page.tsx index 9442b6b..d03771c 100644 --- a/app/(store)/cookies/page.tsx +++ b/app/(store)/cookies/page.tsx @@ -1,9 +1,11 @@ import { CookieTable } from "@/features/legal/components/cookie-table"; -import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page"; +import { LegalPage, legalSeo } from "@/features/legal/components/legal-page"; import { cookieRegistry } from "@/lib/consent/registry"; import { CookieSettingsButton } from "@/lib/consent/cookie-settings-control"; +import { pageMetadata } from "@/lib/seo/metadata"; +import { site } from "@/lib/seo/site"; -export const metadata = legalMetadata("cookies"); +export const metadata = pageMetadata(legalSeo("cookies"), site); // spec 0017, AC-16 and AC-17: the policy, the registry table, and the settings button. export default function CookiesPage() { diff --git a/app/(store)/page.tsx b/app/(store)/page.tsx index d139c7b..79eedce 100644 --- a/app/(store)/page.tsx +++ b/app/(store)/page.tsx @@ -4,22 +4,39 @@ import { Suspense } from "react"; import { storeContainer } from "@/components/layout/container"; import { AccountDeletedNotice } from "@/features/account/components/account-deleted-notice"; import { ListingSkeleton } from "@/features/catalog/listing/components/listing-skeleton"; +import { pagedTitle } from "@/features/catalog/listing/canonical"; +import { getListingCanonical } from "@/features/catalog/listing/canonical-query"; import { ListingView } from "@/features/catalog/listing/components/listing-view"; import { homePath } from "@/features/catalog/paths"; import { brand } from "@/lib/brand/brand"; -import { env } from "@/lib/env"; +import { JsonLdScript } from "@/lib/seo/json-ld"; +import { defaultDescription, pageMetadata } from "@/lib/seo/metadata"; +import { site } from "@/lib/seo/site"; +import { organizationJsonLd, websiteJsonLd } from "@/lib/seo/structured-data"; import { cn } from "@/lib/utils"; -// spec 0013, AC-24: the home title stays; paged and filtered views point at the plain path. -export const metadata: Metadata = { - alternates: { canonical: new URL(homePath, env.NEXT_PUBLIC_SITE_URL).toString() }, -}; +// spec 0018, AC-12 and AC-13: a filtered or sorted view points at the plain path; an unfiltered +// paged one at itself, titled `, page N`. Reads the search params, so it renders at request time, +// like the results; crawlers still get it in the head. No filter text ever reaches it. +export async function generateMetadata({ searchParams }: PageProps<"/">): Promise { + const canonical = await getListingCanonical({ kind: "home" }, homePath, searchParams); + return pageMetadata( + { + title: canonical.page > 1 ? { absolute: pagedTitle(brand.name, canonical.page) } : null, + description: defaultDescription(brand.name), + path: canonical.path, + }, + site, + ); +} // spec 0013, AC-4: every listable product, with filters, sorts and paging. The heading is the // static shell; the results read the search params, so they render behind Suspense. export default function StoreHomePage({ searchParams }: PageProps<"/">) { return (
+ {/* spec 0018, AC-15: in the static shell. */} + diff --git a/app/(store)/privacy/page.tsx b/app/(store)/privacy/page.tsx index 018e26d..ced0e06 100644 --- a/app/(store)/privacy/page.tsx +++ b/app/(store)/privacy/page.tsx @@ -1,6 +1,8 @@ -import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page"; +import { LegalPage, legalSeo } from "@/features/legal/components/legal-page"; +import { pageMetadata } from "@/lib/seo/metadata"; +import { site } from "@/lib/seo/site"; -export const metadata = legalMetadata("privacy"); +export const metadata = pageMetadata(legalSeo("privacy"), site); export default function PrivacyPage() { return ; diff --git a/app/(store)/products/[slug]/page.tsx b/app/(store)/products/[slug]/page.tsx index a5bae30..983ba17 100644 --- a/app/(store)/products/[slug]/page.tsx +++ b/app/(store)/products/[slug]/page.tsx @@ -7,6 +7,9 @@ import { ProductDetailsSkeleton, } from "@/features/catalog/components/product-details"; import { getPrerenderedSlugs, getProductBySlug } from "@/features/catalog/queries"; +import { productSeo } from "@/features/catalog/seo"; +import { pageMetadata } from "@/lib/seo/metadata"; +import { site } from "@/lib/seo/site"; import { cn } from "@/lib/utils"; // Cannot match the slug pattern, so it renders the 404 page. Cache Components refuses an empty @@ -22,7 +25,8 @@ export async function generateMetadata({ params, }: PageProps<"/products/[slug]">): Promise { const product = await getProductBySlug((await params).slug); - return product ? { title: product.name } : { title: "Page not found" }; + // spec 0018, AC-21: a hidden product gets no product metadata; notFound() adds the noindex. + return product ? pageMetadata(productSeo(product), site) : { title: "Page not found" }; } // Other slugs render on their first visit and are cached (spec 0005, Caching model). diff --git a/app/(store)/returns/page.tsx b/app/(store)/returns/page.tsx index 4ecdd0d..04fccde 100644 --- a/app/(store)/returns/page.tsx +++ b/app/(store)/returns/page.tsx @@ -1,6 +1,8 @@ -import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page"; +import { LegalPage, legalSeo } from "@/features/legal/components/legal-page"; +import { pageMetadata } from "@/lib/seo/metadata"; +import { site } from "@/lib/seo/site"; -export const metadata = legalMetadata("returns"); +export const metadata = pageMetadata(legalSeo("returns"), site); export default function ReturnsPage() { return ; diff --git a/app/(store)/search/page.tsx b/app/(store)/search/page.tsx index 294ac07..a676879 100644 --- a/app/(store)/search/page.tsx +++ b/app/(store)/search/page.tsx @@ -7,13 +7,16 @@ import { ListingView } from "@/features/catalog/listing/components/listing-view" import { SearchForm } from "@/features/catalog/listing/components/search-box"; import { searchQuery } from "@/features/catalog/listing/params"; import { searchPath } from "@/features/catalog/paths"; +import { pageMetadata } from "@/lib/seo/metadata"; +import { site } from "@/lib/seo/site"; import { cn } from "@/lib/utils"; -// spec 0013, AC-24: the query never reaches the title or any metadata; results are not indexed. -export const metadata: Metadata = { - title: "Search", - robots: { index: false, follow: true }, -}; +// spec 0013, AC-24 and spec 0018, AC-17: the query never reaches the title or any metadata; +// results are `noindex, follow`, with no canonical and the brand card as the share image. +export const metadata: Metadata = pageMetadata( + { title: "Search", description: null, path: searchPath, indexable: false }, + site, +); async function SearchResults({ searchParams }: Pick, "searchParams">) { const query = searchQuery(await searchParams); diff --git a/app/(store)/shipping/page.tsx b/app/(store)/shipping/page.tsx index ccb5ad4..fa7d847 100644 --- a/app/(store)/shipping/page.tsx +++ b/app/(store)/shipping/page.tsx @@ -1,9 +1,11 @@ -import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page"; +import { LegalPage, legalSeo } from "@/features/legal/components/legal-page"; import { shippingValues } from "@/features/legal/shipping-values"; import { env } from "@/lib/env"; +import { pageMetadata } from "@/lib/seo/metadata"; +import { site } from "@/lib/seo/site"; import { getShippingSettings } from "@/lib/shipping/settings"; -export const metadata = legalMetadata("shipping"); +export const metadata = pageMetadata(legalSeo("shipping"), site); // spec 0017, AC-5: static plus the cached settings read, which an admin save expires. export default async function ShippingPage() { diff --git a/app/(store)/terms/page.tsx b/app/(store)/terms/page.tsx index 4d84df3..022546a 100644 --- a/app/(store)/terms/page.tsx +++ b/app/(store)/terms/page.tsx @@ -1,6 +1,8 @@ -import { LegalPage, legalMetadata } from "@/features/legal/components/legal-page"; +import { LegalPage, legalSeo } from "@/features/legal/components/legal-page"; +import { pageMetadata } from "@/lib/seo/metadata"; +import { site } from "@/lib/seo/site"; -export const metadata = legalMetadata("terms"); +export const metadata = pageMetadata(legalSeo("terms"), site); export default function TermsPage() { return ; diff --git a/app/brand-card.png/route.tsx b/app/brand-card.png/route.tsx new file mode 100644 index 0000000..a0aaf90 --- /dev/null +++ b/app/brand-card.png/route.tsx @@ -0,0 +1,7 @@ +import { brandCardResponse } from "@/lib/seo/brand-images"; + +// spec 0018, AC-3: a fixed URL every page names as its share image. It reads no request data, +// so it prerenders at build. +export function GET() { + return brandCardResponse(); +} diff --git a/app/icon.tsx b/app/icon.tsx new file mode 100644 index 0000000..5a4b7c6 --- /dev/null +++ b/app/icon.tsx @@ -0,0 +1,10 @@ +import { logoResponse } from "@/lib/seo/brand-images"; +import { logoSize } from "@/lib/seo/metadata"; + +// spec 0018, AC-4: the tab icon is the logo; favicon.ico stays for browsers that ask for it. +export const size = logoSize; +export const contentType = "image/png"; + +export default function Icon() { + return logoResponse(); +} diff --git a/app/layout.tsx b/app/layout.tsx index 04f5cd0..b3c4ca0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,13 +6,16 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { brand } from "@/lib/brand/brand"; import { fontHeading, fontSans } from "@/lib/brand/fonts"; import { env } from "@/lib/env"; +import { defaultDescription } from "@/lib/seo/metadata"; import { cn } from "@/lib/utils"; import "./globals.css"; +// spec 0018, AC-1: every relative metadata URL resolves on the site's own origin. export const metadata: Metadata = { + metadataBase: new URL(env.NEXT_PUBLIC_SITE_URL), title: { default: brand.name, template: `%s · ${brand.name}` }, - description: `${brand.name} online store`, + description: defaultDescription(brand.name), }; export default function RootLayout({ children }: LayoutProps<"/">) { diff --git a/app/logo.png/route.tsx b/app/logo.png/route.tsx new file mode 100644 index 0000000..e36c0aa --- /dev/null +++ b/app/logo.png/route.tsx @@ -0,0 +1,6 @@ +import { logoResponse } from "@/lib/seo/brand-images"; + +// spec 0018, AC-4: the Organization logo, at a fixed URL. Prerendered at build. +export function GET() { + return logoResponse(); +} diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..3c24d4d --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,10 @@ +import type { MetadataRoute } from "next"; + +import { env } from "@/lib/env"; +import { robotsRules } from "@/lib/seo/robots"; + +// spec 0018, AC-20: prerendered at build, which is right because Vercel builds each production +// deployment with VERCEL_ENV=production and every other build blocks crawling. +export default function robots(): MetadataRoute.Robots { + return robotsRules(env.VERCEL_ENV, env.NEXT_PUBLIC_SITE_URL); +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..00a0340 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,32 @@ +import type { MetadataRoute } from "next"; + +import { homePath, productPath } from "@/features/catalog/paths"; +import { getSitemapProducts } from "@/features/catalog/queries"; +import { categoryPath } from "@/features/categories/paths"; +import { getSitemapCategories } from "@/features/categories/queries"; +import { legalDocuments } from "@/lib/legal/documents"; +import { legalSlugs } from "@/lib/legal/paths"; +import { absoluteUrl } from "@/lib/seo/metadata"; +import { site } from "@/lib/seo/site"; + +// spec 0018, AC-18 and AC-19: every public page and nothing else. Both reads are cached and +// tagged, so a product or category write shows here on the next request, with no deploy. +export default async function sitemap(): Promise { + const [products, categories] = await Promise.all([getSitemapProducts(), getSitemapCategories()]); + return [ + { url: absoluteUrl(homePath, site) }, + ...legalSlugs.map((slug) => ({ + url: absoluteUrl(legalDocuments[slug].path, site), + lastModified: legalDocuments[slug].updated, + })), + ...categories.map((category) => ({ + url: absoluteUrl(categoryPath(category.slug), site), + lastModified: category.updatedAt, + })), + ...products.map((product) => ({ + url: absoluteUrl(productPath(product.slug), site), + lastModified: product.updatedAt, + ...(product.imageUrls.length > 0 ? { images: [...product.imageUrls] } : {}), + })), + ]; +} diff --git a/package.json b/package.json index ac8ff8b..80a89e9 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "cn": "^0.4.0", "culori": "^4.0.2", "lucide-react": "^1.48.0", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-to-string": "^4.0.0", "next": "16.3.6", "pg": "^8.23.0", "pino": "10.3.1", @@ -76,6 +78,7 @@ "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.8.1", "prisma": "7.10.0", + "schema-dts": "^2.1.0", "simple-git-hooks": "^2.14.0", "supabase": "^2.117.0", "tailwindcss": "^4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1a8e10..59d9b0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,12 @@ importers: lucide-react: specifier: ^1.48.0 version: 1.48.0(react@19.2.8) + mdast-util-from-markdown: + specifier: ^2.0.3 + version: 2.0.3(supports-color@7.2.0) + mdast-util-to-string: + specifier: ^4.0.0 + version: 4.0.0 next: specifier: 16.3.6 version: 16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.13.6)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -150,6 +156,9 @@ importers: prisma: specifier: 7.10.0 version: 7.10.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + schema-dts: + specifier: ^2.1.0 + version: 2.1.0 simple-git-hooks: specifier: ^2.14.0 version: 2.14.0 @@ -4483,6 +4492,13 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + schema-dts-lib@1.1.0: + resolution: {integrity: sha512-Nx00FDPHDimSENMznVpADeYUJDsqlMiO9wcs2S+giggNTm5+ruNkM4m9z9qCk6xUJT+ThDM3NxxpwfHfFk1G+Q==} + engines: {node: '>=14.0.0'} + + schema-dts@2.1.0: + resolution: {integrity: sha512-Y/NvOvuJUZwrm+EmNqJF3M+bRufERJs1h5GJPLilUuK5D5b53ttU+XZgIjEFaAVRToh+ib+cQawFmxKC5Jt9ow==} + selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} @@ -9522,6 +9538,12 @@ snapshots: scheduler@0.27.0: {} + schema-dts-lib@1.1.0: {} + + schema-dts@2.1.0: + dependencies: + schema-dts-lib: 1.1.0 + selderee@0.11.0: dependencies: parseley: 0.12.1 diff --git a/src/features/catalog/components/product-details.tsx b/src/features/catalog/components/product-details.tsx index 621fe9d..fedcb0e 100644 --- a/src/features/catalog/components/product-details.tsx +++ b/src/features/catalog/components/product-details.tsx @@ -2,9 +2,14 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { Skeleton } from "@/components/ui/skeleton"; +import { env } from "@/lib/env"; +import { JsonLdScript } from "@/lib/seo/json-ld"; +import { site } from "@/lib/seo/site"; +import { getShippingSettings } from "@/lib/shipping/settings"; import { ProductDescription } from "../markdown"; import { getProductBySlug } from "../queries"; +import { productJsonLd } from "../seo"; import { productLayout, ProductShowcase } from "./product-showcase"; // spec 0005, AC-7. A slug that is not an active product gets the branded 404 page; under Cache @@ -13,34 +18,46 @@ export async function ProductDetails({ params }: { readonly params: Promise<{ sl const { slug } = await params; const product = await getProductBySlug(slug); if (!product) notFound(); + // Cached and tagged store-settings, so an admin save changes the shipping rate (spec 0018, AC-10). + const settings = await getShippingSettings(); return ( - - - Shop all - -

{product.name}

-
- } - description={ - product.description ? ( -
-

- Description -

- -
- ) : null - } - /> + <> + {/* After the not found check, so a hidden product never gets one (spec 0018, AC-7). */} + + + + Shop all + +

{product.name}

+ + } + description={ + product.description ? ( +
+

+ Description +

+ +
+ ) : null + } + /> + ); } diff --git a/src/features/catalog/listing/canonical-query.ts b/src/features/catalog/listing/canonical-query.ts new file mode 100644 index 0000000..8b23e25 --- /dev/null +++ b/src/features/catalog/listing/canonical-query.ts @@ -0,0 +1,20 @@ +import "server-only"; + +import { env } from "@/lib/env"; + +import { type ListingCanonical, listingCanonical } from "./canonical"; +import { getListingIndex } from "./index-query"; +import { inScopeProducts } from "./listing"; +import { type ListingScope, parseListingParams, type RawParams } from "./params"; + +// The canonical of a home or category view (spec 0018, AC-12), counted from the same cached +// index the listing reads, scoped the same way. +export async function getListingCanonical( + scope: Exclude, + path: string, + searchParams: Promise, +): Promise { + const state = parseListingParams(await searchParams, scope.kind, env.STORE_CURRENCY); + const inScope = inScopeProducts(await getListingIndex(), scope, "") ?? []; + return listingCanonical(path, state, scope.kind, inScope.length); +} diff --git a/src/features/catalog/listing/canonical.test.ts b/src/features/catalog/listing/canonical.test.ts new file mode 100644 index 0000000..167fafc --- /dev/null +++ b/src/features/catalog/listing/canonical.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { listingCanonical, pagedTitle } from "./canonical"; +import { PAGE_SIZE } from "./listing"; +import { type ListingKind, parseListingParams, type RawParams } from "./params"; + +const path = "/categories/shoes"; +// Three pages. +const total = PAGE_SIZE * 2 + 1; + +function canonical(params: RawParams, kind: ListingKind = "category") { + return listingCanonical(path, parseListingParams(params, kind, "EUR"), kind, total); +} + +// spec 0018, AC-12 and AC-13. +describe("listingCanonical", () => { + it.each([ + ["no params", {}], + ["page 0", { page: "0" }], + ["a page that is not a number", { page: "abc" }], + ["page 1", { page: "1" }], + ["the default sort", { sort: "featured" }], + ["a toggle that is off", { stock: "0" }], + ] as const)("gives the plain path for %s", (_label, params) => { + expect(canonical(params)).toEqual({ path, page: 1 }); + }); + + it("points an unfiltered page at itself", () => { + expect(canonical({ page: "2" })).toEqual({ path: `${path}?page=2`, page: 2 }); + }); + + it("names the page shown, so a page past the end is the last page", () => { + expect(canonical({ page: "99" })).toEqual({ path: `${path}?page=3`, page: 3 }); + }); + + it.each([ + ["an option filter", { color: "red", page: "2" }], + ["a tracking param, which parses as an option filter", { utm_source: "x" }], + ["another sort", { sort: "price-asc", page: "2" }], + ["a price bound", { min: "10", page: "2" }], + ["in stock", { stock: "1" }], + ["on sale", { sale: "1", page: "2" }], + ] as const)("gives the plain path for %s", (_label, params) => { + expect(canonical(params)).toEqual({ path, page: 1 }); + }); + + it("works on home the same way", () => { + expect( + listingCanonical("/", parseListingParams({ page: "2" }, "home", "EUR"), "home", total), + ).toEqual({ path: "/?page=2", page: 2 }); + }); + + it("gives page 1 for an empty scope", () => { + expect( + listingCanonical(path, parseListingParams({ page: "4" }, "category", "EUR"), "category", 0), + ).toEqual({ path, page: 1 }); + }); +}); + +describe("pagedTitle", () => { + it("adds the page above 1 only", () => { + expect(pagedTitle("Shoes", 1)).toBe("Shoes"); + expect(pagedTitle("Shoes", 2)).toBe("Shoes, page 2"); + }); +}); diff --git a/src/features/catalog/listing/canonical.ts b/src/features/catalog/listing/canonical.ts new file mode 100644 index 0000000..bc39ddc --- /dev/null +++ b/src/features/catalog/listing/canonical.ts @@ -0,0 +1,41 @@ +import { shownPage } from "./listing"; +import { defaultSort, type ListingKind, type ListingState } from "./params"; + +// Pure (spec 0018, AC-12 and AC-13): the canonical of a home or category view, on the parsed +// state, never the raw params. A filtered or sorted view points at the plain path; an unfiltered +// one at the page it actually shows, so `?page=99` on three pages says page 3. + +export type ListingCanonical = { + // Relative to the site; the plain path for page 1. + readonly path: string; + // Above 1 only for an unfiltered paged view, which then titles itself `, page N`. + readonly page: number; +}; + +function isPlainView(state: ListingState, kind: ListingKind): boolean { + return ( + state.minCents === null && + state.maxCents === null && + !state.inStock && + !state.onSale && + state.options.length === 0 && + state.sort === defaultSort(kind) + ); +} + +export function listingCanonical( + path: string, + state: ListingState, + kind: ListingKind, + // The products in the listing's scope before filters (inScopeProducts). + scopeTotal: number, +): ListingCanonical { + if (!isPlainView(state, kind)) return { path, page: 1 }; + const { page } = shownPage(state.page, scopeTotal); + return page > 1 ? { path: `${path}?page=${page}`, page } : { path, page: 1 }; +} + +// AC-13: `Shoes, page 2`; page 1 keeps the plain title. +export function pagedTitle(title: string, page: number): string { + return page > 1 ? `${title}, page ${page}` : title; +} diff --git a/src/features/catalog/listing/listing.ts b/src/features/catalog/listing/listing.ts index a613c35..5b7dd90 100644 --- a/src/features/catalog/listing/listing.ts +++ b/src/features/catalog/listing/listing.ts @@ -249,35 +249,35 @@ function toCard(product: ListingProduct): ProductCard { }; } -// The products a listing draws from and their default order; null for a search too short to -// run (AC-9). -function scopeOf( +// The products a listing draws from; null for a search too short to run (AC-9). +export function inScopeProducts( index: readonly ListingProduct[], scope: ListingScope, q: string, - featured: Compare, -): { readonly inScope: readonly ListingProduct[]; readonly defaultOrder: Compare } | null { +): readonly ListingProduct[] | null { switch (scope.kind) { case "search": { if (!searchQueryReady(q)) return null; const queryWords = searchWords(q); - return { - inScope: index.filter((product) => matchesWords(product.nameWords, queryWords)), - defaultOrder: bestMatch(queryWords), - }; + return index.filter((product) => matchesWords(product.nameWords, queryWords)); } case "category": - return { - inScope: index.filter( - (product) => product.categoryPositions[scope.categoryId] !== undefined, - ), - defaultOrder: featured, - }; + return index.filter((product) => product.categoryPositions[scope.categoryId] !== undefined); case "home": - return { inScope: index, defaultOrder: featured }; + return index; } } +// The page a listing actually shows: past the end is the last page. Shared with the canonical +// rule (spec 0018, AC-12), so the canonical never names a page the listing does not show. +export function shownPage( + requested: number, + total: number, +): { readonly page: number; readonly pageCount: number } { + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); + return { page: Math.min(requested, pageCount), pageCount }; +} + export function buildListing( index: readonly ListingProduct[], scope: ListingScope, @@ -288,9 +288,9 @@ export function buildListing( const collator = new Intl.Collator(locale); const featured = scope.kind === "category" ? categoryFeatured(scope.categoryId) : homeFeatured; - const scoped = scopeOf(index, scope, state.q, featured); - if (scoped === null) return { status: "prompt" }; - const { inScope, defaultOrder } = scoped; + const inScope = inScopeProducts(index, scope, state.q); + if (inScope === null) return { status: "prompt" }; + const defaultOrder = scope.kind === "search" ? bestMatch(searchWords(state.q)) : featured; // Featured order drives the facet value order (AC-13); the default order the results. const byFeatured = inScope.toSorted(featured); @@ -366,8 +366,7 @@ export function buildListing( const filtered = ordered.filter((product) => resultIds.has(product.id)); const sorted = sortBy ? filtered.toSorted(sortBy) : filtered; - const pageCount = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE)); - const page = Math.min(state.page, pageCount); + const { page, pageCount } = shownPage(state.page, sorted.length); const applied: ListingState = { ...state, // relevance only exists on search; the parser already enforces that. diff --git a/src/features/catalog/queries.ts b/src/features/catalog/queries.ts index fb97874..ea185c2 100644 --- a/src/features/catalog/queries.ts +++ b/src/features/catalog/queries.ts @@ -3,7 +3,7 @@ import "server-only"; import { cacheLife, cacheTag } from "next/cache"; import { type Availability, availability } from "@/lib/availability"; -import { productTag } from "@/lib/cache-tags"; +import { catalogTag, productTag } from "@/lib/cache-tags"; import { db } from "@/lib/db"; import { PLACEHOLDER_IMAGE, productImageUrl } from "@/lib/product-image"; import { SLUG_PATTERN } from "@/lib/slug"; @@ -63,6 +63,8 @@ export type ProductCard = { // count itself, so counts above the low stock threshold never reach the browser (AC-7). export type ProductVariantView = { readonly id: string; + // In the Product JSON-LD (spec 0018, AC-8); not sensitive, order lines show it too. + readonly sku: string; readonly priceCents: number; // Shown struck through as "Was"; never charged (AC-16). readonly compareAtPriceCents: number | null; @@ -90,6 +92,9 @@ export type ProductView = { readonly slug: string; // Markdown, rendered by ProductDescription (spec 0009, AC-6). readonly description: string; + // Set only in the database for now; blank or null falls back to the name and description. + readonly metaTitle: string | null; + readonly metaDescription: string | null; readonly images: readonly GalleryImage[]; readonly optionTypes: readonly ProductOptionTypeView[]; readonly variants: readonly ProductVariantView[]; @@ -109,6 +114,8 @@ export async function getProductBySlug(slug: string): Promise ({ ...toImage(image), id: image.id, @@ -162,6 +172,7 @@ export async function getProductBySlug(slug: string): Promise ({ id: variant.id, + sku: variant.sku, priceCents: variant.priceCents, compareAtPriceCents: variant.compareAtPriceCents, availability: availability(variant.stockQuantity), @@ -182,3 +193,36 @@ export async function getPrerenderedSlugs(): Promise { }); return products.map((product) => product.slug); } + +export type SitemapProduct = { + readonly slug: string; + // ISO 8601: a cached value must survive serialization as it is. + readonly updatedAt: string; + readonly imageUrls: readonly string[]; +}; + +// spec 0018, AC-18 and AC-19: every product the storefront lists (active, with a live variant), +// tagged catalog so a product write shows in the sitemap on the next request. +export async function getSitemapProducts(): Promise { + "use cache"; + cacheLife("max"); + cacheTag(catalogTag); + + const products = await db.product.findMany({ + where: { status: "active", variants: { some: { archived: false } } }, + orderBy: [{ position: "asc" }, { createdAt: "desc" }, { id: "desc" }], + select: { + slug: true, + updatedAt: true, + images: { + orderBy: [{ position: "asc" }, { id: "asc" }], + select: { storagePath: true }, + }, + }, + }); + return products.map((product) => ({ + slug: product.slug, + updatedAt: product.updatedAt.toISOString(), + imageUrls: product.images.map((image) => productImageUrl(image.storagePath)), + })); +} diff --git a/src/features/catalog/seo.test.ts b/src/features/catalog/seo.test.ts new file mode 100644 index 0000000..0370931 --- /dev/null +++ b/src/features/catalog/seo.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; + +import { availability } from "@/lib/availability"; + +import type { ProductView, ProductVariantView } from "./queries"; +import { productJsonLd, productSeo, type StoreFacts } from "./seo"; + +const store: StoreFacts = { + url: "https://shop.example", + name: "BeStore", + locale: "en", + currency: "EUR", + country: "PL", +}; + +function variant(overrides: Partial = {}): ProductVariantView { + return { + id: "v1", + sku: "SCARF-RED", + priceCents: 12900, + compareAtPriceCents: null, + availability: availability(20), + optionValueIds: [], + ...overrides, + }; +} + +function product(overrides: Partial = {}): ProductView { + return { + id: "p1", + name: "Wool scarf", + slug: "wool-scarf", + description: "A **warm** scarf.", + metaTitle: null, + metaDescription: null, + images: [ + { + id: "i1", + src: "https://cdn.example/1.jpg", + alt: "", + width: 800, + height: 1000, + position: 0, + optionValueId: null, + }, + { + id: "i2", + src: "https://cdn.example/2.jpg", + alt: "Red", + width: null, + height: null, + position: 1, + optionValueId: "red", + }, + ], + optionTypes: [], + variants: [variant()], + ...overrides, + }; +} + +const settings = { flatShippingCents: 999, freeShippingThresholdCents: 10000 }; + +// spec 0018, AC-5 and AC-6. +describe("productSeo", () => { + it("falls back to the name, the description's plain text and the first image", () => { + expect(productSeo(product())).toEqual({ + title: "Wool scarf", + description: "A warm scarf.", + path: "/products/wool-scarf", + image: { url: "https://cdn.example/1.jpg", alt: "Wool scarf", width: 800, height: 1000 }, + }); + }); + + it("prefers the meta fields", () => { + expect( + productSeo(product({ metaTitle: "Scarf | warm", metaDescription: "Buy it" })), + ).toMatchObject({ title: "Scarf | warm", description: "Buy it" }); + }); + + it("has no description for an empty one and no image without photos", () => { + const seo = productSeo(product({ description: "", images: [] })); + + expect(seo.description).toBeNull(); + expect(seo).not.toHaveProperty("image"); + }); + + it("uses the image alt text and leaves out an unknown size", () => { + const [, second] = product().images; + const seo = productSeo(product({ images: second ? [second] : [] })); + + expect(seo.image).toEqual({ url: "https://cdn.example/2.jpg", alt: "Red" }); + }); +}); + +// spec 0018, AC-7 to AC-10. +describe("productJsonLd", () => { + const twoVariants = product({ + variants: [ + variant({ sku: "A", priceCents: 12900, compareAtPriceCents: 15900 }), + variant({ id: "v2", sku: "B", priceCents: 5000, availability: availability(0) }), + ], + }); + const data = productJsonLd(twoVariants, settings, store); + + it("describes the product with every gallery image and the brand", () => { + expect(data).toMatchObject({ + "@context": "https://schema.org", + "@type": "Product", + name: "Wool scarf", + description: "A warm scarf.", + url: "https://shop.example/products/wool-scarf", + image: ["https://cdn.example/1.jpg", "https://cdn.example/2.jpg"], + brand: { "@type": "Brand", name: "BeStore" }, + }); + }); + + it("has one Offer per variant, in order, with prices in major units", () => { + expect(data.offers).toEqual([ + { + "@type": "Offer", + sku: "A", + price: "129.00", + priceCurrency: "EUR", + availability: "https://schema.org/InStock", + itemCondition: "https://schema.org/NewCondition", + url: "https://shop.example/products/wool-scarf", + priceSpecification: { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/StrikethroughPrice", + price: "159.00", + priceCurrency: "EUR", + }, + shippingDetails: { + "@type": "OfferShippingDetails", + shippingDestination: { "@type": "DefinedRegion", addressCountry: "PL" }, + shippingRate: { "@type": "MonetaryAmount", value: 0, currency: "EUR" }, + }, + }, + { + "@type": "Offer", + sku: "B", + price: "50.00", + priceCurrency: "EUR", + availability: "https://schema.org/OutOfStock", + itemCondition: "https://schema.org/NewCondition", + url: "https://shop.example/products/wool-scarf", + shippingDetails: { + "@type": "OfferShippingDetails", + shippingDestination: { "@type": "DefinedRegion", addressCountry: "PL" }, + shippingRate: { "@type": "MonetaryAmount", value: 9.99, currency: "EUR" }, + }, + }, + ]); + }); + + it("counts low stock as in stock and never carries a count", () => { + const low = productJsonLd( + product({ variants: [variant({ availability: availability(2) })] }), + settings, + store, + ); + + expect(JSON.stringify(low)).toContain("https://schema.org/InStock"); + expect(JSON.stringify(low)).not.toMatch(/"left"|stock_?quantity|inventoryLevel/i); + }); + + it.each([ + ["at", 12900], + ["below", 10000], + ])("adds no strikethrough for a compare at price %s the price", (_label, compareAt) => { + const offers = productJsonLd( + product({ variants: [variant({ compareAtPriceCents: compareAt })] }), + settings, + store, + ).offers; + + expect(JSON.stringify(offers)).not.toContain("priceSpecification"); + }); + + function rate(priceCents: number, threshold: number | null) { + const json = productJsonLd( + product({ variants: [variant({ priceCents })] }), + { flatShippingCents: 999, freeShippingThresholdCents: threshold }, + store, + ); + return JSON.parse(JSON.stringify(json)).offers[0].shippingDetails.shippingRate.value; + } + + it("charges the flat fee under the threshold and nothing at or above it", () => { + expect(rate(9999, 10000)).toBe(9.99); + expect(rate(10000, 10000)).toBe(0); + expect(rate(10001, 10000)).toBe(0); + }); + + it("always charges the flat fee with no threshold", () => { + expect(rate(1_000_000, null)).toBe(9.99); + }); + + it("formats prices with the currency's own fraction digits", () => { + const yen = productJsonLd(product(), settings, { ...store, currency: "JPY" }); + + expect(JSON.parse(JSON.stringify(yen)).offers[0].price).toBe("12900"); + }); + + it("leaves out the image and description when there are none", () => { + const bare = productJsonLd(product({ images: [], description: "" }), settings, store); + + expect(bare).not.toHaveProperty("image"); + expect(bare).not.toHaveProperty("description"); + }); +}); diff --git a/src/features/catalog/seo.ts b/src/features/catalog/seo.ts new file mode 100644 index 0000000..11c5e92 --- /dev/null +++ b/src/features/catalog/seo.ts @@ -0,0 +1,104 @@ +import type { ItemAvailability, Offer, Product, WithContext } from "schema-dts"; + +import type { Availability } from "@/lib/availability"; +import { centsToInput } from "@/lib/money"; +import { absoluteUrl, type PageSeo, type SiteFacts } from "@/lib/seo/metadata"; +import { META_DESCRIPTION_MAX, plainExcerpt, presentText } from "@/lib/seo/text"; +import { type ShippingSettings, shippingCents } from "@/lib/shipping/rule"; + +import { productPath } from "./paths"; +import type { ProductView } from "./queries"; + +// Pure: a product page's metadata and its Product JSON-LD (spec 0018, AC-5 to AC-10). The data +// only ever describes what the page shows: the same live variants and prices, never a count. + +// Google reads at most this much of a Product description. +const JSON_LD_DESCRIPTION_MAX = 5000; + +export type StoreFacts = SiteFacts & { + readonly currency: string; + readonly country: string; +}; + +// AC-5: meta fields first, then the name and the description's plain text; the first gallery +// image as the share image, or the brand card when there is none. +export function productSeo(product: ProductView): PageSeo { + const image = product.images[0]; + return { + title: presentText(product.metaTitle) ?? product.name, + description: + presentText(product.metaDescription) ?? + plainExcerpt(product.description, META_DESCRIPTION_MAX), + path: productPath(product.slug), + ...(image + ? { + image: { + url: image.src, + alt: presentText(image.alt) ?? product.name, + ...(image.width !== null && image.height !== null + ? { width: image.width, height: image.height } + : {}), + }, + } + : {}), + }; +} + +// AC-8: low stock is still in stock (LimitedAvailability means a limited run, not "few left"). +function offerAvailability(state: Availability): ItemAvailability { + return state.kind === "sold_out" ? "https://schema.org/OutOfStock" : "https://schema.org/InStock"; +} + +export function productJsonLd( + product: ProductView, + settings: ShippingSettings, + store: StoreFacts, +): WithContext { + const url = absoluteUrl(productPath(product.slug), store); + const description = plainExcerpt(product.description, JSON_LD_DESCRIPTION_MAX); + const price = (cents: number) => centsToInput(cents, store.currency); + + const offers: Offer[] = product.variants.map((variant) => ({ + "@type": "Offer", + sku: variant.sku, + price: price(variant.priceCents), + priceCurrency: store.currency, + availability: offerAvailability(variant.availability), + itemCondition: "https://schema.org/NewCondition", + url, + // AC-9: only a "Was" the page shows, so never one at or below the price. + ...(variant.compareAtPriceCents !== null && variant.compareAtPriceCents > variant.priceCents + ? { + priceSpecification: { + "@type": "UnitPriceSpecification", + priceType: "https://schema.org/StrikethroughPrice", + price: price(variant.compareAtPriceCents), + priceCurrency: store.currency, + }, + } + : {}), + // AC-10: the fee of a one item order of this variant, by the rule the cart uses. + shippingDetails: { + "@type": "OfferShippingDetails", + shippingDestination: { "@type": "DefinedRegion", addressCountry: store.country }, + shippingRate: { + "@type": "MonetaryAmount", + value: Number( + price(shippingCents({ subtotalCents: variant.priceCents, discountCents: 0 }, settings)), + ), + currency: store.currency, + }, + }, + })); + + return { + "@context": "https://schema.org", + "@type": "Product", + name: product.name, + ...(description === null ? {} : { description }), + url, + ...(product.images.length > 0 ? { image: product.images.map((image) => image.src) } : {}), + brand: { "@type": "Brand", name: store.name }, + offers, + }; +} diff --git a/src/features/categories/components/category-header.tsx b/src/features/categories/components/category-header.tsx index 2166190..4d23756 100644 --- a/src/features/categories/components/category-header.tsx +++ b/src/features/categories/components/category-header.tsx @@ -1,7 +1,11 @@ import { notFound } from "next/navigation"; import { Skeleton } from "@/components/ui/skeleton"; +import { JsonLdScript } from "@/lib/seo/json-ld"; +import { site } from "@/lib/seo/site"; +import { breadcrumbJsonLd } from "@/lib/seo/structured-data"; +import { categoryPath } from "../paths"; import { getCategoryBySlug } from "../queries"; // spec 0013, AC-1 to AC-3: the category's name and plain text description. Reads only the @@ -13,6 +17,10 @@ export async function CategoryHeader({ params }: { readonly params: Promise<{ sl return (
+ {/* After the not found check, so a hidden category never gets one (spec 0018, AC-16). */} +

{category.name}

{category.description ? (

diff --git a/src/features/categories/queries.ts b/src/features/categories/queries.ts index 3e81f90..a746420 100644 --- a/src/features/categories/queries.ts +++ b/src/features/categories/queries.ts @@ -62,3 +62,26 @@ export async function getPrerenderedCategorySlugs(): Promise }); return categories.map((category) => category.slug); } + +export type SitemapCategory = { + readonly slug: string; + // ISO 8601: a cached value must survive serialization as it is. + readonly updatedAt: string; +}; + +// spec 0018, AC-18 and AC-19: every visible category, for the sitemap. +export async function getSitemapCategories(): Promise { + "use cache"; + cacheLife("max"); + cacheTag(categoriesTag); + + const categories = await db.category.findMany({ + where: { visible: true }, + orderBy: [{ position: "asc" }, { id: "asc" }], + select: { slug: true, updatedAt: true }, + }); + return categories.map((category) => ({ + slug: category.slug, + updatedAt: category.updatedAt.toISOString(), + })); +} diff --git a/src/features/legal/AGENTS.md b/src/features/legal/AGENTS.md index c1e1278..c6ec382 100644 --- a/src/features/legal/AGENTS.md +++ b/src/features/legal/AGENTS.md @@ -6,8 +6,8 @@ The storefront pages `/terms`, `/privacy`, `/returns`, `/shipping` and `/cookies ## Files -- `app/(store)/{terms,privacy,returns,shipping,cookies}/page.tsx`: thin routes, `legalMetadata(slug)` then ``. -- `components/legal-page.tsx`: `server-only`. `legalMetadata` (title, description, canonical from `NEXT_PUBLIC_SITE_URL`) and `LegalPage` (the `h1`, the "Last updated" date, the body). +- `app/(store)/{terms,privacy,returns,shipping,cookies}/page.tsx`: thin routes, `pageMetadata(legalSeo(slug), site)` then ``. +- `components/legal-page.tsx`: `server-only`. `legalSeo` (title, description and path for the shared `pageMetadata`, spec 0018) and `LegalPage` (the `h1`, the "Last updated" date, the body). - `components/legal-markdown.tsx`: its own `react-markdown` renderer, not the product description one. Only `h2`, `h3`, `p`, lists, `strong`, `em`, `a`, `blockquote`, `hr`; raw HTML skipped; links only to `/...`, `#...`, `https:` and `mailto:`. - `components/cookie-table.tsx`: the registry as the table on `/cookies`. - `shipping-values.ts`: pure. The live delivery fee and free delivery rule `/shipping` fills in. diff --git a/src/features/legal/components/legal-page.test.ts b/src/features/legal/components/legal-page.test.ts index a4491eb..1288b45 100644 --- a/src/features/legal/components/legal-page.test.ts +++ b/src/features/legal/components/legal-page.test.ts @@ -24,7 +24,10 @@ vi.mock("next/link", () => ({ createElement("a", { href, ...props }, children), })); -const { LegalPage, legalMetadata } = await import("./legal-page"); +const { LegalPage, legalSeo } = await import("./legal-page"); +const { pageMetadata } = await import("@/lib/seo/metadata"); +const { site } = await import("@/lib/seo/site"); +const legalMetadata = (slug: (typeof legalSlugs)[number]) => pageMetadata(legalSeo(slug), site); const { cookiePolicyVersion, currentTermsVersion } = await import("@/lib/legal/documents"); // spec 0017, Value sourcing: front matter. The checkout's version and the consent cookie's `v` @@ -40,7 +43,7 @@ describe("the text versions", () => { }); // spec 0017, AC-2 and Value sourcing: canonical. -describe("legalMetadata", () => { +describe("the legal page metadata", () => { it.each(legalSlugs)("points the %s canonical URL at NEXT_PUBLIC_SITE_URL", (slug) => { expect(legalMetadata(slug).alternates?.canonical).toBe(`https://staging.other.example/${slug}`); }); diff --git a/src/features/legal/components/legal-page.tsx b/src/features/legal/components/legal-page.tsx index 5baebc5..1885ce5 100644 --- a/src/features/legal/components/legal-page.tsx +++ b/src/features/legal/components/legal-page.tsx @@ -1,25 +1,21 @@ import "server-only"; -import type { Metadata } from "next"; - import { storeContainer } from "@/components/layout/container"; import { calendarDate, formatCalendarDate } from "@/lib/dates"; import { env } from "@/lib/env"; import { legalDocuments } from "@/lib/legal/documents"; import type { LegalSlug } from "@/lib/legal/paths"; +import type { PageSeo } from "@/lib/seo/metadata"; import { fillPlaceholders } from "@/lib/legal/placeholders"; import { cn } from "@/lib/utils"; import { LegalMarkdown } from "./legal-markdown"; -// spec 0017, AC-2: the front matter title and description, a canonical URL, and indexable. -export function legalMetadata(slug: LegalSlug): Metadata { +// spec 0017, AC-2 and spec 0018, AC-2: the front matter title and description on the shared +// page metadata (canonical, share card), and indexable. +export function legalSeo(slug: LegalSlug): PageSeo { const document = legalDocuments[slug]; - return { - title: document.title, - description: document.description, - alternates: { canonical: new URL(document.path, env.NEXT_PUBLIC_SITE_URL).toString() }, - }; + return { title: document.title, description: document.description, path: document.path }; } // spec 0017, AC-1: the title as the only h1, the version date, then the body. It reads no diff --git a/src/lib/brand/brand.test.ts b/src/lib/brand/brand.test.ts new file mode 100644 index 0000000..0e2598f --- /dev/null +++ b/src/lib/brand/brand.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { formatHex } from "culori"; +import { describe, expect, it } from "vitest"; + +import { parseRootTokens } from "@/lib/design/contrast"; + +import { brand } from "./brand"; + +const tokens = parseRootTokens(readFileSync(join(process.cwd(), "app/globals.css"), "utf8")); + +describe("brand.colors", () => { + it.each([ + ["background", brand.colors.background], + ["foreground", brand.colors.foreground], + ] as const)("matches the --%s token", (token, hex) => { + expect(formatHex(tokens[token])).toBe(hex); + }); +}); diff --git a/src/lib/brand/brand.ts b/src/lib/brand/brand.ts index 745e0cc..ed489c1 100644 --- a/src/lib/brand/brand.ts +++ b/src/lib/brand/brand.ts @@ -16,12 +16,17 @@ export type SellerDetails = { export const brand: { readonly name: string; + // sRGB hex copies of the --background and --foreground tokens in app/globals.css, for the + // generated share card and logo (spec 0018, AC-3): next/og cannot read oklch CSS tokens. A + // rebrand updates them with the tokens; brand.test.ts fails until it does. + readonly colors: { readonly background: string; readonly foreground: string }; readonly contactEmail: string | null; // The seller named in the legal pages (spec 0017, AC-4). A null field renders a // `[[TO FILL]]` marker, which a production build refuses. readonly legal: SellerDetails; } = { name: "BeStore", + colors: { background: "#ffffff", foreground: "#0a0a0a" }, contactEmail: null, legal: { name: null, diff --git a/src/lib/seo/brand-images.tsx b/src/lib/seo/brand-images.tsx new file mode 100644 index 0000000..efa76e1 --- /dev/null +++ b/src/lib/seo/brand-images.tsx @@ -0,0 +1,48 @@ +import { ImageResponse } from "next/og"; + +import { brand } from "@/lib/brand/brand"; + +import { brandCardSize, logoSize } from "./metadata"; + +// spec 0018, AC-3 and AC-4: generated from brand.ts, so a rebrand updates them. next/og's +// default font: the next/font files cannot be handed to it without bundling them twice. + +export function brandCardResponse(): ImageResponse { + return new ImageResponse( +

+ {brand.name} +
, + brandCardSize, + ); +} + +export function logoResponse(): ImageResponse { + return new ImageResponse( +
+ {Array.from(brand.name)[0]?.toUpperCase() ?? ""} +
, + logoSize, + ); +} diff --git a/src/lib/seo/json-ld.test.ts b/src/lib/seo/json-ld.test.ts new file mode 100644 index 0000000..be8cc96 --- /dev/null +++ b/src/lib/seo/json-ld.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { serializeJsonLd } from "./json-ld"; + +// spec 0018, AC-11. +describe("serializeJsonLd", () => { + const data = { + "@context": "https://schema.org", + "@type": "Product", + name: "", + } as const; + const text = serializeJsonLd(data); + + it("never writes a `<`, so the text cannot close the script tag", () => { + expect(text).not.toContain("<"); + expect(text).toContain("\\u003c/script>"); + }); + + it("parses back to the same data", () => { + expect(JSON.parse(text)).toEqual(data); + }); +}); diff --git a/src/lib/seo/json-ld.tsx b/src/lib/seo/json-ld.tsx new file mode 100644 index 0000000..fd73a3b --- /dev/null +++ b/src/lib/seo/json-ld.tsx @@ -0,0 +1,21 @@ +import type { Thing, WithContext } from "schema-dts"; + +// spec 0018, AC-11: the one place JSON-LD is written into the page. Every `<` becomes `<`, +// so admin entered text holding `` can never close the tag; JSON parsers read it back +// as the same character. +export function serializeJsonLd(data: WithContext | readonly WithContext[]): string { + return JSON.stringify(data).replace(/ | readonly WithContext[]; +}) { + return ( + ", + }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + + await page.goto(`/products/${product.slug}`); + + const [data] = (await jsonLd(page)).filter((item) => item["@type"] === "Product"); + expect(data?.name).toBe(product.name); + expect(await page.evaluate(() => "pwned" in window)).toBe(false); + expect(errors).toEqual([]); + }); + + test("a draft renders the not found page, noindex, with no product data", async ({ page }) => { + const product = await seedProduct({ stock: 3, label: "Seo draft" }); + await setProductStatus(product.id, "draft"); + + await page.goto(`/products/${product.slug}`); + + await expect(page.getByRole("heading", { name: "We can't find that page" })).toBeVisible(); + expect(await meta(page, "name", "robots")).toContain("noindex"); + expect(await jsonLd(page)).toEqual([]); + expect(await canonical(page)).toBeNull(); + }); +}); + +test.describe("listings", () => { + test("home declares the Organization and WebSite and canonicalizes by the parsed state", async ({ + page, + }) => { + await page.goto("/?page=abc&utm_source=x"); + + await expect(page).toHaveTitle(brand.name); + // Next writes the root canonical without its trailing slash. + expect(await canonical(page)).toBe(siteUrl); + expect(await meta(page, "property", "og:image")).toBe(brandCard); + const data = await jsonLd(page); + expect(data).toContainEqual( + expect.objectContaining({ + "@type": "Organization", + name: brand.name, + logo: `${siteUrl}/logo.png`, + }), + ); + expect(data).toContainEqual(expect.objectContaining({ "@type": "WebSite", name: brand.name })); + expect(JSON.stringify(data)).not.toContain("SearchAction"); + + await page.goto("/?sort=price-asc&page=2"); + expect(await canonical(page)).toBe(siteUrl); + }); + + test("a category has its canonical, description and breadcrumb; a hidden one has none", async ({ + page, + }) => { + const suffix = randomUUID().slice(0, 6); + const slug = `seo-shelf-${suffix}`; + await withDb((db) => + db.query( + `INSERT INTO categories (id, name, slug, description, visible, position, updated_at) + VALUES (gen_random_uuid(), $1, $2, $3, true, 1000000, now()), + (gen_random_uuid(), $4, $5, null, false, 1000001, now())`, + [`Seo shelf ${suffix}`, slug, "Light and **cool**.", `Hidden ${suffix}`, `${slug}-hidden`], + ), + ); + + await page.goto(`/categories/${slug}?color=red&min=1`); + + await expect(page).toHaveTitle(`Seo shelf ${suffix} · ${brand.name}`); + expect(await canonical(page)).toBe(`${siteUrl}/categories/${slug}`); + expect(await meta(page, "name", "description")).toBe("Light and cool."); + const [crumbs] = (await jsonLd(page)).filter((item) => item["@type"] === "BreadcrumbList"); + expect(crumbs?.itemListElement).toEqual([ + { "@type": "ListItem", position: 1, name: brand.name, item: `${siteUrl}/` }, + { + "@type": "ListItem", + position: 2, + name: `Seo shelf ${suffix}`, + item: `${siteUrl}/categories/${slug}`, + }, + ]); + // No filter text anywhere in the metadata (spec 0013, AC-24). + expect(await page.locator("head").innerHTML()).not.toContain("color=red"); + + await page.goto(`/categories/${slug}-hidden`); + await expect(page.getByRole("heading", { name: "We can't find that page" })).toBeVisible(); + expect(await meta(page, "name", "robots")).toContain("noindex"); + expect(await jsonLd(page)).toEqual([]); + }); + + test("search stays noindex, follow with the brand card and no canonical", async ({ page }) => { + await page.goto("/search?q=scarf"); + + expect(await meta(page, "name", "robots")).toBe("noindex, follow"); + expect(await canonical(page)).toBeNull(); + expect(await meta(page, "property", "og:image")).toBe(brandCard); + expect(await jsonLd(page)).toEqual([]); + expect(await page.title()).not.toContain("scarf"); + }); + + test("a legal page has its canonical and the brand card", async ({ page }) => { + await page.goto("/terms"); + + expect(await canonical(page)).toBe(`${siteUrl}/terms`); + expect(await meta(page, "property", "og:image")).toBe(brandCard); + }); +}); + +test.describe("site files", () => { + test("robots.txt blocks every crawler outside production", async ({ request }) => { + const response = await request.get("/robots.txt"); + + expect(response.status()).toBe(200); + const text = await response.text(); + expect(text).toMatch(/User-Agent: \*\s+Disallow: \/\s*$/i); + expect(text).not.toContain("Sitemap"); + }); + + test("the brand card and logo are PNGs at their fixed paths", async ({ request }) => { + for (const path of ["/brand-card.png", "/logo.png"]) { + const response = await request.get(path); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toBe("image/png"); + } + }); + + test("the sitemap follows a publish and a hide on the next request", async ({ + page, + request, + browser, + }) => { + const product = await seedProduct({ stock: 2, label: "Seo map" }); + await setProductStatus(product.id, "draft"); + const url = `${siteUrl}/products/${product.slug}`; + const sitemap = async () => (await request.get("/sitemap.xml")).text(); + + const before = await sitemap(); + expect(before).not.toContain(url); + expect(before).toContain(`${siteUrl}/`); + expect(before).toContain(`${siteUrl}/terms`); + expect(before).not.toMatch(/\/(search|admin|cart|checkout|account|auth)\b/); + + const admin = await createTestUser({ enrolled: true }); + await signInFully(page, admin); + await page.goto(`/admin/products/${product.id}`); + await waitForEditor(page, product.name); + await page.getByRole("button", { name: "Publish" }).click(); + await expect(page.getByText("Product published")).toBeVisible(); + await expect.poll(sitemap).toContain(`${url}`); + + // Rendered and cached while live, so the hide below is a request time miss (AC-21). + const customer = await (await browser.newContext({ userAgent: googlebot })).newPage(); + await customer.goto(`/products/${product.slug}`); + expect(await jsonLd(customer)).not.toEqual([]); + + await page.getByRole("button", { name: "Hide" }).click(); + await expect(page.getByText("Product hidden")).toBeVisible(); + await expect.poll(sitemap).not.toContain(url); + await customer.goto(`/products/${product.slug}`); + await expect(customer.getByRole("heading", { name: "We can't find that page" })).toBeVisible(); + expect(await meta(customer, "name", "robots")).toContain("noindex"); + expect(await jsonLd(customer)).toEqual([]); + }); + + // AC-22: the private pages keep their noindex and gain nothing. + for (const path of [ + "/cart", + "/checkout", + "/account/sign-in", + "/account/sign-up", + "/admin/sign-in", + ]) { + test(`${path} stays noindex with no canonical or JSON-LD`, async ({ page }) => { + await page.goto(path); + + expect(await meta(page, "name", "robots")).toContain("noindex"); + expect(await canonical(page)).toBeNull(); + expect(await jsonLd(page)).toEqual([]); + }); + } +}); From c70183d1a3e0147ec05c90c022629f85beae1a11 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 23 Oct 2026 17:06:03 +0200 Subject: [PATCH 03/12] docs(seo): add the spec 0018 verify steps Co-Authored-By: Claude Opus 5.5 --- docs/specs/0018-storefront-seo/verify.md | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/specs/0018-storefront-seo/verify.md diff --git a/docs/specs/0018-storefront-seo/verify.md b/docs/specs/0018-storefront-seo/verify.md new file mode 100644 index 0000000..ff72d3d --- /dev/null +++ b/docs/specs/0018-storefront-seo/verify.md @@ -0,0 +1,35 @@ +# Verify: SEO for storefront pages · spec 0018 · updated 2026-10-23 +_Steps derived from spec 0018 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._ + +Read every page with a Googlebot user agent (or wait for the streamed tags), since Next only blocks on metadata for known bots. + +## UI / manual +- [ ] Any storefront page → every `og:url`, canonical and `og:image` is absolute on `NEXT_PUBLIC_SITE_URL`, never `localhost` in a deployed build → AC-1 +- [ ] Home, a category, a product, `/terms` and `/search` → `og:type` website, `og:site_name` the brand, `og:image` with width, height and alt, `twitter:card` summary_large_image with the same image; set `STORE_LOCALE=pl-PL` → `og:locale` is `pl_PL`, with `en` there is none → AC-2 +- [ ] Open `/brand-card.png` → a 1200x630 PNG with the brand name in the foreground colour on the background colour; it is the `og:image` of home, categories, legal pages, `/search` and a product with no photo → AC-3 +- [ ] Open `/logo.png` → a 512x512 PNG with the uppercased first letter of the brand; the tab icon shows the same → AC-4 +- [ ] A product with `meta_title` and `meta_description` set in the database → those are its title and description; clear them → the name and the description's plain text; its `og:image` is the first gallery photo with its alt (or the name) → AC-5 +- [ ] A product whose Markdown description is longer than 160 characters → the meta description has no Markdown syntax, ends at a word with `…`, and is at most 160 characters → AC-6 +- [ ] A product with two variants (one with a "Was" price, one sold out) → one `Product` script with `image` in gallery order, `brand`, and two Offers in variant order with SKU, `"129.00"` style prices, InStock / OutOfStock, NewCondition, no stock count → AC-7, AC-8 +- [ ] The variant with a compare at price above its price → `priceSpecification` StrikethroughPrice with the "Was" price; set compare at equal to the price → no strikethrough → AC-9 +- [ ] In `/admin/settings` set the flat fee 9.99 and the free threshold 100 → a 50.00 variant shows `shippingRate` 9.99, a 129.00 variant shows 0; remove the threshold → both 9.99, on the next request → AC-10 +- [ ] Rename a product to `` → no alert, the JSON-LD parses and holds the name → AC-11 +- [ ] On a category with more than 24 products: `?page=2` → canonical `?page=2` and title `, page 2 · BeStore`; `?page=99` → the last page; `?page=0`, `?page=abc`, `?page=1`, `?sort=featured`, `?stock=0`, `?utm_source=x`, `?color=red&page=2`, `?sort=price-asc` → the plain path and the plain title → AC-12, AC-13 +- [ ] A category with `meta_title` / `meta_description` → those win; without → the name and the description cut to 160 characters → AC-14 +- [ ] Home → JSON-LD Organization (name, url, logo `/logo.png`) and WebSite, no SearchAction → AC-15 +- [ ] A category → BreadcrumbList of the brand then the category → AC-16 +- [ ] `/search?q=scarf` → `noindex, follow`, no canonical, no JSON-LD, the brand card, and "scarf" nowhere in the head → AC-17 +- [ ] A draft product, an archived one, a product hidden after it was visited, and a hidden category → the not found page with `noindex`, no JSON-LD, no canonical → AC-21 +- [ ] `/cart`, `/checkout`, `/checkout/complete`, the account pages and `/admin/sign-in` → still `noindex`, no canonical, no JSON-LD → AC-22 +- [ ] On a preview build, run one product page through Google's Rich Results Test → Product with Offers detected, only the expected return policy and delivery time warnings → AC-7 to AC-10 + +## Commands +- [ ] `curl -s localhost:3000/sitemap.xml` → home, the five legal pages with their `updated` dates, every visible category, every active product with a live variant and its image URLs; no draft, archived, variant less product, hidden category, query URL, account, cart, checkout, search, admin or auth path → AC-18 +- [ ] Publish a draft product in the admin, then fetch `/sitemap.xml` again → it is listed; hide it → it is gone, with no deploy (also on `pnpm build && pnpm start`) → AC-19 +- [ ] `curl -s localhost:3000/robots.txt` → `User-Agent: *` / `Disallow: /`, no sitemap line → AC-20 +- [ ] `pnpm test -- src/lib/seo/robots.test.ts` → the production branch allows `/`, disallows `/admin`, `/api`, `/auth`, names the sitemap → AC-20 +- [ ] `pnpm test:db -- tests/db/sitemap.db.test.ts` → passes → AC-18, AC-19 +- [ ] `pnpm test:e2e tests/e2e/seo` → passes → AC-5 to AC-9, AC-11, AC-12, AC-15 to AC-22 + +## Acceptance-criteria coverage +- AC-1 … UI 1 · AC-2 … UI 2 · AC-3 … UI 3 · AC-4 … UI 4 · AC-5 … UI 5 · AC-6 … UI 6 · AC-7, AC-8 … UI 7 · AC-9 … UI 8 · AC-10 … UI 9 · AC-11 … UI 10 · AC-12, AC-13 … UI 11 · AC-14 … UI 12 · AC-15 … UI 13 · AC-16 … UI 14 · AC-17 … UI 15 · AC-18 … Commands 1, 5 · AC-19 … Commands 2 · AC-20 … Commands 3, 4 · AC-21 … UI 16 · AC-22 … UI 17 From 958f9fd2044e1fcf9b76be9e674c6329b1943649 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 23 Oct 2026 17:28:42 +0200 Subject: [PATCH 04/12] test(seo): cover the sitemap, hidden products, photos and paged categories Co-Authored-By: Claude Opus 5.5 --- app/sitemap.test.ts | 115 ++++++++++++++++++++++++++ tests/e2e/seo/seo.spec.ts | 167 +++++++++++++++++++++++++++++++++++++- 2 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 app/sitemap.test.ts diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts new file mode 100644 index 0000000..a722788 --- /dev/null +++ b/app/sitemap.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// spec 0018, AC-18: how the sitemap composes its two cached reads with the legal documents. The +// reads themselves (what counts as listed) are proven against a database in +// tests/db/sitemap.db.test.ts. + +const mocks = vi.hoisted(() => ({ + getSitemapProducts: vi.fn(), + getSitemapCategories: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +// Another origin than the default, so the test proves every URL follows the env. +vi.mock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SITE_URL: "https://shop.other.example", + STORE_LOCALE: "en", + STORE_COUNTRY: "PL", + STORE_CURRENCY: "PLN", + VERCEL_ENV: "development", + }, +})); +vi.mock("@/features/catalog/queries", () => ({ getSitemapProducts: mocks.getSitemapProducts })); +vi.mock("@/features/categories/queries", () => ({ + getSitemapCategories: mocks.getSitemapCategories, +})); + +const { default: sitemap } = await import("./sitemap"); +const { legalDocuments } = await import("@/lib/legal/documents"); +const { legalSlugs } = await import("@/lib/legal/paths"); + +const origin = "https://shop.other.example"; + +beforeEach(() => { + mocks.getSitemapProducts.mockResolvedValue([]); + mocks.getSitemapCategories.mockResolvedValue([]); +}); + +describe("sitemap", () => { + it("lists the home page with no lastModified and the five legal pages with their dates (AC-18)", async () => { + const entries = await sitemap(); + + expect(entries[0]).toEqual({ url: `${origin}/` }); + expect(entries.slice(1)).toEqual( + legalSlugs.map((slug) => ({ + url: `${origin}${legalDocuments[slug].path}`, + lastModified: legalDocuments[slug].updated, + })), + ); + expect(legalSlugs).toHaveLength(5); + for (const entry of entries.slice(1)) { + expect(entry.lastModified).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); + + it("lists each category and product on the site origin with its updated_at (AC-18)", async () => { + mocks.getSitemapCategories.mockResolvedValue([ + { slug: "scarves", updatedAt: "2026-10-01T10:00:00.000Z" }, + ]); + mocks.getSitemapProducts.mockResolvedValue([ + { + slug: "wool-scarf", + updatedAt: "2026-10-02T11:00:00.000Z", + imageUrls: ["https://cdn.example/a.jpg", "https://cdn.example/b.jpg"], + }, + ]); + + const entries = await sitemap(); + + expect(entries).toContainEqual({ + url: `${origin}/categories/scarves`, + lastModified: "2026-10-01T10:00:00.000Z", + }); + expect(entries).toContainEqual({ + url: `${origin}/products/wool-scarf`, + lastModified: "2026-10-02T11:00:00.000Z", + images: ["https://cdn.example/a.jpg", "https://cdn.example/b.jpg"], + }); + }); + + it("leaves out images for a product with no photo", async () => { + mocks.getSitemapProducts.mockResolvedValue([ + { slug: "plain-tee", updatedAt: "2026-10-02T11:00:00.000Z", imageUrls: [] }, + ]); + + const entries = await sitemap(); + + const tee = entries.find((entry) => entry.url === `${origin}/products/plain-tee`); + expect(tee).toBeDefined(); + expect(tee).not.toHaveProperty("images"); + }); + + it("lists nothing beyond home, legal pages, categories and products", async () => { + mocks.getSitemapCategories.mockResolvedValue([ + { slug: "scarves", updatedAt: "2026-10-01T10:00:00.000Z" }, + ]); + mocks.getSitemapProducts.mockResolvedValue([ + { slug: "wool-scarf", updatedAt: "2026-10-02T11:00:00.000Z", imageUrls: [] }, + ]); + + const entries = await sitemap(); + + expect(entries).toHaveLength(1 + 5 + 1 + 1); + for (const { url } of entries) { + expect(url.startsWith(`${origin}/`)).toBe(true); + expect(url).not.toContain("?"); + } + }); + + it("fails the render when a read fails, never serving a partial list", async () => { + mocks.getSitemapProducts.mockRejectedValue(new Error("db down")); + + await expect(sitemap()).rejects.toThrow("db down"); + }); +}); diff --git a/tests/e2e/seo/seo.spec.ts b/tests/e2e/seo/seo.spec.ts index 55c9602..20352ca 100644 --- a/tests/e2e/seo/seo.spec.ts +++ b/tests/e2e/seo/seo.spec.ts @@ -18,6 +18,8 @@ test.use({ userAgent: googlebot }); const siteUrl = new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000").origin; const brandCard = `${siteUrl}/brand-card.png`; +// PAGE_SIZE in src/features/catalog/listing/listing.ts (its unit test pins it). +const PAGE_SIZE = 24; async function meta(page: Page, attribute: "name" | "property", key: string) { const tag = page.locator(`meta[${attribute}="${key}"]`); @@ -129,6 +131,81 @@ test.describe("a product page", () => { expect(await jsonLd(page)).toEqual([]); expect(await canonical(page)).toBeNull(); }); + + // covers: AC-21, the other two ways a product leaves the storefront. + for (const [label, hide] of [ + [ + "an archived product", + (id: string) => + withDb((db) => db.query("UPDATE products SET status = 'archived' WHERE id = $1", [id])), + ], + [ + "a product with no live variant", + (id: string) => + withDb((db) => + db.query("UPDATE product_variants SET archived = true WHERE product_id = $1", [id]), + ), + ], + ] as const) { + test(`${label} renders the not found page, noindex, with no product data`, async ({ page }) => { + const product = await seedProduct({ stock: 3, label: "Seo gone" }); + await hide(product.id); + + await page.goto(`/products/${product.slug}`); + + await expect(page.getByRole("heading", { name: "We can't find that page" })).toBeVisible(); + expect(await meta(page, "name", "robots")).toContain("noindex"); + expect(await jsonLd(page)).toEqual([]); + expect(await canonical(page)).toBeNull(); + expect(await page.title()).not.toContain(product.name); + }); + } + + // covers: AC-1, AC-5, AC-7. The rows only need URLs, so no file is uploaded. + test("shares its first photo with its alt text and lists every photo in gallery order", async ({ + page, + }) => { + const product = await seedProduct({ stock: 4, label: "Seo photo" }); + // Inserted out of order, so the test proves the position decides. + await withDb((db) => + db.query( + `INSERT INTO product_images (id, product_id, storage_path, alt_text, position, width, height) + VALUES (gen_random_uuid(), $1, $2, '', 1, null, null), + (gen_random_uuid(), $1, $3, 'Folded on a chair', 0, 1600, 1200)`, + [product.id, `${product.id}/second.jpg`, `${product.id}/first.jpg`], + ), + ); + + await page.goto(`/products/${product.slug}`); + + const ogImage = await meta(page, "property", "og:image"); + expect(ogImage).toMatch( + new RegExp(`^https?://[^/]+/storage/v1/object/public/.+/${product.id}/first\\.jpg$`), + ); + expect(await meta(page, "property", "og:image:alt")).toBe("Folded on a chair"); + expect(await meta(page, "property", "og:image:width")).toBe("1600"); + expect(await meta(page, "property", "og:image:height")).toBe("1200"); + expect(await meta(page, "name", "twitter:image")).toBe(ogImage); + const [data] = (await jsonLd(page)).filter((item) => item["@type"] === "Product"); + expect(data?.image).toEqual([ogImage, ogImage!.replace("first.jpg", "second.jpg")]); + }); + + // covers: AC-5, the meta fields win over the name and description. + test("uses its meta title and description when they are set", async ({ page }) => { + const product = await seedProduct({ stock: 4, label: "Seo meta" }); + await withDb((db) => + db.query( + "UPDATE products SET meta_title = $2, meta_description = $3, description = $4 WHERE id = $1", + [product.id, "Best scarf ever", "Hand knitted in the hills.", "Body copy."], + ), + ); + + await page.goto(`/products/${product.slug}`); + + await expect(page).toHaveTitle(`Best scarf ever · ${brand.name}`); + expect(await meta(page, "name", "description")).toBe("Hand knitted in the hills."); + expect(await meta(page, "property", "og:description")).toBe("Hand knitted in the hills."); + }); }); test.describe("listings", () => { @@ -194,6 +271,89 @@ test.describe("listings", () => { expect(await jsonLd(page)).toEqual([]); }); + // covers: AC-12, AC-13, AC-14. The page count comes from the cached listing index, so the last + // product goes in through the admin, which expires it. + test("a two page category names the page shown, clamps past the end and keeps filters plain", async ({ + page, + browser, + }) => { + const suffix = randomUUID().slice(0, 6); + const name = `Seo pages ${suffix}`; + const slug = `seo-pages-${suffix}`; + const last = await seedProduct({ stock: 2, label: `Seo pages last ${suffix}` }); + await withDb((db) => + db.query( + `WITH c AS ( + INSERT INTO categories + (id, name, slug, description, meta_description, visible, position, updated_at) + VALUES (gen_random_uuid(), $1, $2, 'Body copy.', 'Twenty five scarves.', true, + 1000002, now()) + RETURNING id + ), p AS ( + INSERT INTO products (id, name, slug, status, updated_at) + SELECT gen_random_uuid(), $1 || ' item ' || i, $2 || '-item-' || i, 'active', now() + FROM generate_series(1, $3::int) AS i + RETURNING id, slug + ), v AS ( + INSERT INTO product_variants + (id, product_id, sku, price_cents, stock_quantity, position, option_key, updated_at) + SELECT gen_random_uuid(), p.id, upper(p.slug), 1500, 5, 0, '', now() FROM p + ) + INSERT INTO product_categories (product_id, category_id, position) + SELECT p.id, c.id, row_number() OVER () FROM p, c`, + [name, slug, PAGE_SIZE], + ), + ); + const category = await withDb(async (db) => { + const row = await db.query<{ id: string }>("SELECT id FROM categories WHERE slug = $1", [ + slug, + ]); + return row.rows[0]!.id; + }); + const admin = await createTestUser({ enrolled: true }); + await signInFully(page, admin); + await page.goto(`/admin/categories/${category}?q=${encodeURIComponent(last.name)}`); + await page.getByRole("button", { name: `Add ${last.name}` }).click(); + await expect( + page + .getByRole("list", { name: "Products in this category" }) + .getByRole("link", { name: last.name }), + ).toBeVisible(); + const shopper = await (await browser.newContext({ userAgent: googlebot })).newPage(); + const plain = `${siteUrl}/categories/${slug}`; + + await shopper.goto(`/categories/${slug}?page=2`); + await expect(shopper).toHaveTitle(`${name}, page 2 · ${brand.name}`); + expect(await canonical(shopper)).toBe(`${plain}?page=2`); + expect(await meta(shopper, "property", "og:url")).toBe(`${plain}?page=2`); + expect(await meta(shopper, "name", "description")).toBe("Twenty five scarves."); + + await shopper.goto(`/categories/${slug}?page=99`); + expect(await canonical(shopper)).toBe(`${plain}?page=2`); + await expect(shopper).toHaveTitle(`${name}, page 2 · ${brand.name}`); + + for (const query of [ + "page=1", + "page=0", + "color=red&page=2", + "sort=price-asc&page=2", + "stock=1&page=2", + ]) { + await shopper.goto(`/categories/${slug}?${query}`); + expect(await canonical(shopper), query).toBe(plain); + await expect(shopper, query).toHaveTitle(`${name} · ${brand.name}`); + } + }); + + test("an unknown category renders the not found page with no breadcrumb", async ({ page }) => { + await page.goto(`/categories/no-such-shelf-${randomUUID().slice(0, 6)}`); + + await expect(page.getByRole("heading", { name: "We can't find that page" })).toBeVisible(); + expect(await meta(page, "name", "robots")).toContain("noindex"); + expect(await jsonLd(page)).toEqual([]); + expect(await canonical(page)).toBeNull(); + }); + test("search stays noindex, follow with the brand card and no canonical", async ({ page }) => { await page.goto("/search?q=scarf"); @@ -279,7 +439,12 @@ test.describe("site files", () => { test(`${path} stays noindex with no canonical or JSON-LD`, async ({ page }) => { await page.goto(path); - expect(await meta(page, "name", "robots")).toContain("noindex"); + // An empty cart's /checkout redirects to /cart after the shell streams, so the head can + // be mid swap on the first read; the retrying assertion waits it out. + await expect(page.locator('meta[name="robots"]').first()).toHaveAttribute( + "content", + /noindex/, + ); expect(await canonical(page)).toBeNull(); expect(await jsonLd(page)).toEqual([]); }); From 3f7c8fe652a18365104bd3ab48a2f24ced8224e0 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 23 Oct 2026 17:28:47 +0200 Subject: [PATCH 05/12] docs(seo): mark spec 0018 verified, tested and accepted Co-Authored-By: Claude Opus 5.5 --- docs/scope/scope.md | 8 ++--- docs/specs/0018-storefront-seo/index.md | 2 +- docs/specs/0018-storefront-seo/verify.md | 44 ++++++++++++------------ 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/scope/scope.md b/docs/scope/scope.md index d96931e..d468964 100644 --- a/docs/scope/scope.md +++ b/docs/scope/scope.md @@ -31,7 +31,7 @@ _These are recommendations to keep your build orderly, not requirements. Skip an | 15 | Admin sales dashboard | Slice 9 | done | | 23 | Native controls in GET forms | Slice 9 follow up | done | | 16 | Legal pages & cookie consent | Launch | done | -| 17 | SEO for storefront pages | Launch | in-progress | +| 17 | SEO for storefront pages | Launch | done | | 18 | Analytics & error tracking | Launch | planned | | 19 | Production deploy | Launch | planned | @@ -324,7 +324,7 @@ spec [0017](../specs/0017-legal-pages-cookie-consent/index.md) · code in [src/l - [x] Verify it: `/check verify legal pages & cookie consent` - [x] Test it: `/test legal pages & cookie consent` -### 17. SEO for storefront pages · in-progress +### 17. SEO for storefront pages · done Make products findable: titles, descriptions, canonical links, sitemap, product rich results, and social share cards on every public page. **Done when:** every product and category page has unique metadata and structured product data, the sitemap lists all visible products, hidden products and admin pages are never indexed. spec [0018](../specs/0018-storefront-seo/index.md) · code in [src/lib/seo/](../../src/lib/seo/), [src/features/catalog/seo.ts](../../src/features/catalog/seo.ts), [app/sitemap.ts](../../app/sitemap.ts), [app/robots.ts](../../app/robots.ts) @@ -334,8 +334,8 @@ spec [0018](../specs/0018-storefront-seo/index.md) · code in [src/lib/seo/](../ - [x] Brand card, logo and icon; home, legal and search pages on the helper; Organization and WebSite JSON-LD (AC-3, AC-4, AC-15, AC-17) - [x] Listing canonicals and paged titles, category descriptions and BreadcrumbList (AC-12 to AC-14, AC-16) - [x] Full sitemap (home, legal, categories, images) and the hidden and private page checks (AC-18, AC-19, AC-21, AC-22) -- [ ] Verify it: `/check verify SEO for storefront pages` -- [ ] Test it: `/test SEO for storefront pages` +- [x] Verify it: `/check verify SEO for storefront pages` +- [x] Test it: `/test SEO for storefront pages` ### 18. Analytics & error tracking · needs a decision See how customers move through the store and know when something breaks. diff --git a/docs/specs/0018-storefront-seo/index.md b/docs/specs/0018-storefront-seo/index.md index a820e1b..cef01e8 100644 --- a/docs/specs/0018-storefront-seo/index.md +++ b/docs/specs/0018-storefront-seo/index.md @@ -1,7 +1,7 @@ # 0018. Storefront SEO **Date**: 2026-10-23 -**Status**: In Progress +**Status**: Accepted ## Summary diff --git a/docs/specs/0018-storefront-seo/verify.md b/docs/specs/0018-storefront-seo/verify.md index ff72d3d..2d80fc4 100644 --- a/docs/specs/0018-storefront-seo/verify.md +++ b/docs/specs/0018-storefront-seo/verify.md @@ -4,32 +4,32 @@ _Steps derived from spec 0018 acceptance criteria. `/check verify` runs these; ` Read every page with a Googlebot user agent (or wait for the streamed tags), since Next only blocks on metadata for known bots. ## UI / manual -- [ ] Any storefront page → every `og:url`, canonical and `og:image` is absolute on `NEXT_PUBLIC_SITE_URL`, never `localhost` in a deployed build → AC-1 -- [ ] Home, a category, a product, `/terms` and `/search` → `og:type` website, `og:site_name` the brand, `og:image` with width, height and alt, `twitter:card` summary_large_image with the same image; set `STORE_LOCALE=pl-PL` → `og:locale` is `pl_PL`, with `en` there is none → AC-2 -- [ ] Open `/brand-card.png` → a 1200x630 PNG with the brand name in the foreground colour on the background colour; it is the `og:image` of home, categories, legal pages, `/search` and a product with no photo → AC-3 -- [ ] Open `/logo.png` → a 512x512 PNG with the uppercased first letter of the brand; the tab icon shows the same → AC-4 -- [ ] A product with `meta_title` and `meta_description` set in the database → those are its title and description; clear them → the name and the description's plain text; its `og:image` is the first gallery photo with its alt (or the name) → AC-5 -- [ ] A product whose Markdown description is longer than 160 characters → the meta description has no Markdown syntax, ends at a word with `…`, and is at most 160 characters → AC-6 -- [ ] A product with two variants (one with a "Was" price, one sold out) → one `Product` script with `image` in gallery order, `brand`, and two Offers in variant order with SKU, `"129.00"` style prices, InStock / OutOfStock, NewCondition, no stock count → AC-7, AC-8 -- [ ] The variant with a compare at price above its price → `priceSpecification` StrikethroughPrice with the "Was" price; set compare at equal to the price → no strikethrough → AC-9 -- [ ] In `/admin/settings` set the flat fee 9.99 and the free threshold 100 → a 50.00 variant shows `shippingRate` 9.99, a 129.00 variant shows 0; remove the threshold → both 9.99, on the next request → AC-10 -- [ ] Rename a product to `` → no alert, the JSON-LD parses and holds the name → AC-11 -- [ ] On a category with more than 24 products: `?page=2` → canonical `?page=2` and title `, page 2 · BeStore`; `?page=99` → the last page; `?page=0`, `?page=abc`, `?page=1`, `?sort=featured`, `?stock=0`, `?utm_source=x`, `?color=red&page=2`, `?sort=price-asc` → the plain path and the plain title → AC-12, AC-13 -- [ ] A category with `meta_title` / `meta_description` → those win; without → the name and the description cut to 160 characters → AC-14 -- [ ] Home → JSON-LD Organization (name, url, logo `/logo.png`) and WebSite, no SearchAction → AC-15 -- [ ] A category → BreadcrumbList of the brand then the category → AC-16 -- [ ] `/search?q=scarf` → `noindex, follow`, no canonical, no JSON-LD, the brand card, and "scarf" nowhere in the head → AC-17 -- [ ] A draft product, an archived one, a product hidden after it was visited, and a hidden category → the not found page with `noindex`, no JSON-LD, no canonical → AC-21 -- [ ] `/cart`, `/checkout`, `/checkout/complete`, the account pages and `/admin/sign-in` → still `noindex`, no canonical, no JSON-LD → AC-22 +- [x] Any storefront page → every `og:url`, canonical and `og:image` is absolute on `NEXT_PUBLIC_SITE_URL`, never `localhost` in a deployed build → AC-1 +- [x] Home, a category, a product, `/terms` and `/search` → `og:type` website, `og:site_name` the brand, `og:image` with width, height and alt, `twitter:card` summary_large_image with the same image; set `STORE_LOCALE=pl-PL` → `og:locale` is `pl_PL`, with `en` there is none → AC-2 +- [x] Open `/brand-card.png` → a 1200x630 PNG with the brand name in the foreground colour on the background colour; it is the `og:image` of home, categories, legal pages, `/search` and a product with no photo → AC-3 +- [x] Open `/logo.png` → a 512x512 PNG with the uppercased first letter of the brand; the tab icon shows the same → AC-4 +- [x] A product with `meta_title` and `meta_description` set in the database → those are its title and description; clear them → the name and the description's plain text; its `og:image` is the first gallery photo with its alt (or the name) → AC-5 +- [x] A product whose Markdown description is longer than 160 characters → the meta description has no Markdown syntax, ends at a word with `…`, and is at most 160 characters → AC-6 +- [x] A product with two variants (one with a "Was" price, one sold out) → one `Product` script with `image` in gallery order, `brand`, and two Offers in variant order with SKU, `"129.00"` style prices, InStock / OutOfStock, NewCondition, no stock count → AC-7, AC-8 +- [x] The variant with a compare at price above its price → `priceSpecification` StrikethroughPrice with the "Was" price; set compare at equal to the price → no strikethrough → AC-9 +- [x] In `/admin/settings` set the flat fee 9.99 and the free threshold 100 → a 50.00 variant shows `shippingRate` 9.99, a 129.00 variant shows 0; remove the threshold → both 9.99, on the next request → AC-10 +- [x] Rename a product to `` → no alert, the JSON-LD parses and holds the name → AC-11 +- [x] On a category with more than 24 products: `?page=2` → canonical `?page=2` and title `, page 2 · BeStore`; `?page=99` → the last page; `?page=0`, `?page=abc`, `?page=1`, `?sort=featured`, `?stock=0`, `?utm_source=x`, `?color=red&page=2`, `?sort=price-asc` → the plain path and the plain title → AC-12, AC-13 +- [x] A category with `meta_title` / `meta_description` → those win; without → the name and the description cut to 160 characters → AC-14 +- [x] Home → JSON-LD Organization (name, url, logo `/logo.png`) and WebSite, no SearchAction → AC-15 +- [x] A category → BreadcrumbList of the brand then the category → AC-16 +- [x] `/search?q=scarf` → `noindex, follow`, no canonical, no JSON-LD, the brand card, and "scarf" nowhere in the head → AC-17 +- [x] A draft product, an archived one, a product hidden after it was visited, and a hidden category → the not found page with `noindex`, no JSON-LD, no canonical → AC-21 +- [x] `/cart`, `/checkout`, `/checkout/complete`, the account pages and `/admin/sign-in` → still `noindex`, no canonical, no JSON-LD → AC-22 - [ ] On a preview build, run one product page through Google's Rich Results Test → Product with Offers detected, only the expected return policy and delivery time warnings → AC-7 to AC-10 ## Commands -- [ ] `curl -s localhost:3000/sitemap.xml` → home, the five legal pages with their `updated` dates, every visible category, every active product with a live variant and its image URLs; no draft, archived, variant less product, hidden category, query URL, account, cart, checkout, search, admin or auth path → AC-18 +- [x] `curl -s localhost:3000/sitemap.xml` → home, the five legal pages with their `updated` dates, every visible category, every active product with a live variant and its image URLs; no draft, archived, variant less product, hidden category, query URL, account, cart, checkout, search, admin or auth path → AC-18 - [ ] Publish a draft product in the admin, then fetch `/sitemap.xml` again → it is listed; hide it → it is gone, with no deploy (also on `pnpm build && pnpm start`) → AC-19 -- [ ] `curl -s localhost:3000/robots.txt` → `User-Agent: *` / `Disallow: /`, no sitemap line → AC-20 -- [ ] `pnpm test -- src/lib/seo/robots.test.ts` → the production branch allows `/`, disallows `/admin`, `/api`, `/auth`, names the sitemap → AC-20 -- [ ] `pnpm test:db -- tests/db/sitemap.db.test.ts` → passes → AC-18, AC-19 -- [ ] `pnpm test:e2e tests/e2e/seo` → passes → AC-5 to AC-9, AC-11, AC-12, AC-15 to AC-22 +- [x] `curl -s localhost:3000/robots.txt` → `User-Agent: *` / `Disallow: /`, no sitemap line → AC-20 +- [x] `pnpm test -- src/lib/seo/robots.test.ts` → the production branch allows `/`, disallows `/admin`, `/api`, `/auth`, names the sitemap → AC-20 +- [x] `pnpm test:db -- tests/db/sitemap.db.test.ts` → passes → AC-18, AC-19 +- [x] `pnpm test:e2e tests/e2e/seo` → passes → AC-5 to AC-9, AC-11, AC-12, AC-15 to AC-22 ## Acceptance-criteria coverage - AC-1 … UI 1 · AC-2 … UI 2 · AC-3 … UI 3 · AC-4 … UI 4 · AC-5 … UI 5 · AC-6 … UI 6 · AC-7, AC-8 … UI 7 · AC-9 … UI 8 · AC-10 … UI 9 · AC-11 … UI 10 · AC-12, AC-13 … UI 11 · AC-14 … UI 12 · AC-15 … UI 13 · AC-16 … UI 14 · AC-17 … UI 15 · AC-18 … Commands 1, 5 · AC-19 … Commands 2 · AC-20 … Commands 3, 4 · AC-21 … UI 16 · AC-22 … UI 17 From 36077071bd9206ce4dd1fe3f58a3a96477be23b0 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Fri, 23 Oct 2026 17:38:29 +0200 Subject: [PATCH 06/12] fix(seo): cut a category description as plain text, never as Markdown The category header shows its description as plain text, so parsing it as Markdown dropped characters the page keeps (*, #, [x](y)) and the meta description no longer matched the page. textExcerpt applies the AC-6 cut to plain text; plainExcerpt now builds on it for product Markdown. Co-Authored-By: Claude Opus 5.5 --- app/(store)/categories/[slug]/page.tsx | 4 ++-- docs/specs/0018-storefront-seo/index.md | 6 +++--- src/lib/seo/text.test.ts | 15 ++++++++++++++- src/lib/seo/text.ts | 24 +++++++++++++++--------- tests/e2e/seo/seo.spec.ts | 3 ++- 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/app/(store)/categories/[slug]/page.tsx b/app/(store)/categories/[slug]/page.tsx index dcd4b8e..608c556 100644 --- a/app/(store)/categories/[slug]/page.tsx +++ b/app/(store)/categories/[slug]/page.tsx @@ -14,7 +14,7 @@ import { categoryPath } from "@/features/categories/paths"; import { getCategoryBySlug, getPrerenderedCategorySlugs } from "@/features/categories/queries"; import { pageMetadata } from "@/lib/seo/metadata"; import { site } from "@/lib/seo/site"; -import { META_DESCRIPTION_MAX, plainExcerpt, presentText } from "@/lib/seo/text"; +import { META_DESCRIPTION_MAX, presentText, textExcerpt } from "@/lib/seo/text"; import { cn } from "@/lib/utils"; // Cannot match the slug pattern, so it renders the 404 page. Cache Components refuses an empty @@ -45,7 +45,7 @@ export async function generateMetadata({ title: pagedTitle(presentText(category.metaTitle) ?? category.name, canonical.page), description: presentText(category.metaDescription) ?? - plainExcerpt(category.description ?? "", META_DESCRIPTION_MAX), + textExcerpt(category.description ?? "", META_DESCRIPTION_MAX), path: canonical.path, }, site, diff --git a/docs/specs/0018-storefront-seo/index.md b/docs/specs/0018-storefront-seo/index.md index cef01e8..d413821 100644 --- a/docs/specs/0018-storefront-seo/index.md +++ b/docs/specs/0018-storefront-seo/index.md @@ -35,7 +35,7 @@ Every public storefront page gets complete search and share metadata: a unique t *Listings* - **AC-12**: Home and category pages declare their canonical by one pure rule on the parsed `ListingState` (from `parseListingParams`), never on raw params. When the state differs from the default state in anything but `page` (any price bound, `inStock`, `onSale`, any option filter, or a sort other than `defaultSort(kind)`), the canonical is the plain path. Otherwise it is the plain path for page 1 and `?page=N` for page N, where N is the page actually shown: the parsed page clamped to the page count, computed by one function exported from `listing.ts` that `buildListing` also uses, so the two cannot drift. So `?page=0`, `?page=abc`, `?page=1`, `?sort=featured`, `?stock=0` and unknown params (`?utm_source=x`) all give the plain path. - **AC-13**: For page N above 1 (the unfiltered case of AC-12), the title gains `, page N` (e.g. `Shoes, page 2 · BeStore`); filtered views keep the plain title. No search query or filter text ever appears in a title or any metadata (spec 0013 AC-24 still holds). -- **AC-14**: A category's title is `meta_title`, else its name (unchanged). Its description is `meta_description`, else its description cut by the AC-6 rule. +- **AC-14**: A category's title is `meta_title`, else its name (unchanged). Its description is `meta_description`, else its description cut by the AC-6 rule. The category description is plain text (the header shows it as is), so it is never parsed as Markdown: `*`, `#` or `[x](y)` stay in the meta description as on the page. - **AC-15**: The home page renders JSON-LD, in its static shell, for an `Organization` (`name`, `url` the site URL, `logo` the absolute `/logo.png` URL) and a `WebSite` (`name`, `url`), with no `SearchAction`. - **AC-16**: Each category page renders JSON-LD for a `BreadcrumbList` with two items: the brand name linking the site URL, then the category name (from `getCategoryBySlug`) linking the category canonical (plain path). The script mounts inside `CategoryHeader` after its not found check, so a hidden or unknown category renders none. - **AC-17**: `/search` stays `noindex, follow` with no canonical, no JSON-LD and the brand card as its share image. @@ -88,7 +88,7 @@ Use Next's built in metadata conventions (`generateMetadata`, `sitemap.ts`, `rob | `pageMetadata({ title, description, path, image? })` in `src/lib/seo/metadata.ts` | pure helper | page values | `Metadata` (AC-2) | n/a | none | | `productJsonLd(view, settings, facts)` in `src/features/catalog/seo.ts` | pure | `ProductView`, `ShippingSettings`, currency, country, site URL | `WithContext` | n/a | none | | `listingCanonical(path, state, kind, pageCount)` in `src/features/catalog/listing/` | pure | parsed `ListingState`, page count | absolute URL + page number for the title | n/a | none | -| `plainExcerpt(markdown, max)` in `src/lib/seo/text.ts` | pure | Markdown, limit | string or null | n/a | none | +| `plainExcerpt(markdown, max)` / `textExcerpt(text, max)` in `src/lib/seo/text.ts` | pure | Markdown or plain text, limit | string or null | n/a | none | | `jsonLdScript(data)` component in `src/lib/seo/` | server component | object | escaped `` → no alert, the JSON-LD parses and holds the name → AC-11 - [x] On a category with more than 24 products: `?page=2` → canonical `?page=2` and title `, page 2 · BeStore`; `?page=99` → the last page; `?page=0`, `?page=abc`, `?page=1`, `?sort=featured`, `?stock=0`, `?utm_source=x`, `?color=red&page=2`, `?sort=price-asc` → the plain path and the plain title → AC-12, AC-13 -- [x] A category with `meta_title` / `meta_description` → those win; without → the name and the description cut to 160 characters → AC-14 +- [x] A category with `meta_title` / `meta_description` → those win; without → the name and the description cut to 160 characters, kept as plain text (`**cool**` stays as typed) → AC-14 - [x] Home → JSON-LD Organization (name, url, logo `/logo.png`) and WebSite, no SearchAction → AC-15 - [x] A category → BreadcrumbList of the brand then the category → AC-16 - [x] `/search?q=scarf` → `noindex, follow`, no canonical, no JSON-LD, the brand card, and "scarf" nowhere in the head → AC-17 - [x] A draft product, an archived one, a product hidden after it was visited, and a hidden category → the not found page with `noindex`, no JSON-LD, no canonical → AC-21 -- [x] `/cart`, `/checkout`, `/checkout/complete`, the account pages and `/admin/sign-in` → still `noindex`, no canonical, no JSON-LD → AC-22 -- [ ] On a preview build, run one product page through Google's Rich Results Test → Product with Offers detected, only the expected return policy and delivery time warnings → AC-7 to AC-10 +- [x] `/cart`, `/checkout`, `/checkout/complete`, the account pages (signed out and signed in), the admin auth pages and every admin panel page → still `noindex`, no canonical, no JSON-LD → AC-22 +- [ ] On a preview build, run one product page through Google's Rich Results Test → Product with Offers detected, only the expected return policy and delivery time warnings → AC-7 to AC-10 _(open: no preview deploy exists yet; a spec 0018 follow-up)_ ## Commands - [x] `curl -s localhost:3000/sitemap.xml` → home, the five legal pages with their `updated` dates, every visible category, every active product with a live variant and its image URLs; no draft, archived, variant less product, hidden category, query URL, account, cart, checkout, search, admin or auth path → AC-18 -- [ ] Publish a draft product in the admin, then fetch `/sitemap.xml` again → it is listed; hide it → it is gone, with no deploy (also on `pnpm build && pnpm start`) → AC-19 +- [x] Publish a draft product in the admin, then fetch `/sitemap.xml` again → it is listed; hide it → it is gone, with no deploy (also on `pnpm build && pnpm start`); the same for a category shown and hidden → AC-19 _(dev server locally; the production build through the CI e2e job, which runs `tests/e2e/seo` on `pnpm build && pnpm start`)_ - [x] `curl -s localhost:3000/robots.txt` → `User-Agent: *` / `Disallow: /`, no sitemap line → AC-20 - [x] `pnpm test -- src/lib/seo/robots.test.ts` → the production branch allows `/`, disallows `/admin`, `/api`, `/auth`, names the sitemap → AC-20 - [x] `pnpm test:db -- tests/db/sitemap.db.test.ts` → passes → AC-18, AC-19 diff --git a/src/features/catalog/AGENTS.md b/src/features/catalog/AGENTS.md index c2525bf..809ea64 100644 --- a/src/features/catalog/AGENTS.md +++ b/src/features/catalog/AGENTS.md @@ -12,8 +12,9 @@ Products, option types, variants, ordered image galleries (up to 8, each optiona - `status.ts`, `variant-edit.ts`, `stock.ts`, `image-edit.ts`: pure edit rules (status transitions and publish position; variant conflicts, renames and new value combinations; stock changes and moved rows; the images conflict check). - `markdown.tsx`: the one description renderer (`ProductDescription`), shared by the cached product page and the admin Preview, so it has no `server-only` import. - `paths.ts`: admin and storefront URLs. -- `listing/`: the storefront listings (spec 0013). `index-query.ts` is the one cached listing index; `index-rows.ts`, `listing.ts`, `params.ts`, `pagination.ts`, `format.ts` are pure (index rows, search / filters / counts / sort / paging, the URL state parser and serializer, page links, the product count label); `components/` holds `ListingView` and its controls, which the home, search and category routes render. -- `queries.ts`: cached storefront reads; `admin-queries.ts`: live admin reads (never cached): the filtered, keyset paged list, `getProductForEdit`, `getStockHistory`, `getArrangeList`, `getCategoryOptions`, `searchProductsForPicker` (also used by the category page route). +- `listing/`: the storefront listings (spec 0013). `index-query.ts` is the one cached listing index; `index-rows.ts`, `listing.ts`, `params.ts`, `pagination.ts`, `format.ts` are pure (index rows, search / filters / counts / sort / paging, the URL state parser and serializer, page links, the product count label); `listing.ts` also exports `inScopeProducts` and `shownPage` for the canonical. `canonical.ts` is the pure home / category canonical and paged title (spec 0018, AC-12 and AC-13), `canonical-query.ts` feeds it the in scope count from the same cached index; `components/` holds `ListingView` and its controls, which the home, search and category routes render. +- `seo.ts`: pure product page metadata (`productSeo`) and Product JSON-LD (`productJsonLd`, one Offer per live variant with shipping), spec 0018. The site wide builders, `pageMetadata` and the env bound `site` / `store` / `storePageMetadata` live in `src/lib/seo/`. +- `queries.ts`: cached storefront reads (`getSitemapProducts` for `app/sitemap.ts` too); `admin-queries.ts`: live admin reads (never cached): the filtered, keyset paged list, `getProductForEdit`, `getStockHistory`, `getArrangeList`, `getCategoryOptions`, `searchProductsForPicker` (also used by the category page route). - `actions/`: `create-product`, `create-image-upload`, and one per edit section or step: `update-details`, `change-status`, `update-variants`, `add-option-value`, `adjust-stock`, `update-images`, `update-product-categories`, `reorder-products`, `delete-product`. - `components/editor/`: the edit page sections; `components/` holds the create form, list, arrange page and storefront parts (card, gallery, purchase). - `log.ts`: the `catalog.product.*`, `catalog.stock.adjusted` and `catalog.products.reordered` events. @@ -22,6 +23,7 @@ Products, option types, variants, ordered image galleries (up to 8, each optiona - Storefront reads are `'use cache'`, tagged `catalog` and `product:` (`src/lib/cache-tags.ts`); every action that changes what the storefront shows expires both with `updateTag` after the commit (a slug change expires the old and the new tag; `reorderProducts` only `catalog`). Category membership, order and visibility are tagged `categories`; the listing index carries both `catalog` and `categories`, so `createProduct` and `updateProductCategories` also expire `categories`. Only `active` products enter the cache. - Listings read the whole sellable catalog once (`getListingIndex`) and search, filter, count and sort it in plain code per request; no query per search. The URL holds the whole state, and `params.ts` turns any input into a valid state, never an error. Search and filter keys fold text through `src/lib/search-text.ts` (not `slugify`). Index variants carry `inStock`, never the count. +- Home and category canonicals come from the parsed `ListingState`, never raw params: a filtered or sorted view points at the plain path, an unfiltered paged one at `?page=N` for the page `shownPage` actually shows (spec 0018, AC-12, which narrows spec 0013 AC-24). No search or filter text ever reaches metadata. - The product page gets an availability per variant, never the stock count, so counts above the low stock threshold never reach the browser (`src/lib/availability.ts`). - Prices are typed in major units and converted with `parseMoney` (`src/lib/money.ts`) without floating point math. - Create and every save are one transaction. Unique violations (23505) map back to the field concerned via `src/lib/db-errors.ts`; invalid input never partly saves. diff --git a/src/features/categories/AGENTS.md b/src/features/categories/AGENTS.md index 6810d84..e6e469d 100644 --- a/src/features/categories/AGENTS.md +++ b/src/features/categories/AGENTS.md @@ -8,9 +8,9 @@ Admin only category management: `/admin/categories` (ordered list with product c - `schemas.ts`: pure. `categoryFieldsSchema` (name, slug, plain text description, visible) is shared by the form (`zodResolver`) and the actions, plus the per action schemas; `categoryFieldErrors` maps issues to fields. - `admin-queries.ts`: `getAdminCategories()` and `getCategoryForEdit(id)`, live reads (never cached). -- `queries.ts`: cached storefront reads of visible categories only: `getNavCategories`, `getCategoryBySlug` (null for unknown, malformed or hidden), `getPrerenderedCategorySlugs`. +- `queries.ts`: cached storefront reads of visible categories only: `getNavCategories`, `getCategoryBySlug` (null for unknown, malformed or hidden), `getPrerenderedCategorySlugs`, `getSitemapCategories` (for `app/sitemap.ts`, spec 0018). - `actions/`: `create-category`, `update-category`, `delete-category`, `reorder-categories`, `set-category-products`, `reorder-category-products`. -- `components/`: the list page (built on `src/components/arrange-list.tsx`), the editor, form, product list (`category-products.tsx`, with arranging split into `arranged-products.tsx`, `use-draft-order.ts` and `discard-order-dialog.tsx`) and delete button; `category-header.tsx` for the storefront page. +- `components/`: the list page (built on `src/components/arrange-list.tsx`), the editor, form, product list (`category-products.tsx`, with arranging split into `arranged-products.tsx`, `use-draft-order.ts` and `discard-order-dialog.tsx`) and delete button; `category-header.tsx` for the storefront page, which also renders the BreadcrumbList JSON-LD after its not found check (spec 0018, AC-16). - `paths.ts`: admin URLs and `categoryPath`. `log.ts`: `catalog.category.created`, `.updated`, `.deleted`, `.products_reordered`, `catalog.categories.reordered` (ids and counts only, never names or descriptions). ## Conventions @@ -20,6 +20,7 @@ Admin only category management: `/admin/categories` (ordered list with product c - A product joins a category at the end (`max(position) + 1`, category row locked first). Adding a link that exists or removing one already gone is a no op, not an error. - Updates compare `loadedUpdatedAt` under `FOR UPDATE` and return `stale` on a mismatch. Reorder (categories, and products inside a category) takes the full id list, refuses with `stale` unless it is exactly the current set, and rewrites positions `0..n-1` in one raw SQL statement so `updated_at` stays put. - Deleting removes the category and its links; the products stay unchanged. +- The description is plain text everywhere (the header shows it as is), so its meta description fallback is `textExcerpt`, never the Markdown `plainExcerpt` (spec 0018, AC-14). - Storefront reads are `'use cache'`, tagged `categories` (`src/lib/cache-tags.ts`); every action here expires it with `updateTag` after the commit, and delete also expires `catalog` (the listing index carries both tags). ## Tests diff --git a/src/features/legal/AGENTS.md b/src/features/legal/AGENTS.md index c6ec382..aa033a6 100644 --- a/src/features/legal/AGENTS.md +++ b/src/features/legal/AGENTS.md @@ -6,8 +6,8 @@ The storefront pages `/terms`, `/privacy`, `/returns`, `/shipping` and `/cookies ## Files -- `app/(store)/{terms,privacy,returns,shipping,cookies}/page.tsx`: thin routes, `pageMetadata(legalSeo(slug), site)` then ``. -- `components/legal-page.tsx`: `server-only`. `legalSeo` (title, description and path for the shared `pageMetadata`, spec 0018) and `LegalPage` (the `h1`, the "Last updated" date, the body). +- `app/(store)/{terms,privacy,returns,shipping,cookies}/page.tsx`: thin routes, `storePageMetadata(legalSeo(slug))` then ``. +- `components/legal-page.tsx`: `server-only`. `legalSeo` (title, description and path for the shared `pageMetadata`, through `storePageMetadata`, spec 0018) and `LegalPage` (the `h1`, the "Last updated" date, the body). - `components/legal-markdown.tsx`: its own `react-markdown` renderer, not the product description one. Only `h2`, `h3`, `p`, lists, `strong`, `em`, `a`, `blockquote`, `hr`; raw HTML skipped; links only to `/...`, `#...`, `https:` and `mailto:`. - `components/cookie-table.tsx`: the registry as the table on `/cookies`. - `shipping-values.ts`: pure. The live delivery fee and free delivery rule `/shipping` fills in.