@forgecart/cli 2.202610052310.0 → 2.202610060357.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront-shadcn/.forgecartignore +2 -0
  3. package/templates/storefront-shadcn/Procfile +1 -0
  4. package/templates/storefront-shadcn/README.md +229 -0
  5. package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
  6. package/templates/storefront-shadcn/components.json +21 -0
  7. package/templates/storefront-shadcn/next.config.js +105 -0
  8. package/templates/storefront-shadcn/package.json +39 -0
  9. package/templates/storefront-shadcn/postcss.config.js +5 -0
  10. package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
  11. package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
  12. package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  13. package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  14. package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
  15. package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
  16. package/templates/storefront-shadcn/src/app/error.tsx +23 -0
  17. package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
  18. package/templates/storefront-shadcn/src/app/globals.css +156 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
  20. package/templates/storefront-shadcn/src/app/page.tsx +217 -0
  21. package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
  22. package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
  23. package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
  24. package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
  25. package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
  26. package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
  27. package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
  28. package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
  29. package/templates/storefront-shadcn/src/app/robots.ts +69 -0
  30. package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
  31. package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
  32. package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
  33. package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
  34. package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
  35. package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
  36. package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
  37. package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
  38. package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
  39. package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
  40. package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
  41. package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
  42. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
  43. package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
  44. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
  45. package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
  46. package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
  47. package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
  48. package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
  49. package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  50. package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
  51. package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
  52. package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
  53. package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
  54. package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
  55. package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
  56. package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
  57. package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
  58. package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
  59. package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
  60. package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
  61. package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
  62. package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
  63. package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
  64. package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
  65. package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
  66. package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
  67. package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
  68. package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
  69. package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
  70. package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
  71. package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
  72. package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
  73. package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
  74. package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
  75. package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
  76. package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
  77. package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
  78. package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
  79. package/templates/storefront-shadcn/src/lib/format.ts +89 -0
  80. package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
  81. package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
  82. package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
  83. package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
  84. package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
  85. package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
  86. package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
  87. package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
  88. package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
  89. package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
  90. package/templates/storefront-shadcn/src/lib/money.ts +50 -0
  91. package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
  92. package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
  93. package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
  94. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
  95. package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
  96. package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
  97. package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
  98. package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
  99. package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
  100. package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
  101. package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
  102. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
  103. package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
  104. package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
  105. package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
  106. package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
  107. package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
  108. package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
  109. package/templates/storefront-shadcn/src/middleware.ts +379 -0
  110. package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
  111. package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
  112. package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
  113. package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
  114. package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
  115. package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
  116. package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  117. package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  118. package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
  119. package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
  120. package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
  121. package/templates/storefront-shadcn/src/server/runner.ts +90 -0
  122. package/templates/storefront-shadcn/src/server/types.ts +36 -0
  123. package/templates/storefront-shadcn/tsconfig.json +25 -0
  124. package/templates/storefront-shadcn-sdk-floor.json +1174 -0
  125. package/templates/template-set.json +10 -0
@@ -0,0 +1,708 @@
1
+ # Storefront SEO migration
2
+
3
+ This document migrates a storefront that was built before the platform's locale and SEO layer
4
+ existed. It is written for the agent that edits this storefront. The operator (the person who runs
5
+ this store) hands it to you; no platform process runs it. Follow it top to bottom, as one piece of
6
+ work, and finish with the DONE summary in the last section.
7
+
8
+ **This file is platform-owned. Do not edit this file**, and do not record progress in it. The
9
+ operator does not edit it either. `forgecart refresh` replaces an unchanged copy with every newer
10
+ version and leaves an edited copy alone, reporting it under `conflicts` — an edited copy stops
11
+ receiving corrections. Your progress goes in the DONE summary.
12
+
13
+ **How it arrived.** The operator ran the template refresh with no `adopt` list. The refresh:
14
+
15
+ - wrote every platform file that was missing, or unchanged since it was generated, at the current
16
+ platform version, and listed it under `rendered`;
17
+ - left every platform file that differs from the current platform version untouched, and listed it
18
+ under `conflicts`;
19
+ - listed every platform file that already matched under `upToDate`;
20
+ - never touched a file the platform does not ship.
21
+
22
+ **Does the migration apply?** Yes, when that refresh listed `src/lib/locale/grammar.ts` or
23
+ `src/lib/seo/metadata.ts` under `rendered`: neither file existed in this storefront before. There
24
+ is no version number to check instead. If you do not have the report, run the sweep in the
25
+ deletion list: any match outside the listed platform matches means the migration applies. With no
26
+ such match there is nothing to migrate — report that, with the sweep as evidence, and stop.
27
+
28
+ **What `conflicts` contains.** A provisioned storefront usually has no
29
+ `.forgecart/template-manifest.json` before its first refresh, so the refresh cannot tell a file the
30
+ merchant customized from a file that is only an older platform version: `conflicts` lists every
31
+ platform file that differs from the current version. The merge steps handle both kinds the same
32
+ way. The refresh wrote the manifest, so every later refresh is exact.
33
+
34
+ **The tree is mixed until you finish.** New platform modules now sit next to old files. For
35
+ example, `src/app/sitemap.ts` imports `getProductSeoEntries` and `getServedPageRoutes` from
36
+ `src/lib/forgecart.ts`; an older `src/lib/forgecart.ts` exports neither, so `/sitemap.xml` fails
37
+ until that file is merged. Therefore:
38
+
39
+ 1. Do the migration as its own work. Do not mix feature changes into the same turns.
40
+ 2. Put the deletions and the platform-file merges in the same promote.
41
+ 3. Do not ask the operator to publish until every step of the verification section passes.
42
+
43
+ **The refresh operation.** You call it to take the platform version of a file (to adopt it):
44
+
45
+ ```text
46
+ CallAdminOperation { "name": "workspaceTemplate.adminRefreshWorkspaceTemplate", "args": { "input": { "adopt": ["src/middleware.ts", "src/lib/forgecart.ts"] } } }
47
+ ```
48
+
49
+ It is the admin mutation `refreshWorkspaceTemplate(input: { adopt: [...] })` and returns
50
+ `rendered`, `conflicts`, `adopted`, `upToDate` and `dependenciesChanged`. Each `adopt` path must
51
+ be one a refresh reported under `conflicts`. When `dependenciesChanged` is `true`, dependencies were
52
+ re-installed before the call returned. `WORKSPACE_TEMPLATE_REFRESH_UNAVAILABLE` means the
53
+ workspace is not running yet; retry.
54
+
55
+ ## What the platform now ships
56
+
57
+ The layer the migration moves this storefront onto. Every rule below is implemented by a platform
58
+ file; your job is to call it, never to reimplement it.
59
+
60
+ - **The URL is the only language selector.** The channel's default language is served unprefixed
61
+ (`/products`); every other language under its two-letter prefix (`/de/products`). Both render the
62
+ SAME route: the middleware rewrites `/de/products` to `/products` and passes the language on. There
63
+ is no `[locale]` segment, no query parameter and no cookie that selects a language.
64
+ - **Two-letter top-level segments are reserved** for languages. Routes that exist once per store —
65
+ `/sitemap.xml`, `/robots.txt`, `/ping`, `/api`, `/__fc`, `/__forge_beacon`, `/verify`,
66
+ `/reset-password` — never take a prefix.
67
+ - **The root layout decides the status.** A prefix naming the default language answers 308 to the
68
+ unprefixed URL (`/en/products` → `/products`); a prefix the channel does not offer answers 404.
69
+ - **Links stay in their language.** Every internal link is a `LocaleLink` with the unprefixed path;
70
+ outside JSX, `localizedPath(path, binding)`. The language switcher uses plain anchors with
71
+ `hrefLang`, so switching loads a new document.
72
+ - **`src/middleware.ts` is the only `Content-Language` emitter.** Every page under a language prefix
73
+ answers with exactly one `Content-Language: <xx>`; an unprefixed page answers with none — its
74
+ `<html lang>` states the language.
75
+ - **Metadata is composed, never hand-built.** Every route's `generateMetadata` returns
76
+ `routeMetadata(...)`; the root layout returns `shellMetadata(...)`; an entity's title, description
77
+ and social image come from `entityMetadata(...)`. Canonical, hreflang, `x-default`, robots and
78
+ social tags are produced together and cannot disagree.
79
+ - **Product URLs resolve themselves.** `resolveProductPath` answers a stale slug, a renamed
80
+ product's previous slug and another language's slug with one 308 to the current address. Product
81
+ renames never need a redirect entry.
82
+ - **The XML sitemap** is `/sitemap.xml`, built by `src/app/sitemap.ts` from `STATIC_ROUTES`, the
83
+ fragments in `src/seo/routes.d/`, the published pages under `/pages/<route>` and the catalog.
84
+ - **`robots.txt`** is built by `src/app/robots.ts` from `NOINDEX_PATHS` and `NOINDEX_QUERY_KEYS`.
85
+ - **One noindex mechanism:** the metadata `robots` field. No `X-Robots-Tag` header anywhere.
86
+ - **Structured data** is Product + BreadcrumbList on a product page and exactly one BlogPosting on a
87
+ blog post, nothing else. Organization, WebSite, logo, `sameAs` and SearchAction are refused.
88
+ - **Rename redirects** live in `REDIRECTS` in `src/seo/redirects.ts` and work without a restart.
89
+ - **Preview posture.** A deployment without `FORGECART_PUBLIC_ORIGIN` — every preview — marks every
90
+ page noindex, emits relative canonicals, serves `robots.txt` as `Disallow: /` and an empty XML
91
+ sitemap. A published deployment gets its origin from `forgecart init --public-origin`.
92
+ - **One SDK client per language.** Changing the language of a shared client is the defect this
93
+ replaces.
94
+ - **Site verification.** The root layout reads the search-console verification placements with
95
+ `getSiteVerifications()` and passes them to `shellMetadata`, so every page serves them as
96
+ `<meta>` tags.
97
+
98
+ The platform files, and the symbols they export:
99
+
100
+ | File | What it gives you | Symbols |
101
+ | ------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
102
+ | `src/lib/locale/grammar.ts` | The URL grammar: default language unprefixed, others under `/<xx>/`, reserved segments | `isLocaleSegment`, `isUnprefixedPath`, `parseLocalePath`, `stripLocalePrefix`, `localePrefixedPath` |
103
+ | `src/lib/locale/middleware-plan.ts` | The locale-rewrite decision the middleware applies | `planLocaleRewrite`, `localeRequestHeaders`, `hasServerAuthoredHeaders`, `LOCALE_REQUEST_HEADER` |
104
+ | `src/middleware.ts` | Rename redirects, the locale rewrite, the `Content-Language` header, visitor and campaign cookies | `middleware`, `config` |
105
+ | `src/lib/locale/request-binding.ts` | The request's language, resolved once per request | `getRequestLocale`, `RequestLocale` |
106
+ | `src/lib/locale/request-locale.ts` | Render, 308 to the unprefixed default, or 404 for a language the channel does not offer | `decideRequestLocale`, `LocaleDecision` |
107
+ | `src/lib/locale/localized-path.ts` | Paths in the current language, and the switcher's target | `localizedPath`, `switchedLocalePath`, `LocaleBinding` |
108
+ | `src/components/LocaleLink.tsx` | The internal link | `LocaleLink`, `LocaleLinkProps` |
109
+ | `src/components/LanguageSwitcher.tsx` | The language switcher | `LanguageSwitcher` |
110
+ | `src/lib/seo/metadata.ts` | Title, description, canonical, hreflang, robots, social and verification tags, composed | `routeMetadata`, `shellMetadata`, `entityMetadata`, `buildMetadata`, `buildShellMetadata`, `pageTitle`, `SiteVerificationMeta` |
111
+ | `src/lib/seo/alternates.ts` | Self-canonical and reciprocal hreflang with `x-default` | `buildAlternates`, `staticPathsByLocale`, `PathsByLocale` |
112
+ | `src/lib/seo/resolve-path.ts` | The product slug law: 308 for stale, previous and other-language slugs | `resolveProductPath`, `productPathsByLocale`, `PathResolution` |
113
+ | `src/lib/seo/noindex.ts` | Which pages and query states are noindex | `NOINDEX_PATHS`, `NOINDEX_QUERY_KEYS`, `shouldNoindex`, `routeRobots`, `deploymentRefusesIndexing`, `NOINDEX_ROBOTS` |
114
+ | `src/lib/seo/public-origin.ts` | The deployment's public origin, absent on previews | `getPublicOrigin`, `resolvePublicOrigin` |
115
+ | `src/app/sitemap.ts` | The XML sitemap at `/sitemap.xml` | `sitemap`, `dynamic` |
116
+ | `src/lib/seo/sitemap-entries.ts` | The route table and the rules for the XML sitemap's rows | `STATIC_ROUTES`, `StaticRoute`, `SCAFFOLDED_ROUTES_DIR`, `mergeScaffoldedRoutes`, `pageSitemapRoutes`, `MAX_SITEMAP_URLS` |
117
+ | `src/lib/seo/scaffolded-routes.ts` | Reads the `src/seo/routes.d/*.json` fragments | `readScaffoldedRoutes` |
118
+ | `src/app/robots.ts` | `robots.txt` | `robots`, `dynamic` |
119
+ | `src/lib/seo/json-ld.ts` | Product, BreadcrumbList and BlogPosting nodes, and their safe serialization | `productJsonLd`, `breadcrumbJsonLd`, `blogPostingJsonLd`, `serializeJsonLd`, `JsonLdNode` |
120
+ | `src/seo/redirects.ts` | The rename-redirect table you fill | `REDIRECTS`, `RedirectRule` |
121
+ | `src/lib/seo/redirect-plan.ts` | The redirect rule: exact match, chains collapsed, cycles refused | `planRedirect`, `MAX_REDIRECT_HOPS` |
122
+ | `src/lib/seo/site-verification.ts` | The search-console verification placements, read per request | `getSiteVerifications` |
123
+ | `src/lib/forgecart.ts` | One shop client per language, and the reads the XML sitemap and the product page use | `getShopClientForLocale`, `getShopClient`, `getProductBySlug`, `ProductWithSeo`, `getProductSeoEntries`, `getServedPageRoutes` |
124
+ | `src/app/layout.tsx` | `<html lang>`, the 308 and 404 decisions, and the metadata floor every page inherits | `generateMetadata`, `RootLayout` |
125
+ | `src/app/products/[slug]/page.tsx` | The product page: slug law, entity metadata, Product and BreadcrumbList | `generateMetadata`, `ProductDetailPage` |
126
+ | `src/lib/asset-alt.ts` | The `alt` text of an asset image | `getAssetAlt` |
127
+
128
+ ## The deletion list
129
+
130
+ Each class below is improvised code that the platform now provides. For every class:
131
+
132
+ 1. Find it with the sweep rows the class names.
133
+ 2. Delete the files listed under **Delete:** — under whatever name this storefront used.
134
+ 3. Remove the code listed under **Remove:** from the files that stay.
135
+ 4. Use what **Replaced by:** names instead.
136
+ 5. Add every deleted path to the DONE summary's `Deleted:` list.
137
+
138
+ **Who deletes.** The platform's storefront agent cannot delete files yet: its file tools are
139
+ `Read`, `Write`, `Edit`, `Glob` and `Grep`. A `DeleteFile` storefront tool is a tracked follow-up.
140
+ Until it ships, the **Delete:** steps are carried out by an agent or operator with file-deletion
141
+ capability on the workspace. Before the promote that carries the deletions, confirm with `Glob`
142
+ that every path on your `Deleted:` list is gone.
143
+
144
+ Rules that apply to every class:
145
+
146
+ - **Replace, never keep both.** An improvised helper left beside the platform one is a second source
147
+ of truth, and the next change calls the wrong one.
148
+ - **Never delete a platform file.** Every platform file appears in exactly one list of the refresh
149
+ report (`rendered`, `conflicts`, `adopted`, `upToDate`). A path on any of those lists is merged —
150
+ see the merge steps — never deleted.
151
+ - **Delete a file only when nothing imports it.** Grep for its module path first and move every
152
+ importer onto the platform helper in the same change.
153
+
154
+ ### The sweep
155
+
156
+ Run every row on the whole storefront. `Grep` rows are extended regular expressions (the Grep tool
157
+ runs `grep -E`); escape them as JSON strings when you pass them. `Glob` rows are file patterns from
158
+ the storefront root. The platform matches listed on each row are platform files that match on
159
+ purpose — leave them. Every other match is improvised code this migration removes.
160
+
161
+ - **S1** Glob `middleware.*` — platform matches: none
162
+ - **S2** Glob `src/middleware.*` — platform matches: `src/middleware.ts`
163
+ - **S3** Glob `proxy.*` — platform matches: none
164
+ - **S4** Glob `src/proxy.*` — platform matches: none
165
+ - **S5** Grep `NextResponse\.rewrite` in `src` — platform matches: `src/middleware.ts`
166
+ - **S6** Grep `(function|const) (localizedPath|withLocale|buildHref|localizeHref|localePath)` in `src` — platform matches: `src/lib/locale/localized-path.ts`
167
+ - **S7** Grep `/\$\{(lang|locale|language)\}` in `src` — platform matches: `src/lib/locale/grammar.ts`
168
+ - **S8** Grep `from ['"]next/link['"]` in `src` — platform matches: `src/components/LocaleLink.tsx`
169
+ - **S9** Glob `src/lib/seo.*` — platform matches: none
170
+ - **S10** Grep `<link rel=['"](canonical|alternate)` in `src` — platform matches: `src/components/LanguageSwitcher.tsx`, `src/lib/seo/json-ld.ts`
171
+ - **S11** Grep `<html lang=['"]` in `src` — platform matches: `src/app/global-error.tsx`, `src/lib/locale/channel-locales-loader.ts`
172
+ - **S12** Glob `src/app/sitemap*` — platform matches: `src/app/sitemap.ts`
173
+ - **S13** Glob `public/sitemap*` — platform matches: none
174
+ - **S14** Glob `next-sitemap.config.*` — platform matches: none
175
+ - **S15** Glob `src/app/robots*` — platform matches: `src/app/robots.ts`
176
+ - **S16** Glob `public/robots.txt` — platform matches: none
177
+ - **S17** Grep `(redirects|headers)(\(\) *\{| *:)|i18n *:` in `next.config.js` — platform matches: none
178
+ - **S18** Grep `Response\.redirect\(` in `src` — platform matches: `src/middleware.ts`
179
+ - **S19** Grep `['"]Content-Language['"]` in `src` — platform matches: `src/middleware.ts`
180
+ - **S20** Grep `[hH]ttp-?[eE]quiv` in `src` — platform matches: none
181
+ - **S21** Grep `[?&](lang|locale)=` in `src` — platform matches: none
182
+ - **S22** Grep `[pP]arams\.(lang|locale)|\.get\(['"][A-Za-z_-]*(lang|locale|language)['"]\)` in `src` — platform matches: none
183
+ - **S23** Glob `src/components/language-switcher.*` — platform matches: none
184
+ - **S24** Grep `[Aa]ccept-[Ll]anguage|NEXT_LOCALE` in `src` — platform matches: none
185
+ - **S25** Grep `application/ld\+json` in `src` — platform matches: `src/app/products/[slug]/page.tsx`, `src/lib/seo/json-ld.ts`
186
+ - **S26** Grep `['"]@type['"]: *['"](Organization|WebSite)` in `src` — platform matches: none
187
+ - **S27** Grep `\.setLanguageCode\(` in `src` — platform matches: `src/lib/shop-session.ts`
188
+ - **S28** Grep `['"]X-Robots-Tag['"]` in `src` — platform matches: none
189
+
190
+ One check is by inspection: Glob `src/app/*` and read the top-level route directories. A
191
+ `[locale]`, `[lang]` or two-letter directory (`src/app/de/`) belongs to the `[locale]` class below.
192
+
193
+ ### Improvised middleware locale stages
194
+
195
+ - **Find:** S1–S5. A second middleware or proxy file, or a rewrite outside `buildBaseResponse` in
196
+ `src/middleware.ts`.
197
+ - **Delete:** `middleware.ts`, `middleware.js`, `src/middleware.js`, `proxy.ts`, `src/proxy.ts`
198
+ - **Remove:** a language rewrite, redirect or matcher inside `src/middleware.ts` — by merging that
199
+ file (merge steps).
200
+ - **Replaced by:** the platform `src/middleware.ts` and `planLocaleRewrite`. That file is merged,
201
+ never deleted: it also carries the rename redirects and the visitor and campaign cookies.
202
+
203
+ ### `localizedPath` twins and bare links
204
+
205
+ - **Find:** S6–S8. A link helper defined outside the platform files, a path built by hand as
206
+ `/${locale}/…`, or `next/link` imported anywhere but `LocaleLink`.
207
+ - **Delete:** `src/lib/localized-path.ts`
208
+ - **Remove:** hand-prefixed hrefs and bare `next/link` imports in components that stay.
209
+ - **Replaced by:** `LocaleLink` in JSX (`href` is the unprefixed path, `locale` is the request's
210
+ `LocaleBinding`) and `localizedPath(path, binding)` outside JSX. Trap: the platform file
211
+ `src/lib/locale/localized-path.ts` has the same basename as the usual twin. Keep it.
212
+
213
+ ### Hand-written `seo.ts` metadata helpers
214
+
215
+ - **Find:** S9–S11. A metadata helper module, hand-written head tags, a literal `<html lang>`.
216
+ - **Delete:** `src/lib/seo.ts`
217
+ - **Remove:** hand-written `<link rel="canonical">` and `<link rel="alternate" hreflang>` tags,
218
+ static `export const metadata` objects, and `alternates`, `canonical` or `robots` values built
219
+ by hand in a route's metadata.
220
+ - **Replaced by:** `routeMetadata(...)` in every route's `generateMetadata`, `shellMetadata(...)` in
221
+ the root layout, and `entityMetadata(...)` for an entity (`src/lib/seo/metadata.ts`).
222
+
223
+ ### XML sitemap emitters other than `src/app/sitemap.ts`
224
+
225
+ - **Find:** S12–S14.
226
+ - **Delete:** `src/app/sitemap.xml/route.ts`, `public/sitemap.xml`, `next-sitemap.config.js`
227
+ - **Remove:** a `postbuild` script that runs `next-sitemap`, and the `next-sitemap` dependency (see
228
+ `package.json` in the merge steps).
229
+ - **Replaced by:** `src/app/sitemap.ts`. A route handler at `src/app/sitemap.xml/route.ts` claims
230
+ the same URL as that file and must go.
231
+
232
+ ### `robots.txt` files other than `src/app/robots.ts`
233
+
234
+ - **Find:** S15–S16.
235
+ - **Delete:** `public/robots.txt`, `src/app/robots.txt/route.ts`
236
+ - **Replaced by:** `src/app/robots.ts`. To keep a page out of search, make it noindex (see the
237
+ static pages below); never hand-edit a disallow list.
238
+
239
+ ### Ad-hoc rename redirects and Pages Router `i18n`
240
+
241
+ - **Find:** S17–S18. `redirects()` or an `i18n` key in `next.config.js`, a redirect map in the old
242
+ middleware, a route handler that only redirects a moved page.
243
+ - **Delete:** `src/app/<old-path>/route.ts`
244
+ - **Remove:** the `redirects()` function and the `i18n` key from `next.config.js` (merge steps).
245
+ - **Replaced by:** one `REDIRECTS` entry per moved page (see the rename redirects below) and the
246
+ locale grammar.
247
+
248
+ ### Duplicate `Content-Language` emitters
249
+
250
+ - **Find:** S17, S19–S20.
251
+ - **Delete:** no file.
252
+ - **Remove:** every `Content-Language` header set outside the rewrite branch of `src/middleware.ts`
253
+ — a `headers()` entry in `next.config.js`, a second `headers.set('Content-Language', …)`, a route
254
+ handler — and every `<meta httpEquiv="content-language">`.
255
+ - **Replaced by:** the rewrite branch of `src/middleware.ts` (`buildBaseResponse`). Do not add a
256
+ header to unprefixed pages: they carry none by design, and a second emitter produces two header
257
+ lines, which the promote gate reports as `SEO_DUPLICATE_LOCALE_HEADER`.
258
+
259
+ ### `?lang=` and `?locale=` switchers
260
+
261
+ - **Find:** S21–S23.
262
+ - **Delete:** `src/components/language-switcher.tsx`
263
+ - **Remove:** every read of a `lang` or `locale` query parameter, and every link or `router.push`
264
+ that sets one.
265
+ - **Replaced by:** `LanguageSwitcher` (`src/components/LanguageSwitcher.tsx`), which
266
+ `src/components/Header.tsx` already renders. Trap: `language-switcher.tsx` and
267
+ `LanguageSwitcher.tsx` differ only in letter case. Delete the lowercase file by its exact path;
268
+ the platform file exports `LanguageSwitcher` and imports `switchedLocalePath`.
269
+
270
+ ### Language state outside the URL
271
+
272
+ - **Find:** S24, plus any cookie or `localStorage` entry that stores the language and any redirect
273
+ of `/` by language.
274
+ - **Delete:** no file.
275
+ - **Remove:** the cookie and storage reads and writes, the `Accept-Language` negotiation, the
276
+ redirect.
277
+ - **Replaced by:** the URL: the default language unprefixed, every other language under `/<xx>/`.
278
+
279
+ ### `[locale]` segments and per-language route copies
280
+
281
+ - **Find:** the inspection of `src/app/*` above.
282
+ - **Delete:** `src/app/[locale]/`, `src/app/[lang]/`, `src/app/de/`
283
+ - **Remove:** `generateStaticParams` over languages in the pages you keep.
284
+ - **Replaced by:** one route per page in the flat tree: `src/app/our-story/page.tsx` serves
285
+ `/our-story` and, through the middleware rewrite, `/de/our-story`. Move each page to its
286
+ unprefixed route before you delete the segment, and merge per-language copies into that one route:
287
+ it reads its copy in the request's language (`getRequestLocale()`). A route left in a two-letter
288
+ directory is reported as `SEO_RESERVED_LOCALE_SEGMENT`.
289
+
290
+ ### Hand-built JSON-LD
291
+
292
+ - **Find:** S25–S26.
293
+ - **Delete:** no file.
294
+ - **Remove:** every hand-built `<script type="application/ld+json">` and every Organization,
295
+ WebSite, logo, `sameAs` or SearchAction node.
296
+ - **Replaced by:** `productJsonLd`, `breadcrumbJsonLd`, `blogPostingJsonLd` and `serializeJsonLd`
297
+ (`src/lib/seo/json-ld.ts`). Anything else is reported as `SEO_FORBIDDEN_JSONLD`.
298
+
299
+ ### Language set on a shared SDK client
300
+
301
+ - **Find:** S27.
302
+ - **Delete:** no file.
303
+ - **Remove:** every `setLanguageCode(` call on a server-side client, and every module-level client
304
+ shared across languages.
305
+ - **Replaced by:** `getShopClient()` for the request's language and `getShopClientForLocale(locale)`
306
+ for an explicit one (`src/lib/forgecart.ts`). Trap: `src/lib/shop-session.ts` calls
307
+ `client.setLanguageCode(...)` on the shopper's browser session client when the language changes.
308
+ That is platform code; leave it.
309
+
310
+ ### A second noindex mechanism
311
+
312
+ - **Find:** S17, S28.
313
+ - **Delete:** no file.
314
+ - **Remove:** every `X-Robots-Tag` header.
315
+ - **Replaced by:** the metadata `robots` field, which `routeMetadata` sets from `NOINDEX_PATHS`,
316
+ `NOINDEX_QUERY_KEYS`, the deployment's posture and the route's `contentIndexable`.
317
+
318
+ ## Merge steps per platform file — replace, don't add
319
+
320
+ Every file the refresh listed under `conflicts` is merged with this procedure:
321
+
322
+ 1. **Read it and list what the merchant customized:** markup, copy, styling, extra components,
323
+ extra helpers, non-SEO settings. Anything the deletion list covers is not a customization — it is
324
+ what the migration replaces.
325
+ 2. **Adopt it** — take the platform version:
326
+ `CallAdminOperation { "name": "workspaceTemplate.adminRefreshWorkspaceTemplate", "args": { "input": { "adopt": ["<path>"] } } }`.
327
+ Adopt a file BEFORE you edit it in the turn, never after: adopting writes the live file at once,
328
+ and an edit made before it was based on the old content, so the promote reports it as a conflict.
329
+ Adopt several files in one call when you can.
330
+ 3. **Re-apply the customizations from step 1 onto the platform version**, through the platform
331
+ helpers: links as `LocaleLink`, metadata through `routeMetadata`, structured data through
332
+ `json-ld.ts`. Never paste the old implementation back beside the new one.
333
+ 4. **Record it.** With nothing to re-apply, the file is "Adopted as-is". Otherwise it is "Merged".
334
+
335
+ A merged file keeps appearing under `conflicts` in every later refresh: it now differs from the
336
+ platform version on purpose. An adopted-as-is file appears under `upToDate`.
337
+
338
+ ### `src/middleware.ts`
339
+
340
+ - This is the one middleware. Keep the platform order inside `middleware()`: `planRedirect(...)`
341
+ with `REDIRECTS` first (308), then `planLocaleRewrite`, then the visitor and campaign cookie
342
+ stages.
343
+ - Merchant logic that is not about language or SEO (an access gate, extra response headers) goes
344
+ inside `middleware()` after the redirect stage. It never rewrites or redirects by language.
345
+ - Keep the platform `export const config` matcher. Never add a second `config`.
346
+ - Never set `Content-Language` or `X-Robots-Tag` here or anywhere else.
347
+ - A rename redirect the old middleware carried becomes a `REDIRECTS` entry, not code here.
348
+
349
+ ### `src/app/layout.tsx`
350
+
351
+ - `RootLayout` stays `async` and reads `await getRequestLocale()`. It raises
352
+ `permanentRedirect(decision.to)` and `notFound()` in the shell, before any markup.
353
+ - `<html lang={binding.locale}>` — never a literal language.
354
+ - `generateMetadata` returns `shellMetadata({ shopName, channelResolved, siteVerifications })` with
355
+ `siteVerifications` from `await getSiteVerifications()`. No static `export const metadata`.
356
+ - Keep the development-only revision stamp: it is merged into `other`, which carries the
357
+ verification tags — never overwrite `other`.
358
+ - No `<Suspense>` above `<body>`. Keep `CartProvider` with
359
+ `getBrowserShopConfig(binding.locale)` and `<Header locale={binding} languageCodes={languageCodes} />`.
360
+ - Re-apply the merchant's header and footer chrome and copy; every internal link is
361
+ `<LocaleLink href="/unprefixed-path" locale={binding}>`.
362
+
363
+ ### `src/app/products/[slug]/page.tsx`
364
+
365
+ - Keep the slug law in the page shell: `resolveProductPath({ requestedSlug, product, binding })`,
366
+ `notFound()` for a missing product, `permanentRedirect(resolution.to)` for a redirect.
367
+ - Keep the one product read shared by `generateMetadata` and the page (`productForRequest`).
368
+ - `generateMetadata` returns `routeMetadata` with `pathsByLocale: productPathsByLocale(product)`,
369
+ title, description and social image from `entityMetadata(product, binding.locale)`, and
370
+ `contentIndexable` from the resolution.
371
+ - Structured data only through `productJsonLd`, `breadcrumbJsonLd` and `serializeJsonLd`, gated on
372
+ `routeRobots` exactly as the platform file does it.
373
+ - Re-apply the merchant's display markup inside `ProductDetail`: exactly one `<h1>`, images with
374
+ `alt={getAssetAlt(asset)}`.
375
+ - Delete language-specific slug lookups: the shop API resolves slugs per language, including a
376
+ product's previous slug.
377
+
378
+ ### `src/lib/forgecart.ts`
379
+
380
+ - One shop client per language: `getShopClient()` for the request's language,
381
+ `getShopClientForLocale(locale)` for an explicit one. No module-level shared client, no
382
+ `setLanguageCode(`.
383
+ - Keep `getProductBySlug` returning `ProductWithSeo`, `getProductSeoEntries` and
384
+ `getServedPageRoutes`: `src/app/sitemap.ts` and the product page import them.
385
+ - Re-apply merchant reads as functions on `await getShopClient()`, through the SDK's typed methods.
386
+
387
+ ### `next.config.js`
388
+
389
+ - Remove `redirects()` (its entries become `REDIRECTS` entries), every `headers()` entry that sets
390
+ `Content-Language` or `X-Robots-Tag`, and the Pages Router `i18n` key.
391
+ - Keep `cacheComponents: false` as an explicit key. Next re-enables an absent key, and with the
392
+ option on, the root layout can no longer answer its 308 and 404.
393
+ - Keep the platform's `output`, `serverExternalPackages`, `allowedDevOrigins`, `experimental` and
394
+ `turbopack` settings.
395
+ - Re-apply merchant settings that are not about language or SEO — for example a narrower
396
+ `images.remotePatterns`.
397
+
398
+ ### Every other file in `conflicts`
399
+
400
+ Typical ones: `src/app/page.tsx`, `src/app/products/page.tsx`, `src/components/Header.tsx`,
401
+ `src/components/ProductCard.tsx`, `package.json`. The same procedure applies:
402
+
403
+ - links as `LocaleLink`; route metadata through `routeMetadata` with
404
+ `staticPathsByLocale(languageCodes, '<route>')`; images with `alt={getAssetAlt(asset)}`;
405
+ - `package.json`: adopt it. Adopting keeps every package the storefront declares that the platform
406
+ version does not, and takes the platform's range for a package both declare. Then delete the
407
+ packages the deletion list removed, such as `next-sitemap`, from it; the promote reinstalls the
408
+ dependencies. A `package.json` that still declares a package of the storefront's is Merged, with
409
+ those packages as its customizations.
410
+
411
+ ### Routes the platform does not ship
412
+
413
+ A merchant route (for example `src/app/our-story/page.tsx`) is not in the refresh report. Migrate
414
+ it in place: `generateMetadata` returns `routeMetadata(...)`, every internal link is a
415
+ `LocaleLink`, the page has exactly one `<h1>`, and it joins the XML sitemap (next section). A route
416
+ that sets `other` in its metadata passes `siteVerifications: await getSiteVerifications()` to
417
+ `routeMetadata` and merges its own keys into the returned `other` — otherwise it replaces the
418
+ verification tags the root layout serves.
419
+
420
+ ### The data tables — never adopt them
421
+
422
+ `src/seo/redirects.ts` (`REDIRECTS`) and `src/lib/seo/sitemap-entries.ts` (`STATIC_ROUTES`) are
423
+ tables. Never adopt either once it carries a row you or the merchant added: adopting replaces the
424
+ table with the platform's. `src/seo/redirects.ts` with your rows appears under `conflicts` in every
425
+ later refresh — expected. Do not add rows to `STATIC_ROUTES` in this migration: pages join the XML
426
+ sitemap through fragments (next section), which keeps `src/lib/seo/sitemap-entries.ts` identical to
427
+ the platform version, so later refreshes keep updating it.
428
+
429
+ ## Existing rename redirects and static pages
430
+
431
+ ### Rename redirects
432
+
433
+ 1. Collect every rename the old storefront served: `redirects()` in `next.config.js`, redirect
434
+ maps in the old middleware, route handlers that only redirect a moved page (sweep rows S17, S18).
435
+ 2. For each moved page, add one entry to `REDIRECTS` in `src/seo/redirects.ts`:
436
+ `{ from: '/old-path', to: '/new-path' }`.
437
+ - Paths are rooted and unprefixed. `/old-path` covers `/de/old-path` too, and each redirects
438
+ inside its own language. Never write a prefixed path: it covers one language only.
439
+ - Matching is exact: one entry per moved page. Chains collapse into one 308, and cycles are
440
+ refused.
441
+ - Routes that exist once per store (`/sitemap.xml`, `/robots.txt`, `/api/…`, `/ping`,
442
+ `/verify`, `/reset-password`) are never redirected.
443
+ 3. **Product renames are never `REDIRECTS` entries.** The shop API keeps a product's previous slug,
444
+ and `resolveProductPath` answers it with a 308. Drop the old product redirect instead of moving
445
+ it.
446
+ 4. Remove the old mechanism in the same change (deletion list).
447
+ 5. A page that only moved out of a `[locale]` segment or a two-letter directory keeps its URLs —
448
+ `/de/our-story` is now served by `src/app/our-story/page.tsx` through the rewrite — and needs no
449
+ entry. A default-language URL the old storefront served under a prefix (`/en/our-story`) is
450
+ redirected by the platform; it needs no entry either.
451
+
452
+ ### Static pages
453
+
454
+ 1. Every page that is not a platform file and not an ACF page joins the XML sitemap with one
455
+ fragment file, `src/seo/routes.d/<page>.json`:
456
+
457
+ ```json
458
+ { "path": "/our-story", "indexable": true }
459
+ ```
460
+
461
+ - `path` is rooted and unprefixed. One file per page; the file name names the page.
462
+ - A malformed fragment is skipped and named in the server log.
463
+ - A path already in `STATIC_ROUTES` keeps the platform's row; a fragment cannot override it.
464
+
465
+ 2. A session or funnel page (sign-in, account, order status) gets `"indexable": false` in its
466
+ fragment and `contentIndexable: false` in its `routeMetadata(...)`.
467
+ 3. The page's `generateMetadata` has the shape of the one in `src/app/products/page.tsx`:
468
+
469
+ ```ts
470
+ export async function generateMetadata({
471
+ searchParams,
472
+ }: {
473
+ searchParams: Promise<RouteSearchParams>;
474
+ }): Promise<Metadata> {
475
+ const [resolvedSearchParams, { binding, languageCodes, shopName, channelResolved }] =
476
+ await Promise.all([searchParams, getRequestLocale()]);
477
+ return routeMetadata({
478
+ binding,
479
+ shopName,
480
+ channelResolved,
481
+ pathname: '/our-story',
482
+ pathsByLocale: staticPathsByLocale(languageCodes, '/our-story'),
483
+ searchParams: resolvedSearchParams,
484
+ title: 'Our story',
485
+ });
486
+ }
487
+ ```
488
+
489
+ 4. ACF pages under `/pages/<route>` join the XML sitemap by themselves. Write no fragment for them.
490
+ 5. A page in a two-letter top-level directory moves to a name of three letters or more, with a
491
+ `REDIRECTS` entry for the old path.
492
+
493
+ ### SEO source mapping for imported page-kind definitions
494
+
495
+ A route that renders the records of a page-kind definition the merchant imported — a blog, a
496
+ journal, a lookbook, for example `src/app/blog/[slug]/page.tsx` — maps its SEO sources at the route:
497
+ its `generateMetadata` passes the record's own fields, in the request's language, to
498
+ `routeMetadata`. The target is no `SEO_UNMAPPED_PAGE_KIND` on those routes.
499
+
500
+ ACF pages the platform serves at `/pages/<route>` render the page's name as their title and no
501
+ description, so they report `SEO_UNMAPPED_PAGE_KIND` (a WARN) whatever you do in this migration. Do
502
+ not edit `src/app/pages/[slug]/page.tsx` to silence it; list those routes under Remaining WARNs.
503
+
504
+ Sign off every imported definition against this table:
505
+
506
+ | Element | Where it comes from | How you check it |
507
+ | ---------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
508
+ | Title tag | `routeMetadata({ title })` from the record's title field; the shop name is appended | `<title>` on every language's URL; `SEO_TITLE_MISSING` |
509
+ | Meta description | `routeMetadata({ description })` from the record's excerpt field — never the body | `<meta name="description">` on every language's URL; `SEO_UNMAPPED_PAGE_KIND` |
510
+ | H1 | Exactly one `<h1>`, carrying the text the title comes from | `SEO_H1_MISSING`, `SEO_H1_DUPLICATE` |
511
+ | Slug | The route path, unprefixed; a changed code-page path gets a `REDIRECTS` entry | `SEO_RENAME_WITHOUT_REDIRECT` |
512
+ | Excerpt (dek) | Rendered as the intro paragraph under the `<h1>`, and the same words as the description | The snapshot of each language's page |
513
+ | Social opt-in | `socialImage` only when the record has a stored social image; otherwise leave it out | No `og:*` or `twitter:*` tags without one |
514
+ | Canonical | Produced by `routeMetadata`; never written by hand | `SEO_CANONICAL_MISSING_OR_FOREIGN` on the published deployment |
515
+ | Author | `authorName` in `blogPostingJsonLd(...)` | The BlogPosting node |
516
+ | Dates | `publishedAt` and `updatedAt` in `blogPostingJsonLd(...)`: the shop API's ISO-8601 stamps | `SEO_SCHEMA_BLOGPOSTING_INVALID` |
517
+ | BlogPosting | Exactly one node per post, from `blogPostingJsonLd(...)` and `serializeJsonLd(...)` | `SEO_SCHEMA_BLOGPOSTING_INVALID` |
518
+ | Alt | `alt={getAssetAlt(asset)}` for asset images; an embed's own `alt` for body images | `SEO_IMG_ALT_MISSING` |
519
+ | Indexability | `contentIndexable` in `routeMetadata(...)`, and `indexable` in the page's fragment | `SEO_NOINDEX_MISSING` |
520
+
521
+ ### Alt-text backfill
522
+
523
+ Migrated media arrive with no alt text: an image rendered through `getAssetAlt(asset)` renders
524
+ `alt=""` until its asset is described.
525
+
526
+ 1. Describe each content image once, on its asset — one value for every language:
527
+ `CallAdminOperation { "name": "asset.updateAsset", "args": { "input": { "id": "<asset id>", "altText": "<what the image shows>" } } }`.
528
+ The operator can do the same in the dashboard's media library.
529
+ 2. Start with hero and featured images, then images inside bodies. An image embedded in a rich-text
530
+ body carries its own `alt`, per language, in that body.
531
+ 3. Decorative images stay `alt=""`. Never fill alt text with a file name or a product name.
532
+
533
+ Until the backfill is done, `SEO_IMG_ALT_MISSING` WARNs on the routes you change are expected and
534
+ do not block the migration. List the remaining ones in the DONE summary.
535
+
536
+ ## Verification — the crawler assertions and the per-locale browser round
537
+
538
+ Every step runs on the final tree, after the promote that applied it. Read the channel's languages
539
+ first: `CallAdminOperation { "name": "channel.channels", "args": { "options": { "take": 1 } } }`
540
+ returns `defaultLanguageCode` and `availableLanguageCodes`. The examples use `en` as the default and
541
+ `de` as another language.
542
+
543
+ ### 1. The sweep is clean
544
+
545
+ Run every sweep row (S1–S28) again. Each returns exactly its platform matches. Then check:
546
+
547
+ - every path on your `Deleted:` list is gone;
548
+ - no path on your `Deleted:` list appears in any list of the refresh report.
549
+
550
+ ### 2. The crawler assertions — the promote gate
551
+
552
+ Every promote runs the platform's SEO gate: it crawls the routes the promote changed, in every
553
+ language, on the storefront's own server, plus `/`, `/robots.txt` and `/sitemap.xml`. It is the
554
+ crawler that verifies this migration; `SEO_RED` rolls the promote back and names each finding with
555
+ its route.
556
+
557
+ - Findings on the routes the promote changed are RED. Findings on routes it did not change, on the
558
+ links out of `/`, and four codes on every route (`SEO_H1_MISSING`, `SEO_H1_DUPLICATE`,
559
+ `SEO_IMG_ALT_MISSING`, `SEO_UNMAPPED_PAGE_KIND`) are WARN. `/robots.txt` and `/sitemap.xml` are
560
+ always RED.
561
+ - It plans at most 25 changed routes per promote and names the ones it skipped. Migrate merchant
562
+ routes in batches of at most 25, after the promote that carries the deletions and the platform
563
+ merges.
564
+ - **The preview asserts nothing about its address.** It has no public origin — whether it is the
565
+ local `<code>.127.0.0.1.nip.io:8081` host or a hosted preview — so every page is noindex, canonicals
566
+ are relative, `robots.txt` is `Disallow: /` and the XML sitemap is empty. That is correct. The
567
+ gate therefore SKIPS `SEO_CANONICAL_MISSING_OR_FOREIGN`, `SEO_HREFLANG_SET_INCOMPLETE`,
568
+ `SEO_XDEFAULT_MISSING` and `SEO_SITEMAP_ROUTE_MISSING` there and prints why. A skip is not a pass:
569
+ step 6 checks them on the published deployment. A wrong claim is still reported on the preview —
570
+ a foreign canonical, a `?lang=` hreflang, a set that is not reciprocated.
571
+ - Fix a RED at the named route before anything else, and never re-promote an unchanged tree hoping
572
+ it passes: the gate is deterministic.
573
+
574
+ | Code | Severity on a changed route | What it means after a migration | Fix |
575
+ | ---------------------------------- | -------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------- |
576
+ | `SEO_HTML_LANG_MISMATCH` | RED | `<html lang>` is a literal or ignores the URL's language | `<html lang={binding.locale}>` in `src/app/layout.tsx` |
577
+ | `SEO_CANONICAL_MISSING_OR_FOREIGN` | RED | A hand-written or missing canonical | `routeMetadata(...)`; delete the hand-written tag |
578
+ | `SEO_HREFLANG_NOT_RECIPROCAL` | RED | Language versions list different alternate sets | `staticPathsByLocale` or `productPathsByLocale` for `pathsByLocale` |
579
+ | `SEO_HREFLANG_SET_INCOMPLETE` | RED on catalog routes, else WARN | The alternate set omits a language the store serves | Build `pathsByLocale` from `languageCodes` |
580
+ | `SEO_HREFLANG_QUERY_PARAM` | RED | An alternate points at `?lang=` | The deletion list's `?lang=` class |
581
+ | `SEO_XDEFAULT_MISSING` | RED on catalog routes, else WARN | Hand-built alternates without `x-default` | `routeMetadata(...)` |
582
+ | `SEO_LOCALE_PREFIX_NOT_REDIRECTED` | RED | `/en/…` answers 200: an improvised stage or segment serves it | Delete the improvised stage or `[locale]` segment |
583
+ | `SEO_LOCALE_ORPHAN_LINK` | RED | A link leaves its language: a hand-built or bare link remains | `LocaleLink` or `localizedPath` |
584
+ | `SEO_TITLE_MISSING` | RED | A route emits no title | `generateMetadata` returning `routeMetadata({ title })` |
585
+ | `SEO_RESERVED_LOCALE_SEGMENT` | RED | A route sits in a two-letter or infrastructure directory | Move it to a longer name, with a `REDIRECTS` entry |
586
+ | `SEO_RENAME_WITHOUT_REDIRECT` | RED | A moved or removed route has no redirect | A `REDIRECTS` entry |
587
+ | `SEO_NOINDEX_MISSING` | RED | A session, funnel or query-state page is indexable | `contentIndexable: false`; delete a second noindex mechanism |
588
+ | `SEO_FORBIDDEN_JSONLD` | RED | Hand-built or forbidden structured data | `json-ld.ts` builders only |
589
+ | `SEO_SITEMAP_INVALID` | RED | An improvised emitter answers `/sitemap.xml`, or it fails | Delete the emitter; merge `src/lib/forgecart.ts` |
590
+ | `SEO_SITEMAP_ROUTE_MISSING` | RED | An indexable route has no XML sitemap row | A `src/seo/routes.d/<page>.json` fragment |
591
+ | `SEO_ROUTE_UNREACHABLE` | RED | A changed route does not answer 200 | `TailServerLogs`: usually an import of a deleted file or missing export |
592
+ | `SEO_DUPLICATE_LOCALE_HEADER` | RED | Two `Content-Language` values on one response | Delete the duplicate emitter |
593
+ | `SEO_SCHEMA_BLOGPOSTING_INVALID` | RED | A blog post's node is missing, doubled or unreadable | One `blogPostingJsonLd(...)` node |
594
+ | `SEO_H1_MISSING` | WARN | No `<h1>` | Exactly one `<h1>` |
595
+ | `SEO_H1_DUPLICATE` | WARN | More than one `<h1>` | Exactly one `<h1>` |
596
+ | `SEO_IMG_ALT_MISSING` | WARN | An image has no alt text | The alt-text backfill |
597
+ | `SEO_UNMAPPED_PAGE_KIND` | WARN | A page-kind route renders no description of its own | The route-level SEO source mapping |
598
+
599
+ ### 3. Route probes
600
+
601
+ `ProbeRoute` requests one route on the storefront's server without following redirects and reports
602
+ the status and the start of the body. Probe:
603
+
604
+ | Route | Expected |
605
+ | ---------------------------------------------------- | -------------------------------------------------------- |
606
+ | `/` | 200; the `<html` tag carries `lang="en"` |
607
+ | `/de` | 200; `lang="de"` |
608
+ | `/de/products` | 200; `lang="de"` |
609
+ | `/en/products` | 308 — the default language is never prefixed |
610
+ | `/zz/products`, with `zz` not offered | 404 |
611
+ | `/products?lang=de` | 200; `lang="en"` — a query parameter selects no language |
612
+ | Every `from` path in `REDIRECTS`, and its `/de` form | 308 |
613
+ | `/sitemap.xml` | 200; empty on the preview |
614
+ | `/robots.txt` | 200; `Disallow: /` on the preview |
615
+
616
+ `ProbeRoute` does not report headers. `Content-Language` is covered twice: in the source by sweep
617
+ row S19, whose only match is `src/middleware.ts`, and on the wire by the promote gate
618
+ (`SEO_DUPLICATE_LOCALE_HEADER`). The expected wire state: exactly one `Content-Language: de` on
619
+ `/de` and every `/de/…` page, and none on an unprefixed page.
620
+
621
+ ### 4. The per-locale browser round
622
+
623
+ After the promote — the preview serves the promoted tree — `browser_navigate` to every language's
624
+ URL of every route the migration touched: the default language unprefixed, every other language
625
+ under its prefix. For each one, `browser_snapshot` and confirm:
626
+
627
+ - the page shows that language's own copy, not the default language's;
628
+ - `html lang` equals that language;
629
+ - the language switcher's target is the same page in the other language;
630
+ - every internal link stays in the language: `/de/…` on a German page, unprefixed on a
631
+ default-language page.
632
+
633
+ ### 5. The refresh settles
634
+
635
+ After the last promote, call the refresh operation once more with an empty `adopt` list. Without
636
+ an `adopt` list it never overwrites a file that differs from the platform version. Expect:
637
+
638
+ - `rendered` and `adopted` are empty;
639
+ - `conflicts` is exactly your Merged list, plus `src/seo/redirects.ts` when you added redirects;
640
+ - every Adopted-as-is file and `SEO-MIGRATION.md` are in `upToDate`.
641
+
642
+ A file in `conflicts` that is not on your Merged list was never merged: merge it.
643
+
644
+ ### 6. Publish, then check the published deployment
645
+
646
+ Ask the operator to publish the storefront; an agent cannot deploy. On the published deployment:
647
+
648
+ - `robots.txt` names `Sitemap: <origin>/sitemap.xml` and disallows the noindex paths;
649
+ - `/sitemap.xml` lists every indexable route in every language;
650
+ - every indexable page carries an absolute canonical and a complete hreflang set with `x-default`;
651
+ - every page serves the search-console verification `<meta>` tags. Until the published deployment
652
+ serves them, the platform records `SEO_VERIFICATION_PLACEMENT_NOT_SERVED` for the connected
653
+ search console. That is expected and is not a failed verification (`SITE_NOT_VERIFIED`); it
654
+ clears on the platform's next sync after the publish.
655
+
656
+ ### How the platform tests this document
657
+
658
+ The `retrofit` phase, the last phase of the platform's storefront crawler harness
659
+ (`tool/storefront-verify`), follows this document on a storefront built by the real CLI. It turns
660
+ that storefront into an improvised pre-SEO one: ten defects drawn from the deletion list, and no
661
+ `.forgecart/template-manifest.json`, like a provisioned storefront. It runs the refresh with no
662
+ `adopt` list. It then applies a DONE summary in the form of the last section, in this document's
663
+ order: the deletions; one adopting refresh of the merged and adopted files, before any edit to
664
+ them; the re-applied customizations; a rename moved into `src/seo/redirects.ts`; a merchant page
665
+ migrated in place and joined by a `src/seo/routes.d/<page>.json` fragment; the final refresh. Its
666
+ 49 assertions run in three stages:
667
+
668
+ - `migrated` (5) reads the tree and the three refresh reports. The first refresh renders this
669
+ document and passes the applicability test above. The deletion list names every class the
670
+ storefront carried (`retrofit-the-document-names-every-deletion-class`). No improvisation
671
+ survives beside its replacement. The final refresh settles exactly as step 5 expects
672
+ (`retrofit-the-final-refresh-is-settled`).
673
+ - `served` (43) serves the migrated storefront with one `next dev` boot. There is one
674
+ `Content-Language` emitter, the migrated page stays in its language, `?lang=` selects nothing,
675
+ the moved rename answers 308 in every language, and the XML sitemap lists the migrated page with
676
+ the hreflang set the page emits. Every re-applied customization still acts, and the crawler's 37
677
+ locale and SEO assertions pass again, each as `retrofit:<id>`.
678
+ - `restored` (1) proves that the harness's workspace is back byte for byte, whether the phase
679
+ passed or failed (`retrofit-the-workspace-is-restored-byte-for-byte`).
680
+
681
+ It is a platform test: your workspace does not contain it, and nothing here asks you to run it.
682
+
683
+ ## The DONE summary lists the deleted files
684
+
685
+ Report the migration in exactly this form:
686
+
687
+ ```text
688
+ SEO migration — DONE
689
+ Deleted:
690
+ - <every removed path, one per line>
691
+ Merged (platform version taken, customizations re-applied):
692
+ - <path> — <the customizations re-applied>
693
+ Adopted as-is:
694
+ - <path>
695
+ Redirects added to src/seo/redirects.ts:
696
+ - /old-path → /new-path
697
+ Routes joined the XML sitemap:
698
+ - src/seo/routes.d/<page>.json — /<page>, indexable: true|false
699
+ Remaining WARNs:
700
+ - <CODE> at <url> — <why it remains>
701
+ ```
702
+
703
+ - The summary lists every deleted file: `Deleted:` names every path the migration removed, and
704
+ nothing else. `src/middleware.ts` and every other platform file belong under Merged or Adopted
705
+ as-is, never under `Deleted:`.
706
+ - A summary whose `Deleted:` list is empty has replaced nothing: either the sweep found no
707
+ improvised code — then say so and quote it — or the migration is not done.
708
+ - Write "none" under a heading that has no entries. Never leave a heading out.