@forgecart/cli 2.202610280649.0 → 2.202610280926.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 (218) hide show
  1. package/dist/src/commands/init-questions.d.ts +12 -11
  2. package/dist/src/commands/init-questions.js +15 -23
  3. package/dist/src/commands/init-questions.js.map +1 -1
  4. package/dist/src/commands/init.d.ts +13 -13
  5. package/dist/src/commands/init.js +75 -144
  6. package/dist/src/commands/init.js.map +1 -1
  7. package/dist/src/commands/refresh.d.ts +3 -3
  8. package/dist/src/commands/refresh.js +4 -14
  9. package/dist/src/commands/refresh.js.map +1 -1
  10. package/dist/src/commands/template-manifest.d.ts +2 -5
  11. package/dist/src/commands/template-manifest.js +1 -1
  12. package/dist/src/commands/template-manifest.js.map +1 -1
  13. package/dist/src/commands/type/init.input.d.ts +1 -1
  14. package/dist/src/commands/type/init.types.d.ts +4 -4
  15. package/dist/src/template/template-set.d.ts +17 -27
  16. package/dist/src/template/template-set.js +25 -42
  17. package/dist/src/template/template-set.js.map +1 -1
  18. package/dist/src/template/template-unavailable-error.d.ts +3 -4
  19. package/dist/src/template/template-unavailable-error.js +3 -4
  20. package/dist/src/template/template-unavailable-error.js.map +1 -1
  21. package/dist/src/template/type/template-set.types.d.ts +14 -17
  22. package/dist/src/template/type/template-set.types.js +6 -1
  23. package/dist/src/template/type/template-set.types.js.map +1 -1
  24. package/dist/src/template/util/template-identity.util.d.ts +1 -3
  25. package/dist/src/template/util/template-identity.util.js +0 -5
  26. package/dist/src/template/util/template-identity.util.js.map +1 -1
  27. package/package.json +1 -1
  28. package/templates/storefront-blank/.forgecartignore +2 -0
  29. package/templates/storefront-blank/Procfile +1 -0
  30. package/templates/storefront-blank/README.md +86 -0
  31. package/templates/storefront-blank/SEO-MIGRATION.md +718 -0
  32. package/templates/storefront-blank/_gitignore +21 -0
  33. package/templates/storefront-blank/next.config.js +105 -0
  34. package/templates/storefront-blank/package.json +35 -0
  35. package/templates/storefront-blank/postcss.config.js +5 -0
  36. package/templates/storefront-blank/src/app/%5F%5Ffc/identify/route.ts +205 -0
  37. package/templates/storefront-blank/src/app/%5F%5Ffc/track/route.ts +189 -0
  38. package/templates/storefront-blank/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  39. package/templates/storefront-blank/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  40. package/templates/storefront-blank/src/app/error.tsx +21 -0
  41. package/templates/storefront-blank/src/app/global-error.tsx +23 -0
  42. package/templates/storefront-blank/src/app/globals.css +110 -0
  43. package/templates/storefront-blank/src/app/layout.tsx +102 -0
  44. package/templates/storefront-blank/src/app/page.tsx +52 -0
  45. package/templates/storefront-blank/src/app/pages/[slug]/not-found.tsx +23 -0
  46. package/templates/storefront-blank/src/app/pages/[slug]/page.tsx +115 -0
  47. package/templates/storefront-blank/src/app/ping/route.ts +21 -0
  48. package/templates/storefront-blank/src/app/robots.ts +69 -0
  49. package/templates/storefront-blank/src/app/sitemap.ts +107 -0
  50. package/templates/storefront-blank/src/components/ForgeErrorBeacon.tsx +102 -0
  51. package/templates/storefront-blank/src/components/ForgeTracker.tsx +479 -0
  52. package/templates/storefront-blank/src/components/ForgecartDesigner.tsx +43 -0
  53. package/templates/storefront-blank/src/components/LocaleLink.tsx +49 -0
  54. package/templates/storefront-blank/src/instrumentation.ts +109 -0
  55. package/templates/storefront-blank/src/lib/account/account-link.ts +76 -0
  56. package/templates/storefront-blank/src/lib/account/account-state.ts +152 -0
  57. package/templates/storefront-blank/src/lib/account/register-state.ts +133 -0
  58. package/templates/storefront-blank/src/lib/account/reset-password-state.ts +111 -0
  59. package/templates/storefront-blank/src/lib/account/verify-state.ts +56 -0
  60. package/templates/storefront-blank/src/lib/account-actions.ts +76 -0
  61. package/templates/storefront-blank/src/lib/account-session.ts +114 -0
  62. package/templates/storefront-blank/src/lib/action-result.ts +30 -0
  63. package/templates/storefront-blank/src/lib/asset-alt.ts +34 -0
  64. package/templates/storefront-blank/src/lib/backend-actions.ts +20 -0
  65. package/templates/storefront-blank/src/lib/backend-client.ts +47 -0
  66. package/templates/storefront-blank/src/lib/cart-context.tsx +236 -0
  67. package/templates/storefront-blank/src/lib/checkout-session.ts +185 -0
  68. package/templates/storefront-blank/src/lib/content/page-metadata.ts +113 -0
  69. package/templates/storefront-blank/src/lib/content/render-fields.tsx +256 -0
  70. package/templates/storefront-blank/src/lib/content/resolve-page.ts +143 -0
  71. package/templates/storefront-blank/src/lib/error-messages.ts +118 -0
  72. package/templates/storefront-blank/src/lib/experiments.ts +333 -0
  73. package/templates/storefront-blank/src/lib/forgecart.ts +464 -0
  74. package/templates/storefront-blank/src/lib/format.ts +89 -0
  75. package/templates/storefront-blank/src/lib/identify-forward.ts +152 -0
  76. package/templates/storefront-blank/src/lib/locale/channel-locales-loader.ts +169 -0
  77. package/templates/storefront-blank/src/lib/locale/channel-locales-map.ts +46 -0
  78. package/templates/storefront-blank/src/lib/locale/channel-locales.ts +191 -0
  79. package/templates/storefront-blank/src/lib/locale/grammar.ts +194 -0
  80. package/templates/storefront-blank/src/lib/locale/localized-path.ts +55 -0
  81. package/templates/storefront-blank/src/lib/locale/middleware-plan.ts +107 -0
  82. package/templates/storefront-blank/src/lib/locale/request-binding.ts +80 -0
  83. package/templates/storefront-blank/src/lib/locale/request-locale.ts +66 -0
  84. package/templates/storefront-blank/src/lib/marketing-params.ts +213 -0
  85. package/templates/storefront-blank/src/lib/money.ts +50 -0
  86. package/templates/storefront-blank/src/lib/seo/alternates.ts +123 -0
  87. package/templates/storefront-blank/src/lib/seo/json-ld.ts +266 -0
  88. package/templates/storefront-blank/src/lib/seo/metadata.ts +419 -0
  89. package/templates/storefront-blank/src/lib/seo/noindex.ts +223 -0
  90. package/templates/storefront-blank/src/lib/seo/public-origin.ts +166 -0
  91. package/templates/storefront-blank/src/lib/seo/redirect-plan.ts +86 -0
  92. package/templates/storefront-blank/src/lib/seo/resolve-path.ts +107 -0
  93. package/templates/storefront-blank/src/lib/seo/scaffolded-routes.ts +83 -0
  94. package/templates/storefront-blank/src/lib/seo/sidecar.ts +75 -0
  95. package/templates/storefront-blank/src/lib/seo/site-verification.ts +98 -0
  96. package/templates/storefront-blank/src/lib/seo/sitemap-cache.ts +114 -0
  97. package/templates/storefront-blank/src/lib/seo/sitemap-entries.ts +310 -0
  98. package/templates/storefront-blank/src/lib/session-actions.ts +61 -0
  99. package/templates/storefront-blank/src/lib/session-cookies.ts +98 -0
  100. package/templates/storefront-blank/src/lib/shop-config.ts +51 -0
  101. package/templates/storefront-blank/src/lib/shop-session.ts +204 -0
  102. package/templates/storefront-blank/src/lib/track-forward.ts +200 -0
  103. package/templates/storefront-blank/src/lib/uuid.ts +19 -0
  104. package/templates/storefront-blank/src/middleware.ts +379 -0
  105. package/templates/storefront-blank/src/seo/redirects.ts +44 -0
  106. package/templates/storefront-blank/src/seo/static-routes.ts +12 -0
  107. package/templates/storefront-blank/src/server/app.module.ts +18 -0
  108. package/templates/storefront-blank/src/server/backend-api.ts +21 -0
  109. package/templates/storefront-blank/src/server/backend-method.decorator.ts +23 -0
  110. package/templates/storefront-blank/src/server/bootstrap.ts +122 -0
  111. package/templates/storefront-blank/src/server/forge/live-revision.ts +158 -0
  112. package/templates/storefront-blank/src/server/forgecart/forgecart-client.factory.ts +73 -0
  113. package/templates/storefront-blank/src/server/forgecart/forgecart.module.ts +9 -0
  114. package/templates/storefront-blank/src/server/runner.ts +90 -0
  115. package/templates/storefront-blank/src/server/types.ts +36 -0
  116. package/templates/storefront-blank/tsconfig.json +25 -0
  117. package/templates/storefront-blank-sdk-floor.json +1174 -0
  118. package/templates/storefront-shadcn-blank/.forgecartignore +2 -0
  119. package/templates/storefront-shadcn-blank/Procfile +1 -0
  120. package/templates/storefront-shadcn-blank/README.md +93 -0
  121. package/templates/storefront-shadcn-blank/SEO-MIGRATION.md +718 -0
  122. package/templates/storefront-shadcn-blank/_gitignore +21 -0
  123. package/templates/storefront-shadcn-blank/components.json +20 -0
  124. package/templates/storefront-shadcn-blank/next.config.js +105 -0
  125. package/templates/storefront-shadcn-blank/package.json +42 -0
  126. package/templates/storefront-shadcn-blank/postcss.config.js +5 -0
  127. package/templates/storefront-shadcn-blank/src/app/%5F%5Ffc/identify/route.ts +205 -0
  128. package/templates/storefront-shadcn-blank/src/app/%5F%5Ffc/track/route.ts +189 -0
  129. package/templates/storefront-shadcn-blank/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  130. package/templates/storefront-shadcn-blank/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  131. package/templates/storefront-shadcn-blank/src/app/error.tsx +23 -0
  132. package/templates/storefront-shadcn-blank/src/app/global-error.tsx +23 -0
  133. package/templates/storefront-shadcn-blank/src/app/globals.css +156 -0
  134. package/templates/storefront-shadcn-blank/src/app/layout.tsx +102 -0
  135. package/templates/storefront-shadcn-blank/src/app/page.tsx +53 -0
  136. package/templates/storefront-shadcn-blank/src/app/pages/[slug]/not-found.tsx +24 -0
  137. package/templates/storefront-shadcn-blank/src/app/pages/[slug]/page.tsx +115 -0
  138. package/templates/storefront-shadcn-blank/src/app/ping/route.ts +21 -0
  139. package/templates/storefront-shadcn-blank/src/app/robots.ts +69 -0
  140. package/templates/storefront-shadcn-blank/src/app/sitemap.ts +107 -0
  141. package/templates/storefront-shadcn-blank/src/components/ForgeErrorBeacon.tsx +102 -0
  142. package/templates/storefront-shadcn-blank/src/components/ForgeTracker.tsx +479 -0
  143. package/templates/storefront-shadcn-blank/src/components/ForgecartDesigner.tsx +43 -0
  144. package/templates/storefront-shadcn-blank/src/components/LocaleLink.tsx +49 -0
  145. package/templates/storefront-shadcn-blank/src/components/ui/alert.tsx +75 -0
  146. package/templates/storefront-shadcn-blank/src/components/ui/badge.tsx +40 -0
  147. package/templates/storefront-shadcn-blank/src/components/ui/button.tsx +64 -0
  148. package/templates/storefront-shadcn-blank/src/components/ui/card.tsx +28 -0
  149. package/templates/storefront-shadcn-blank/src/components/ui/input.tsx +26 -0
  150. package/templates/storefront-shadcn-blank/src/components/ui/label.tsx +22 -0
  151. package/templates/storefront-shadcn-blank/src/components/ui/native-select.tsx +27 -0
  152. package/templates/storefront-shadcn-blank/src/components/ui/skeleton.tsx +21 -0
  153. package/templates/storefront-shadcn-blank/src/components/ui/utils.ts +16 -0
  154. package/templates/storefront-shadcn-blank/src/instrumentation.ts +109 -0
  155. package/templates/storefront-shadcn-blank/src/lib/account/account-link.ts +76 -0
  156. package/templates/storefront-shadcn-blank/src/lib/account/account-state.ts +152 -0
  157. package/templates/storefront-shadcn-blank/src/lib/account/register-state.ts +133 -0
  158. package/templates/storefront-shadcn-blank/src/lib/account/reset-password-state.ts +111 -0
  159. package/templates/storefront-shadcn-blank/src/lib/account/verify-state.ts +56 -0
  160. package/templates/storefront-shadcn-blank/src/lib/account-actions.ts +76 -0
  161. package/templates/storefront-shadcn-blank/src/lib/account-session.ts +114 -0
  162. package/templates/storefront-shadcn-blank/src/lib/action-result.ts +30 -0
  163. package/templates/storefront-shadcn-blank/src/lib/asset-alt.ts +34 -0
  164. package/templates/storefront-shadcn-blank/src/lib/backend-actions.ts +20 -0
  165. package/templates/storefront-shadcn-blank/src/lib/backend-client.ts +47 -0
  166. package/templates/storefront-shadcn-blank/src/lib/cart-context.tsx +236 -0
  167. package/templates/storefront-shadcn-blank/src/lib/checkout-session.ts +185 -0
  168. package/templates/storefront-shadcn-blank/src/lib/content/page-metadata.ts +113 -0
  169. package/templates/storefront-shadcn-blank/src/lib/content/render-fields.tsx +256 -0
  170. package/templates/storefront-shadcn-blank/src/lib/content/resolve-page.ts +143 -0
  171. package/templates/storefront-shadcn-blank/src/lib/error-messages.ts +118 -0
  172. package/templates/storefront-shadcn-blank/src/lib/experiments.ts +333 -0
  173. package/templates/storefront-shadcn-blank/src/lib/forgecart.ts +464 -0
  174. package/templates/storefront-shadcn-blank/src/lib/format.ts +89 -0
  175. package/templates/storefront-shadcn-blank/src/lib/identify-forward.ts +152 -0
  176. package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales-loader.ts +169 -0
  177. package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales-map.ts +46 -0
  178. package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales.ts +191 -0
  179. package/templates/storefront-shadcn-blank/src/lib/locale/grammar.ts +194 -0
  180. package/templates/storefront-shadcn-blank/src/lib/locale/localized-path.ts +55 -0
  181. package/templates/storefront-shadcn-blank/src/lib/locale/middleware-plan.ts +107 -0
  182. package/templates/storefront-shadcn-blank/src/lib/locale/request-binding.ts +80 -0
  183. package/templates/storefront-shadcn-blank/src/lib/locale/request-locale.ts +66 -0
  184. package/templates/storefront-shadcn-blank/src/lib/marketing-params.ts +213 -0
  185. package/templates/storefront-shadcn-blank/src/lib/money.ts +50 -0
  186. package/templates/storefront-shadcn-blank/src/lib/seo/alternates.ts +123 -0
  187. package/templates/storefront-shadcn-blank/src/lib/seo/json-ld.ts +266 -0
  188. package/templates/storefront-shadcn-blank/src/lib/seo/metadata.ts +419 -0
  189. package/templates/storefront-shadcn-blank/src/lib/seo/noindex.ts +223 -0
  190. package/templates/storefront-shadcn-blank/src/lib/seo/public-origin.ts +166 -0
  191. package/templates/storefront-shadcn-blank/src/lib/seo/redirect-plan.ts +86 -0
  192. package/templates/storefront-shadcn-blank/src/lib/seo/resolve-path.ts +107 -0
  193. package/templates/storefront-shadcn-blank/src/lib/seo/scaffolded-routes.ts +83 -0
  194. package/templates/storefront-shadcn-blank/src/lib/seo/sidecar.ts +75 -0
  195. package/templates/storefront-shadcn-blank/src/lib/seo/site-verification.ts +98 -0
  196. package/templates/storefront-shadcn-blank/src/lib/seo/sitemap-cache.ts +114 -0
  197. package/templates/storefront-shadcn-blank/src/lib/seo/sitemap-entries.ts +310 -0
  198. package/templates/storefront-shadcn-blank/src/lib/session-actions.ts +61 -0
  199. package/templates/storefront-shadcn-blank/src/lib/session-cookies.ts +98 -0
  200. package/templates/storefront-shadcn-blank/src/lib/shop-config.ts +51 -0
  201. package/templates/storefront-shadcn-blank/src/lib/shop-session.ts +204 -0
  202. package/templates/storefront-shadcn-blank/src/lib/track-forward.ts +200 -0
  203. package/templates/storefront-shadcn-blank/src/lib/uuid.ts +19 -0
  204. package/templates/storefront-shadcn-blank/src/middleware.ts +379 -0
  205. package/templates/storefront-shadcn-blank/src/seo/redirects.ts +44 -0
  206. package/templates/storefront-shadcn-blank/src/seo/static-routes.ts +12 -0
  207. package/templates/storefront-shadcn-blank/src/server/app.module.ts +18 -0
  208. package/templates/storefront-shadcn-blank/src/server/backend-api.ts +21 -0
  209. package/templates/storefront-shadcn-blank/src/server/backend-method.decorator.ts +23 -0
  210. package/templates/storefront-shadcn-blank/src/server/bootstrap.ts +122 -0
  211. package/templates/storefront-shadcn-blank/src/server/forge/live-revision.ts +158 -0
  212. package/templates/storefront-shadcn-blank/src/server/forgecart/forgecart-client.factory.ts +73 -0
  213. package/templates/storefront-shadcn-blank/src/server/forgecart/forgecart.module.ts +9 -0
  214. package/templates/storefront-shadcn-blank/src/server/runner.ts +90 -0
  215. package/templates/storefront-shadcn-blank/src/server/types.ts +36 -0
  216. package/templates/storefront-shadcn-blank/tsconfig.json +25 -0
  217. package/templates/storefront-shadcn-blank-sdk-floor.json +1174 -0
  218. package/templates/template-set.json +15 -0
@@ -0,0 +1,143 @@
1
+ import { isLocaleSegment } from '../locale/grammar';
2
+
3
+ /**
4
+ * What a storefront page URL resolves to (#1934, epic launch#50) — the WHOLE
5
+ * decision behind `/pages/<route>`, as a value.
6
+ *
7
+ * PURE BY CONTRACT, for the reason `lib/seo/resolve-path.ts` is: this is the
8
+ * law that decides between a rendered page and a real 404, and
9
+ * `tool/storefront-template-spec` imports it directly by relative path from
10
+ * OUTSIDE the scaffold, so nothing reachable from here may need the template's
11
+ * Next toolchain. The route file is left as dispatch — it turns `notFound` into
12
+ * Next's `notFound()` and `ok` into markup, and decides nothing itself.
13
+ *
14
+ * The inputs are declared STRUCTURALLY rather than imported from the SDK for
15
+ * the same reason (`ResolvableProduct` precedent): the generated query types
16
+ * satisfy these shapes structurally, so the scaffold's own `tsc` proves the
17
+ * assignment while this module stays importable with no `paths`.
18
+ */
19
+
20
+ /**
21
+ * The subset of a page definition this decision reads, declared AS THE SHOP
22
+ * API'S GENERATED TYPES CARRY IT.
23
+ *
24
+ * Every nullable selection reaches a scaffold OPTIONAL as well as nullable
25
+ * (`route?: Maybe<string>`), so a contract spelled `string | null` would refuse
26
+ * the very value the query produces — and, this module having no compiler of
27
+ * its own in this repository, the refusal would surface in the merchant's
28
+ * `next build` instead of here. Admitting the shape is therefore part of the
29
+ * contract, not a laxity: what each absence MEANS is decided in the table
30
+ * below, once, rather than guessed at by each caller.
31
+ */
32
+ export interface RoutedPageGroup {
33
+ /**
34
+ * `'page'` for a storefront page; anything else — a data definition, or a
35
+ * selection from a pod whose SDK does not carry the field — is not one.
36
+ */
37
+ readonly kind?: string | null;
38
+ /**
39
+ * The route the definition is mounted at. Absent means mounted NOWHERE: the
40
+ * column is nullable because most definitions are data rather than pages.
41
+ */
42
+ readonly route?: string | null;
43
+ }
44
+
45
+ /**
46
+ * Why a page URL is a 404. Four distinct causes, kept apart because they answer
47
+ * different questions about a deployment — "the merchant has not published
48
+ * this yet" and "this URL was never a page" look identical to a visitor and are
49
+ * nothing alike to whoever is debugging.
50
+ */
51
+ export type PageNotFoundReason =
52
+ /**
53
+ * The slug is a two-letter locale segment. No page may hold one (launch#54
54
+ * W1-7 — the server refuses to mint such a route rather than auto-suffixing
55
+ * it), so this URL can never carry content in any deployment. Judged first
56
+ * and without consulting the lookup, so the rule is stated where it is true
57
+ * rather than inferred from an empty result.
58
+ */
59
+ | 'reserved-locale-segment'
60
+ /**
61
+ * No page definition of this channel is mounted at this route: the lookup
62
+ * answered nothing, or what it answered carries no route of its own and is
63
+ * therefore mounted nowhere.
64
+ */
65
+ | 'unknown-route'
66
+ /** Something answered, but it is not a page definition. */
67
+ | 'not-a-page'
68
+ /**
69
+ * The page exists and has nothing a visitor may see: never given a record, or
70
+ * holding only records the publish fence hides — a DRAFT, or one SCHEDULED
71
+ * for a moment that has not arrived. The shop API already removed those rows
72
+ * (it fences every storefront read to published content), so this arm is the
73
+ * renderer HONOURING an empty result, never a second publish check.
74
+ */
75
+ | 'no-live-content';
76
+
77
+ /**
78
+ * The answer for one page URL.
79
+ *
80
+ * The `ok` arm carries BOTH halves of what the route renders, narrowed to the
81
+ * caller's own types. That is why it is generic rather than a bare verdict: a
82
+ * caller that had to re-check `group !== null` after a resolution that already
83
+ * proved it would be asserting what the law just decided.
84
+ */
85
+ export type PageResolution<TGroup, TEntry> =
86
+ | { kind: 'ok'; group: TGroup; entry: TEntry }
87
+ | { kind: 'notFound'; reason: PageNotFoundReason };
88
+
89
+ export interface ResolvePageInput<TGroup, TEntry> {
90
+ /** The slug exactly as it appeared in the URL, locale prefix already stripped. */
91
+ requestedRoute: string;
92
+ /** What `pageByRoute` answered, or null when it answered nothing. */
93
+ group: TGroup | null;
94
+ /**
95
+ * The page's visible records, in the order the CALLER asked for. A page is
96
+ * one URL, so it serves one record: the first of these. Which record that is
97
+ * is the caller's decision and belongs at the query (see `getPageEntries`),
98
+ * not here.
99
+ */
100
+ entries: readonly TEntry[];
101
+ }
102
+
103
+ /**
104
+ * Decide the honest answer for a page URL.
105
+ *
106
+ * TOTAL over its input and ordered so each arm may assume the ones above it did
107
+ * not fire. Two of the four refusals are the feature's own contract — an
108
+ * unpublished or not-yet-live page answers 404 — and the other two are what
109
+ * make the function total.
110
+ */
111
+ export function resolvePage<TGroup extends RoutedPageGroup, TEntry>({
112
+ requestedRoute,
113
+ group,
114
+ entries,
115
+ }: ResolvePageInput<TGroup, TEntry>): PageResolution<TGroup, TEntry> {
116
+ if (isLocaleSegment(requestedRoute)) {
117
+ return { kind: 'notFound', reason: 'reserved-locale-segment' };
118
+ }
119
+
120
+ if (group === null) return { kind: 'notFound', reason: 'unknown-route' };
121
+
122
+ // A boundary check, not a redundant one: the shop query returns the general
123
+ // field-definition type and nothing in that TYPE says "page", so this is the
124
+ // template asserting that what it is about to render as a page is one — the
125
+ // same posture `parseScaffoldedRoute` takes toward a value another process
126
+ // wrote.
127
+ if (group.kind !== 'page') return { kind: 'notFound', reason: 'not-a-page' };
128
+
129
+ // Mounted-ness, read back off the answer itself, in the same posture as the
130
+ // kind check above: `pageByRoute` matched this group BY its route, so one
131
+ // that carries none contradicts the question it answered. Stating that as a
132
+ // 404 is what keeps the route from rendering a page no URL owns — and it is
133
+ // why the absence is resolved HERE rather than by a caller that would have
134
+ // to invent a meaning for it.
135
+ if (group.route === null || group.route === undefined) {
136
+ return { kind: 'notFound', reason: 'unknown-route' };
137
+ }
138
+
139
+ const entry = entries[0];
140
+ if (entry === undefined) return { kind: 'notFound', reason: 'no-live-content' };
141
+
142
+ return { kind: 'ok', group, entry };
143
+ }
@@ -0,0 +1,118 @@
1
+ import type { ExtractedError } from '@forgecart/sdk/shop';
2
+
3
+ /**
4
+ * Shopper-facing copy for the typed error codes a storefront call can meet.
5
+ *
6
+ * The shop API's error contract ships CODES, never prose — `message` on an
7
+ * extracted error is the code string itself, and resolving human copy is the
8
+ * frontend's job (error-handling contract). A raw enum leaking into the UI is
9
+ * a defect (the address step rendered SHIPPING_ADDRESS_PHONE_REQUIRED
10
+ * verbatim, 2026-08-09).
11
+ *
12
+ * Two families live here. The checkout's address codes, and every refusal a
13
+ * customer ACCOUNT call can meet (#1093): signing in and out, registering, a
14
+ * verification or reset link, a password or e-mail change. The account family
15
+ * is swept against the shop's own `ErrorCode` enum by
16
+ * `tool/storefront-template-spec` — every member of its `Auth - Self-Service`
17
+ * and `Auth - Service` blocks, plus the sign-in refusal, the anonymous
18
+ * socket's refusal and the taken address — so a refusal added to the platform
19
+ * reddens that sweep until it has copy here. Before #1093 this map held the
20
+ * three address codes alone, and every refused sign-in rendered as the
21
+ * generic line: indistinguishable from the platform faults it was masking.
22
+ *
23
+ * `SHOP_API_UNREACHABLE` is not a platform code but this template's own
24
+ * (`lib/action-result.ts`): the answer when the API could not be asked at all,
25
+ * which is the one failure the generic line's "check your details" is wrong
26
+ * about.
27
+ *
28
+ * A Map rather than an object literal: a lookup keyed by an API-supplied
29
+ * string must not be able to land on `Object.prototype` (`constructor`,
30
+ * `toString`) and render a function as copy.
31
+ */
32
+ const SHOPPER_MESSAGES: ReadonlyMap<string, string> = new Map([
33
+ ['SHIPPING_ADDRESS_PHONE_REQUIRED', 'A phone number is required for delivery updates.'],
34
+ ['SHIPPING_ADDRESS_COUNTRY_REQUIRED', 'Please select a country.'],
35
+ ['SHIPPING_ADDRESS_COUNTRY_INVALID', 'We can’t ship to the selected country yet.'],
36
+ [
37
+ 'INVALID_CREDENTIALS',
38
+ 'That e-mail address and password do not match. Check both and try again.',
39
+ ],
40
+ ['UNAUTHORIZED', 'You are not signed in, or your session has ended. Please sign in again.'],
41
+ [
42
+ 'EMAIL_ADDRESS_CONFLICT',
43
+ 'That e-mail address already has an account. Sign in with it, or reset its password.',
44
+ ],
45
+ [
46
+ 'IDENTIFIER_ALREADY_EXISTS',
47
+ 'That e-mail address already has an account. Sign in with it, or reset its password.',
48
+ ],
49
+ [
50
+ 'NOT_VERIFIED',
51
+ 'This account is not confirmed yet. Open the link in the confirmation e-mail we sent you, then sign in.',
52
+ ],
53
+ [
54
+ 'VERIFICATION_TOKEN_INVALID',
55
+ 'This confirmation link is not valid — it may have been used already. Ask for a new confirmation e-mail.',
56
+ ],
57
+ [
58
+ 'VERIFICATION_TOKEN_EXPIRED',
59
+ 'This confirmation link has expired. Ask for a new confirmation e-mail and open the newest link.',
60
+ ],
61
+ [
62
+ 'PASSWORD_RESET_TOKEN_INVALID',
63
+ 'This password reset link is not valid — it may have been used already. Ask for a new one.',
64
+ ],
65
+ [
66
+ 'PASSWORD_RESET_TOKEN_EXPIRED',
67
+ 'This password reset link has expired. Ask for a new one and open the newest link.',
68
+ ],
69
+ [
70
+ 'PASSWORD_VALIDATION_FAILED',
71
+ 'That password does not meet the store’s requirements. Please choose a different one.',
72
+ ],
73
+ [
74
+ 'PASSWORD_CHANGE_FAILED',
75
+ 'Your password could not be changed. Check your current password and try again.',
76
+ ],
77
+ [
78
+ 'IDENTIFIER_CHANGE_TOKEN_INVALID',
79
+ 'This e-mail change link is not valid — it may have been used already. Ask for the change again.',
80
+ ],
81
+ [
82
+ 'IDENTIFIER_CHANGE_TOKEN_EXPIRED',
83
+ 'This e-mail change link has expired. Ask for the change again and open the newest link.',
84
+ ],
85
+ [
86
+ 'CUSTOMER_NOT_FOUND',
87
+ 'That account does not shop at this store. Sign in with the address you registered here.',
88
+ ],
89
+ ['SESSION_NOT_FOUND', 'Your session has ended. Please sign in again.'],
90
+ [
91
+ 'SESSION_CACHE_READ_FAILED',
92
+ 'We could not check your session just now. Please try again in a moment.',
93
+ ],
94
+ [
95
+ 'SESSION_CACHE_WRITE_FAILED',
96
+ 'We could not save your session just now. Please try again in a moment.',
97
+ ],
98
+ [
99
+ 'SHOP_API_UNREACHABLE',
100
+ 'We could not reach the store just now. Please check your connection and try again.',
101
+ ],
102
+ ]);
103
+
104
+ const FALLBACK_MESSAGE = 'Something went wrong. Please check your details and try again.';
105
+
106
+ /**
107
+ * Human copy for a typed error — never undefined.
108
+ *
109
+ * A code this map has no copy for renders the generic line WITH the code
110
+ * appended in parentheses: an unmodelled refusal must stay distinguishable
111
+ * from a platform fault, by the shopper who reports it and by the merchant —
112
+ * or their agent — who reads the report. Only an error carrying no code at all
113
+ * renders the generic line bare.
114
+ */
115
+ export function shopperMessage(error: Pick<ExtractedError, 'code'> | null | undefined): string {
116
+ if (!error?.code) return FALLBACK_MESSAGE;
117
+ return SHOPPER_MESSAGES.get(error.code) ?? `${FALLBACK_MESSAGE} (${error.code})`;
118
+ }
@@ -0,0 +1,333 @@
1
+ import {
2
+ deriveExposureEventId,
3
+ resolveExperimentAssignment,
4
+ type AssignmentResult,
5
+ type AssignmentSource,
6
+ type ExperimentAssignmentConfig,
7
+ } from '@forgecart/sdk';
8
+ import { cookies, headers } from 'next/headers';
9
+ import { after } from 'next/server';
10
+ import { cache } from 'react';
11
+ import { uuidv7 } from 'uuidv7';
12
+
13
+ import { SESSION_COOKIE } from './session-cookies';
14
+ import { forwardTrackEvent, getUpstreamConfig, isObviousBot } from './track-forward';
15
+ import type { TrackEventInput, UpstreamConfig } from './track-forward';
16
+
17
+ /**
18
+ * Server-side A/B variant resolution (design doc Wave F-B).
19
+ *
20
+ * `getVariant(experimentKey)` is the ONE storefront API for experiments:
21
+ * Server Components call it and branch on the returned `variantKey` (variant
22
+ * payloads are code branches first, `payload` JSON second). Resolution is
23
+ * LOCAL — the SDK's deterministic assignment engine over a TTL-cached config
24
+ * — so variants render with zero flicker and zero added request round-trips.
25
+ *
26
+ * How the pieces fit:
27
+ * - Config: the public `activeExperiments` shop query, fetched over raw
28
+ * HTTP GraphQL with the hand-rolled `forgecart-token` header (the
29
+ * `track-forward.ts` pattern — the template's pinned published SDK has no
30
+ * plain-HTTP query path) and cached module-scope for
31
+ * {@link CONFIG_TTL_MS} keyed by channel token. A fetch failure resolves
32
+ * to "no experiments" (every caller renders the default branch) and is
33
+ * cached for the same TTL, so a dead backend costs one failed fetch per
34
+ * minute — never one per request.
35
+ * - Subject: the `forgecart-visitor` cookie (S6). When absent, an id is
36
+ * minted IN-REQUEST via a request-scoped `cache()` — never relying on
37
+ * middleware→RSC cookie propagation (S6 doc rule; the middleware persists
38
+ * the durable cookie, this module only needs a consistent value for the
39
+ * current render). Same UUIDv7 value shape as the middleware mint.
40
+ * - Forced entry: the `forgecart-exp-force` cookie the middleware merges
41
+ * from `?fc-exp=` campaign URLs. The engine honors it only when the
42
+ * experiment allows forced entry and the variant exists
43
+ * (`assignmentSource: 'forced'`).
44
+ * - Exposure: when the engine says `exposed` (hashed or forced), ONE
45
+ * `experiment_exposure` marketing event is emitted per request inside
46
+ * `next/server` `after()` — after the response, off the critical path —
47
+ * through the shared `forwardTrackEvent` (never an HTTP self-call to the
48
+ * relay, never the SDK's identity-pinning WebSocket). The event id is the
49
+ * SDK's deterministic UUIDv5 over `salt:subjectKey:utcDay`, so re-renders
50
+ * and multi-page sessions dedupe server-side to one exposure per subject
51
+ * per UTC day.
52
+ *
53
+ * `currencyCode` is deliberately ABSENT from the exposure input: §A wants the
54
+ * session market's currency on exposures, but pre-cart the template server
55
+ * has no side-effect-free source for it — the shop API surfaces currency only
56
+ * via `activeOrder` (whose read AUTO-CREATES an order for a session, see
57
+ * `cart-actions.ts#getCart`) and the geo-seeded market lives in a session
58
+ * that a first-touch page view does not yet have. Inventing a source here
59
+ * (e.g. the channel default off a product read) would mislabel the segment;
60
+ * the field stays unset until the ingest side can stamp the session market
61
+ * server-side.
62
+ */
63
+
64
+ const VISITOR_COOKIE = 'forgecart-visitor';
65
+ const FORCED_ENTRY_COOKIE = 'forgecart-exp-force';
66
+
67
+ /** Delivery-config cache TTL — the design doc's 60s config-plane freshness. */
68
+ const CONFIG_TTL_MS = 60_000;
69
+
70
+ /** Canonical textual UUID — the only visitor-cookie shape trusted as subject. */
71
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
72
+
73
+ /** Backend slug grammar (`ExperimentService`) — gates keys read from cookies. */
74
+ const SLUG_REGEX = /^[a-z0-9][a-z0-9_-]{0,63}$/;
75
+
76
+ /**
77
+ * The SDK's own `ShopActiveExperiments` document, inlined — same rationale as
78
+ * `track-forward.ts`: the generated client is WebSocket-only, so the config
79
+ * read speaks plain HTTP GraphQL with the identical selection.
80
+ */
81
+ const ACTIVE_EXPERIMENTS_QUERY = `query ShopActiveExperiments {
82
+ activeExperiments {
83
+ experimentKey
84
+ subjectType
85
+ version
86
+ salt
87
+ lifecycle
88
+ trafficBps
89
+ allowForcedEntry
90
+ variants {
91
+ key
92
+ weight
93
+ payload
94
+ }
95
+ winnerVariantKey
96
+ }
97
+ }`;
98
+
99
+ /** One variant as the `activeExperiments` shop query delivers it. */
100
+ interface ActiveExperimentVariant {
101
+ key: string;
102
+ weight: number;
103
+ payload: Record<string, unknown> | null;
104
+ }
105
+
106
+ /** The public delivery projection of one experiment (FB2b contract). */
107
+ interface ActiveExperiment {
108
+ experimentKey: string;
109
+ subjectType: 'visitor' | 'customer';
110
+ version: number;
111
+ salt: string;
112
+ lifecycle: 'running' | 'paused' | 'completed';
113
+ trafficBps: number;
114
+ allowForcedEntry: boolean;
115
+ variants: ActiveExperimentVariant[];
116
+ winnerVariantKey: string | null;
117
+ }
118
+
119
+ /** What a Server Component branches on. */
120
+ export interface ExperimentVariantResult {
121
+ /**
122
+ * The assigned variant key, or `null` when the experiment is unknown /
123
+ * undelivered / malformed — callers treat `null` as "render the default
124
+ * branch", which keeps every page correct with zero experiments configured.
125
+ */
126
+ variantKey: string | null;
127
+ /**
128
+ * How the variant was decided: `'hashed'` (randomized cohort), `'forced'`
129
+ * (campaign entry — excluded from the randomized comparison), `'lifecycle'`
130
+ * (paused → control / completed → winner), or `null` (non-participant
131
+ * control or no experiment).
132
+ */
133
+ assignmentSource: AssignmentSource | null;
134
+ /** The variant's optional delivery payload (design doc Q5), if any. */
135
+ payload: Record<string, unknown> | null;
136
+ }
137
+
138
+ interface GraphQLActiveExperimentsResponse {
139
+ data?: { activeExperiments?: unknown } | null;
140
+ errors?: unknown[];
141
+ }
142
+
143
+ /** Module-scope 60s TTL cache — one config fetch per server per minute. */
144
+ let configCache: {
145
+ channelToken: string;
146
+ expiresAt: number;
147
+ load: Promise<ActiveExperiment[]>;
148
+ } | null = null;
149
+
150
+ async function fetchActiveExperiments(upstream: UpstreamConfig): Promise<ActiveExperiment[]> {
151
+ try {
152
+ const response = await fetch(upstream.shopApiUrl, {
153
+ method: 'POST',
154
+ headers: {
155
+ 'content-type': 'application/json',
156
+ 'forgecart-token': upstream.channelToken,
157
+ },
158
+ body: JSON.stringify({ query: ACTIVE_EXPERIMENTS_QUERY }),
159
+ });
160
+ if (!response.ok) return [];
161
+ const payload = (await response.json()) as GraphQLActiveExperimentsResponse;
162
+ const list = payload.data?.activeExperiments;
163
+ return Array.isArray(list) ? (list as ActiveExperiment[]) : [];
164
+ } catch {
165
+ // Experiments must never break the storefront: no config → every caller
166
+ // renders its default branch until the next TTL window retries.
167
+ return [];
168
+ }
169
+ }
170
+
171
+ function getActiveExperiments(): Promise<ActiveExperiment[]> {
172
+ const upstream = getUpstreamConfig();
173
+ // Inert before `forgecart init` writes `.env` (image-prewarm contract).
174
+ if (!upstream) return Promise.resolve([]);
175
+ const now = Date.now();
176
+ if (
177
+ configCache &&
178
+ configCache.channelToken === upstream.channelToken &&
179
+ now < configCache.expiresAt
180
+ ) {
181
+ return configCache.load;
182
+ }
183
+ configCache = {
184
+ channelToken: upstream.channelToken,
185
+ expiresAt: now + CONFIG_TTL_MS,
186
+ load: fetchActiveExperiments(upstream),
187
+ };
188
+ return configCache.load;
189
+ }
190
+
191
+ /**
192
+ * The assignment subject for this request: the S6 visitor cookie when it
193
+ * rode in, else ONE in-request UUIDv7 shared by every `getVariant` call of
194
+ * the render (request-scoped `cache()`). A cookie-less first page view thus
195
+ * gets a consistent assignment within its own render; the middleware's
196
+ * Set-Cookie makes the durable id authoritative from the next request on.
197
+ */
198
+ const getRequestVisitorId = cache(async (): Promise<string> => {
199
+ const store = await cookies();
200
+ const existing = store.get(VISITOR_COOKIE)?.value;
201
+ if (existing && UUID_PATTERN.test(existing)) return existing;
202
+ return uuidv7();
203
+ });
204
+
205
+ /** The forced-entry map from the middleware-merged cookie, slug-validated. */
206
+ const getForcedVariantMap = cache(async (): Promise<Record<string, string>> => {
207
+ const store = await cookies();
208
+ const raw = store.get(FORCED_ENTRY_COOKIE)?.value;
209
+ if (!raw) return {};
210
+ let parsed: unknown;
211
+ try {
212
+ // `cookies()` already percent-decodes — `raw` is the JSON text.
213
+ parsed = JSON.parse(raw);
214
+ } catch {
215
+ return {};
216
+ }
217
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {};
218
+ const map: Record<string, string> = {};
219
+ for (const [experimentKey, variantKey] of Object.entries(parsed)) {
220
+ if (typeof variantKey !== 'string') continue;
221
+ if (!SLUG_REGEX.test(experimentKey) || !SLUG_REGEX.test(variantKey)) continue;
222
+ map[experimentKey] = variantKey;
223
+ }
224
+ return map;
225
+ });
226
+
227
+ function toAssignmentConfig(experiment: ActiveExperiment): ExperimentAssignmentConfig {
228
+ return {
229
+ salt: experiment.salt,
230
+ lifecycle: experiment.lifecycle,
231
+ trafficBps: experiment.trafficBps,
232
+ allowForcedEntry: experiment.allowForcedEntry,
233
+ variants: experiment.variants.map(({ key, weight }) => ({ key, weight })),
234
+ winnerVariantKey: experiment.winnerVariantKey,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Emit the `experiment_exposure` marketing event inside `after()`. Request
240
+ * data (UA, client IP, session) is captured HERE, during render — `after()`
241
+ * callbacks run once the response is done and must not touch request APIs.
242
+ * The response is already sent when a session-less exposure reaches the shop
243
+ * API, so a token minted for it cannot be persisted — accepted: the relay's
244
+ * first client batch establishes the durable shopper session.
245
+ */
246
+ async function scheduleExposure(
247
+ experiment: ActiveExperiment,
248
+ result: AssignmentResult,
249
+ visitorId: string,
250
+ ): Promise<void> {
251
+ const requestHeaders = await headers();
252
+ const userAgent = requestHeaders.get('user-agent') ?? '';
253
+ // Same bot policy as the relay: unambiguous crawlers emit nothing.
254
+ if (isObviousBot(userAgent)) return;
255
+ const forwardedFor = requestHeaders.get('x-forwarded-for');
256
+ const store = await cookies();
257
+ const sessionToken = store.get(SESSION_COOKIE)?.value ?? null;
258
+
259
+ const occurredAt = new Date();
260
+ const input: TrackEventInput = {
261
+ eventType: 'experiment_exposure',
262
+ // Deterministic UUIDv5 over salt:subjectKey:utcDay — every re-render of
263
+ // this subject today resubmits the SAME id and the backend dedupes.
264
+ eventId: deriveExposureEventId(experiment.salt, visitorId, occurredAt),
265
+ occurredAt: occurredAt.toISOString(),
266
+ properties: {
267
+ experimentKey: experiment.experimentKey,
268
+ variantKey: result.variantKey,
269
+ version: experiment.version,
270
+ subjectType: experiment.subjectType,
271
+ assignmentSource: result.assignmentSource,
272
+ visitorId,
273
+ },
274
+ };
275
+
276
+ after(async () => {
277
+ await forwardTrackEvent(input, { sessionToken, userAgent, forwardedFor });
278
+ });
279
+ }
280
+
281
+ /**
282
+ * Resolve the variant for one experiment. Request-scoped `cache()` keeps the
283
+ * whole render consistent — layout and page asking for the same key get one
284
+ * resolution and at most ONE exposure emission per request.
285
+ */
286
+ export const getVariant = cache(async (experimentKey: string): Promise<ExperimentVariantResult> => {
287
+ const inert: ExperimentVariantResult = {
288
+ variantKey: null,
289
+ assignmentSource: null,
290
+ payload: null,
291
+ };
292
+ if (!SLUG_REGEX.test(experimentKey)) return inert;
293
+
294
+ const experiments = await getActiveExperiments();
295
+ const experiment = experiments.find((entry) => entry.experimentKey === experimentKey);
296
+ if (!experiment) return inert;
297
+
298
+ if (experiment.subjectType === 'customer') {
299
+ // The storefront server has no verified customer id (auth lives behind
300
+ // the shop API session) — a customer-scoped experiment renders as
301
+ // non-participant control here, with no exposure. Customer-subject
302
+ // resolution belongs to clients that KNOW the customer id.
303
+ const control = experiment.variants[0];
304
+ if (!control) return inert;
305
+ return { variantKey: control.key, assignmentSource: null, payload: control.payload ?? null };
306
+ }
307
+
308
+ const visitorId = await getRequestVisitorId();
309
+ const forcedVariantKey = (await getForcedVariantMap())[experimentKey] ?? null;
310
+
311
+ let result: AssignmentResult;
312
+ try {
313
+ result = resolveExperimentAssignment(toAssignmentConfig(experiment), {
314
+ subjectKey: visitorId,
315
+ forcedVariantKey,
316
+ });
317
+ } catch {
318
+ // A malformed delivery (out-of-bounds weight, empty variants) must
319
+ // never break rendering — the caller falls back to its default branch.
320
+ return inert;
321
+ }
322
+
323
+ if (result.exposed && result.variantKey !== null) {
324
+ await scheduleExposure(experiment, result, visitorId);
325
+ }
326
+
327
+ const variant = experiment.variants.find((entry) => entry.key === result.variantKey);
328
+ return {
329
+ variantKey: result.variantKey,
330
+ assignmentSource: result.assignmentSource,
331
+ payload: variant?.payload ?? null,
332
+ };
333
+ });