@forgecart/cli 2.202608221935.0 → 2.202609190800.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 (90) hide show
  1. package/dist/src/cli.js +3 -21
  2. package/dist/src/cli.js.map +1 -1
  3. package/dist/src/commands/__test__/cli-harness.d.ts +21 -0
  4. package/dist/src/commands/__test__/cli-harness.js +29 -0
  5. package/dist/src/commands/__test__/cli-harness.js.map +1 -0
  6. package/dist/src/commands/init.d.ts +28 -2
  7. package/dist/src/commands/init.js +100 -15
  8. package/dist/src/commands/init.js.map +1 -1
  9. package/dist/src/commands/refresh.d.ts +40 -0
  10. package/dist/src/commands/refresh.js +147 -0
  11. package/dist/src/commands/refresh.js.map +1 -0
  12. package/dist/src/commands/template-manifest.d.ts +38 -0
  13. package/dist/src/commands/template-manifest.js +116 -0
  14. package/dist/src/commands/template-manifest.js.map +1 -0
  15. package/dist/src/version.d.ts +10 -0
  16. package/dist/src/version.js +25 -0
  17. package/dist/src/version.js.map +1 -0
  18. package/package.json +1 -1
  19. package/templates/storefront/README.md +42 -4
  20. package/templates/storefront/next.config.js +29 -7
  21. package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
  22. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
  23. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
  24. package/templates/storefront/src/app/cart/page.tsx +14 -2
  25. package/templates/storefront/src/app/checkout/page.tsx +14 -2
  26. package/templates/storefront/src/app/layout.tsx +85 -22
  27. package/templates/storefront/src/app/page.tsx +63 -20
  28. package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
  29. package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
  30. package/templates/storefront/src/app/ping/route.ts +1 -1
  31. package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
  32. package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
  33. package/templates/storefront/src/app/products/page.tsx +41 -6
  34. package/templates/storefront/src/app/register/page.tsx +54 -0
  35. package/templates/storefront/src/app/reset-password/page.tsx +60 -0
  36. package/templates/storefront/src/app/robots.ts +69 -0
  37. package/templates/storefront/src/app/sitemap.ts +106 -0
  38. package/templates/storefront/src/app/verify/page.tsx +155 -0
  39. package/templates/storefront/src/components/CartView.tsx +26 -7
  40. package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
  41. package/templates/storefront/src/components/Header.tsx +30 -10
  42. package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
  43. package/templates/storefront/src/components/LocaleLink.tsx +49 -0
  44. package/templates/storefront/src/components/ProductCard.tsx +10 -4
  45. package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
  46. package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
  47. package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
  48. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
  49. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
  50. package/templates/storefront/src/lib/account/account-link.ts +76 -0
  51. package/templates/storefront/src/lib/account/register-state.ts +133 -0
  52. package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
  53. package/templates/storefront/src/lib/account/verify-state.ts +56 -0
  54. package/templates/storefront/src/lib/account-actions.ts +76 -0
  55. package/templates/storefront/src/lib/account-session.ts +47 -0
  56. package/templates/storefront/src/lib/asset-alt.ts +34 -0
  57. package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
  58. package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
  59. package/templates/storefront/src/lib/experiments.ts +1 -1
  60. package/templates/storefront/src/lib/forgecart.ts +300 -27
  61. package/templates/storefront/src/lib/format.ts +12 -14
  62. package/templates/storefront/src/lib/identify-forward.ts +152 -0
  63. package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
  64. package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
  65. package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
  66. package/templates/storefront/src/lib/locale/grammar.ts +194 -0
  67. package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
  68. package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
  69. package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
  70. package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
  71. package/templates/storefront/src/lib/marketing-params.ts +213 -0
  72. package/templates/storefront/src/lib/money.ts +50 -0
  73. package/templates/storefront/src/lib/seo/alternates.ts +120 -0
  74. package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
  75. package/templates/storefront/src/lib/seo/metadata.ts +323 -0
  76. package/templates/storefront/src/lib/seo/noindex.ts +218 -0
  77. package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
  78. package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
  79. package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
  80. package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
  81. package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
  82. package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
  83. package/templates/storefront/src/lib/session-actions.ts +15 -8
  84. package/templates/storefront/src/lib/session-cookies.ts +98 -0
  85. package/templates/storefront/src/lib/shop-config.ts +9 -2
  86. package/templates/storefront/src/lib/shop-session.ts +42 -5
  87. package/templates/storefront/src/lib/track-forward.ts +43 -14
  88. package/templates/storefront/src/middleware.ts +196 -16
  89. package/templates/storefront/src/seo/redirects.ts +44 -0
  90. package/templates/storefront/src/server/runner.ts +1 -2
@@ -1,18 +1,104 @@
1
- import Link from 'next/link';
2
- import { notFound } from 'next/navigation';
1
+ import type { Metadata } from 'next';
2
+ import { notFound, permanentRedirect } from 'next/navigation';
3
3
  import { connection } from 'next/server';
4
- import { Suspense } from 'react';
4
+ import { Suspense, cache } from 'react';
5
5
 
6
6
  import { ProductPurchase } from '../../../components/ProductPurchase';
7
+ import { LocaleLink } from '../../../components/LocaleLink';
8
+ import { getAssetAlt } from '../../../lib/asset-alt';
7
9
  import {
8
10
  getProductBySlug,
9
11
  getSellingPlanGroupsForVariant,
12
+ type ProductWithSeo,
10
13
  type SellingPlanGroup,
11
14
  } from '../../../lib/forgecart';
15
+ import type { LocaleBinding } from '../../../lib/locale/localized-path';
16
+ import { getRequestLocale } from '../../../lib/locale/request-binding';
17
+ import { buildAlternates } from '../../../lib/seo/alternates';
18
+ import {
19
+ breadcrumbJsonLd,
20
+ productJsonLd,
21
+ serializeJsonLd,
22
+ type JsonLdNode,
23
+ } from '../../../lib/seo/json-ld';
24
+ import { entityMetadata, routeMetadata } from '../../../lib/seo/metadata';
25
+ import { routeRobots, type RouteSearchParams } from '../../../lib/seo/noindex';
26
+ import { getPublicOrigin } from '../../../lib/seo/public-origin';
27
+ import { productPathsByLocale, resolveProductPath } from '../../../lib/seo/resolve-path';
28
+
29
+ /**
30
+ * The trail, once — rendered as the visible breadcrumbs AND as the
31
+ * `BreadcrumbList` a crawler reads. Two lists would be two claims about where
32
+ * this page sits, and nothing in the template would notice them diverging.
33
+ */
34
+ const BREADCRUMB_TRAIL: readonly { name: string; path: string }[] = [
35
+ { name: 'Home', path: '/' },
36
+ { name: 'Products', path: '/products' },
37
+ ];
38
+
39
+ /**
40
+ * One product read per request, shared by `generateMetadata` and the page.
41
+ *
42
+ * Next invokes them as separate calls, so without React's request cache the
43
+ * slug law would run against two independent fetches — and a product renamed
44
+ * between them would emit metadata for one address while redirecting to
45
+ * another.
46
+ */
47
+ const productForRequest = cache(async (slug: string) => getProductBySlug(slug));
12
48
 
13
49
  // PROGRESSIVE PRE-RENDERING: static shell; the data sections below are
14
50
  // per-request holes streamed inside their Suspense boundaries
15
- // (cacheComponents — see next.config.js).
51
+ // (see next.config.js for why every route is request-rendered).
52
+
53
+ /**
54
+ * Indexability for a product URL comes from the same resolution the page acts
55
+ * on, through the shared request cache — so the tag and the status can never
56
+ * disagree about what this URL is.
57
+ *
58
+ * Two content-level refusals ride here that no route rule could know: a
59
+ * FALLBACK copy (this language has no real content, so indexing it would put
60
+ * near-duplicate text in competition with the language that does) and the
61
+ * merchant's own `indexable: false`. Redirect and notFound need no directive —
62
+ * their metadata is discarded with the response body.
63
+ *
64
+ * This is the one route whose title, description and social image are the
65
+ * MERCHANT's rather than the template's: they come from the per-language SEO
66
+ * sidecar through `entityMetadata`, already resolved into the request's
67
+ * language by the shop API, so a German product page carries a German title
68
+ * without this file knowing anything about translation.
69
+ */
70
+ export async function generateMetadata({
71
+ params,
72
+ searchParams,
73
+ }: {
74
+ params: Promise<{ slug: string }>;
75
+ searchParams: Promise<RouteSearchParams>;
76
+ }): Promise<Metadata> {
77
+ const [{ slug }, resolvedSearchParams] = await Promise.all([params, searchParams]);
78
+ const { binding, shopName, channelResolved } = await getRequestLocale();
79
+ const product = await productForRequest(slug);
80
+ const resolution = resolveProductPath({ requestedSlug: slug, product, binding });
81
+ const entity = product ? entityMetadata(product, binding.locale) : null;
82
+ return routeMetadata({
83
+ binding,
84
+ shopName,
85
+ channelResolved,
86
+ pathname: `/products/${slug}`,
87
+ searchParams: resolvedSearchParams,
88
+ // Unlike a structural route, a product earns its alternates per language:
89
+ // the map is the intersection of "has a slug" and "has content", so an
90
+ // untranslated language never appears. `buildAlternates` then returns
91
+ // undefined for a fallback copy — no canonical, no hreflang — because a
92
+ // derived page has no address of its own to claim and must not be pulled
93
+ // into a reciprocal set it is not a member of. An absent product yields an
94
+ // empty map, which lands in that same arm rather than needing its own.
95
+ pathsByLocale: product ? productPathsByLocale(product) : {},
96
+ title: entity?.title ?? null,
97
+ description: entity?.description,
98
+ socialImage: entity?.socialImage,
99
+ contentIndexable: resolution.kind === 'ok' ? resolution.indexable : false,
100
+ });
101
+ }
16
102
 
17
103
  /**
18
104
  * Product detail (Blueprint `pages/product-detail`): identity block ordering —
@@ -21,25 +107,121 @@ import {
21
107
  * shoppers keep their place in the catalog.
22
108
  */
23
109
  // Next.js 15: route params are delivered as a Promise.
24
- export default function ProductDetailPage({ params }: { params: Promise<{ slug: string }> }) {
110
+ /**
111
+ * The slug law runs in the page SHELL, above the Suspense boundary, and that
112
+ * placement is the whole correctness of the route's status codes.
113
+ *
114
+ * `notFound()` and `permanentRedirect()` can only set a status where Next
115
+ * assigns one. Raised from inside a boundary — as the product lookup used to
116
+ * be — the shell has already flushed with a 200 and the throw resolves as
117
+ * client-rendered content instead: the response is a **200 carrying a
118
+ * not-found page**. A crawler drops a real 404 and INDEXES a soft one, so an
119
+ * unknown slug would have put an error page into the index under a URL that
120
+ * will never hold content. (Measured: `/products/no-such-product` answered 200
121
+ * before this moved.)
122
+ *
123
+ * The cost is that the product read is no longer streamed — but it is the one
124
+ * thing this page IS, so a skeleton bought nothing while the only content
125
+ * loaded. The genuinely secondary read (per-variant selling plans) stays in the
126
+ * boundary below, which is what streaming is for.
127
+ */
128
+ export default async function ProductDetailPage({
129
+ params,
130
+ searchParams,
131
+ }: {
132
+ params: Promise<{ slug: string }>;
133
+ searchParams: Promise<RouteSearchParams>;
134
+ }) {
135
+ const { binding } = await getRequestLocale();
136
+ const [{ slug }, resolvedSearchParams] = await Promise.all([params, searchParams]);
137
+ const product = await productForRequest(slug);
138
+ const resolution = resolveProductPath({ requestedSlug: slug, product, binding });
139
+
140
+ // `product === null` IS the resolution's notFound arm — spelled out rather
141
+ // than matched on `resolution.kind` so TypeScript narrows `product` for the
142
+ // render below (`notFound()` returns never). Matching the kind would leave it
143
+ // nullable and force a cast that asserts what the law already guarantees.
144
+ if (product === null) notFound();
145
+ if (resolution.kind === 'redirect') permanentRedirect(resolution.to);
146
+
25
147
  return (
26
148
  <div className="space-y-8">
149
+ {structuredData(product, binding, slug, resolvedSearchParams, resolution).map((node) => (
150
+ <script
151
+ key={node['@type']}
152
+ type="application/ld+json"
153
+ // The ONE way to put JSON-LD in a document; `serializeJsonLd` escapes
154
+ // `<`, so a description carrying `</script>` cannot close the element.
155
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(node) }}
156
+ />
157
+ ))}
27
158
  <Suspense fallback={<DetailSkeleton />}>
28
- <ProductDetail params={params} />
159
+ <ProductDetail product={product} locale={binding} />
29
160
  </Suspense>
30
161
  </div>
31
162
  );
32
163
  }
33
164
 
34
- async function ProductDetail({ params }: { params: Promise<{ slug: string }> }) {
35
- // Request-time hole (cacheComponents): the build keeps the fallback shell.
36
- await connection();
37
- const { slug } = await params;
38
- const product = await getProductBySlug(slug);
165
+ /**
166
+ * The structured data this page is entitled to emit — empty whenever it is not.
167
+ *
168
+ * Gated on the SAME `routeRobots` the metadata uses, because a page telling
169
+ * crawlers to ignore it while handing them a machine-readable description of
170
+ * its contents is making two opposite claims at once. `undefined` from that
171
+ * call is the one mechanism's way of saying "this is a real, indexable
172
+ * address", and it already covers the fallback copy, the merchant's own
173
+ * `indexable: false`, query-state views and the missing public origin.
174
+ *
175
+ * The origin is read ONCE and handed to both claims this function makes — the
176
+ * canonical `buildAlternates` derives, and the breadcrumb item URLs — so the
177
+ * two can never name different hosts. Its null arm is what narrows it to a
178
+ * string for those URLs; it cannot be taken here, because no-origin is one of
179
+ * the reasons the gate above would already have closed.
180
+ */
181
+ function structuredData(
182
+ product: ProductWithSeo,
183
+ binding: LocaleBinding,
184
+ slug: string,
185
+ searchParams: RouteSearchParams,
186
+ resolution: ReturnType<typeof resolveProductPath>,
187
+ ): JsonLdNode[] {
188
+ const contentIndexable = resolution.kind === 'ok' ? resolution.indexable : false;
189
+ if (routeRobots(`/products/${slug}`, searchParams, contentIndexable) !== undefined) return [];
190
+
191
+ const publicOrigin = getPublicOrigin();
192
+ const alternates = buildAlternates({
193
+ binding,
194
+ pathsByLocale: productPathsByLocale(product),
195
+ publicOrigin,
196
+ });
197
+ if (!alternates || publicOrigin === null) return [];
39
198
 
40
- if (!product) {
41
- notFound();
42
- }
199
+ return [
200
+ productJsonLd({
201
+ name: product.name,
202
+ description: product.description,
203
+ image: product.featuredAsset?.preview,
204
+ canonical: alternates.canonical,
205
+ variants: product.variants,
206
+ }),
207
+ breadcrumbJsonLd({
208
+ binding,
209
+ publicOrigin,
210
+ leafName: product.name,
211
+ trail: BREADCRUMB_TRAIL,
212
+ }),
213
+ ];
214
+ }
215
+
216
+ async function ProductDetail({
217
+ product,
218
+ locale: binding,
219
+ }: {
220
+ product: ProductWithSeo;
221
+ locale: LocaleBinding;
222
+ }) {
223
+ // Request-time hole: streamed inside its Suspense boundary.
224
+ await connection();
43
225
 
44
226
  // Subscription selling plans are eligible per variant, so fetch the groups for
45
227
  // each of the product's variants (in parallel) and pass a variantId -> groups
@@ -60,12 +242,13 @@ async function ProductDetail({ params }: { params: Promise<{ slug: string }> })
60
242
  <>
61
243
  <div className="breadcrumbs text-sm text-base-content/60">
62
244
  <ul>
63
- <li>
64
- <Link href="/">Home</Link>
65
- </li>
66
- <li>
67
- <Link href="/products">Products</Link>
68
- </li>
245
+ {BREADCRUMB_TRAIL.map((crumb) => (
246
+ <li key={crumb.path}>
247
+ <LocaleLink href={crumb.path} locale={binding}>
248
+ {crumb.name}
249
+ </LocaleLink>
250
+ </li>
251
+ ))}
69
252
  <li>
70
253
  <span aria-current="page">{product.name}</span>
71
254
  </li>
@@ -78,7 +261,7 @@ async function ProductDetail({ params }: { params: Promise<{ slug: string }> })
78
261
  // eslint-disable-next-line @next/next/no-img-element
79
262
  <img
80
263
  src={product.featuredAsset.preview}
81
- alt={product.name}
264
+ alt={getAssetAlt(product.featuredAsset)}
82
265
  className="h-full w-full object-cover"
83
266
  />
84
267
  ) : (
@@ -1,30 +1,65 @@
1
+ import type { Metadata } from 'next';
1
2
  import { connection } from 'next/server';
2
3
  import { Suspense } from 'react';
3
4
 
4
5
  import { ProductCard } from '../../components/ProductCard';
5
6
  import { getProducts } from '../../lib/forgecart';
7
+ import type { LocaleBinding } from '../../lib/locale/localized-path';
8
+ import { getRequestLocale } from '../../lib/locale/request-binding';
9
+ import { staticPathsByLocale } from '../../lib/seo/alternates';
10
+ import { routeMetadata } from '../../lib/seo/metadata';
11
+ import type { RouteSearchParams } from '../../lib/seo/noindex';
12
+
13
+ /**
14
+ * The catalog is indexable; a SORTED catalog is the same products in another
15
+ * order, so `?sort=` is a view of this page rather than a page of its own.
16
+ *
17
+ * The title is the template's own chrome, in English, exactly like this page's
18
+ * `<h1>`: structural surfaces are not translated content, and inventing a
19
+ * translation layer for them here would put a second, weaker source of truth
20
+ * beside the shop API's. The pages whose titles a merchant actually cares
21
+ * about — products — take theirs from the localized SEO sidecar.
22
+ */
23
+ export async function generateMetadata({
24
+ searchParams,
25
+ }: {
26
+ searchParams: Promise<RouteSearchParams>;
27
+ }): Promise<Metadata> {
28
+ const [resolvedSearchParams, { binding, languageCodes, shopName, channelResolved }] =
29
+ await Promise.all([searchParams, getRequestLocale()]);
30
+ return routeMetadata({
31
+ binding,
32
+ shopName,
33
+ channelResolved,
34
+ pathname: '/products',
35
+ pathsByLocale: staticPathsByLocale(languageCodes, '/products'),
36
+ searchParams: resolvedSearchParams,
37
+ title: 'All products',
38
+ });
39
+ }
6
40
 
7
41
  // PROGRESSIVE PRE-RENDERING: static shell; the data sections below are
8
42
  // per-request holes streamed inside their Suspense boundaries
9
- // (cacheComponents — see next.config.js).
43
+ // (see next.config.js for why every route is request-rendered).
10
44
 
11
45
  /**
12
46
  * Catalog — the browse grid. The header carries the count as orientation
13
47
  * (Blueprint listing guidance: state scope up front); cards do the visual
14
48
  * lifting, the grid stays quiet.
15
49
  */
16
- export default function ProductsPage() {
50
+ export default async function ProductsPage() {
51
+ const { binding } = await getRequestLocale();
17
52
  return (
18
53
  <div className="space-y-8">
19
54
  <Suspense fallback={<CatalogSkeleton />}>
20
- <Catalog />
55
+ <Catalog locale={binding} />
21
56
  </Suspense>
22
57
  </div>
23
58
  );
24
59
  }
25
60
 
26
- async function Catalog() {
27
- // Request-time hole (cacheComponents): the build keeps the fallback shell.
61
+ async function Catalog({ locale }: { locale: LocaleBinding }) {
62
+ // Request-time hole: streamed inside its Suspense boundary.
28
63
  await connection();
29
64
  const { items, totalItems } = await getProducts({ take: 24 });
30
65
 
@@ -44,7 +79,7 @@ async function Catalog() {
44
79
  ) : (
45
80
  <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 lg:grid-cols-4">
46
81
  {items.map((product) => (
47
- <ProductCard key={product.id} product={product} />
82
+ <ProductCard key={product.id} product={product} locale={locale} />
48
83
  ))}
49
84
  </div>
50
85
  )}
@@ -0,0 +1,54 @@
1
+ import type { Metadata } from 'next';
2
+
3
+ import { RegisterForm } from '../../components/account/RegisterForm';
4
+ import { getRequestLocale } from '../../lib/locale/request-binding';
5
+ import { routeMetadata } from '../../lib/seo/metadata';
6
+
7
+ /**
8
+ * `/register` — where a shopper creates an account (#1471).
9
+ *
10
+ * The page renders a form and nothing else: it asks the shop API nothing on
11
+ * GET, so unlike `/verify` there is no request-time hole to stream, and unlike
12
+ * `/reset-password` there is no token in the URL to read. Everything that
13
+ * happens here happens on the SUBMIT, over the shopper's own websocket.
14
+ *
15
+ * Unlike the two account-LINK routes, this one IS locale-prefixable — a
16
+ * shopper addresses it, from a link on this storefront, rather than receiving
17
+ * it in a mail — so `register` is deliberately absent from the grammar's
18
+ * unprefixed set and `/de/register` serves the German render.
19
+ */
20
+
21
+ const PAGE_TITLE = 'Create an account';
22
+
23
+ /**
24
+ * Noindex via `NOINDEX_PATHS`: a form with no content of its own competes with
25
+ * nothing, and it is the same posture `/cart` and `/checkout` take.
26
+ *
27
+ * No alternates, despite the route existing in every language. Hreflang is a
28
+ * claim about which address of a cluster a crawler should prefer, and a page
29
+ * that has just declined to be indexed at all has no such preference to state;
30
+ * the empty map is what makes `buildAlternates` withhold the claim rather than
31
+ * this page hand-rolling a robots value of its own.
32
+ */
33
+ export async function generateMetadata(): Promise<Metadata> {
34
+ const { binding, shopName, channelResolved } = await getRequestLocale();
35
+ return routeMetadata({
36
+ binding,
37
+ shopName,
38
+ channelResolved,
39
+ pathname: '/register',
40
+ pathsByLocale: {},
41
+ title: PAGE_TITLE,
42
+ });
43
+ }
44
+
45
+ export default async function RegisterPage() {
46
+ const { binding } = await getRequestLocale();
47
+
48
+ return (
49
+ <div className="mx-auto max-w-xl space-y-6">
50
+ <h1 className="text-2xl font-bold tracking-tight">{PAGE_TITLE}</h1>
51
+ <RegisterForm locale={binding} />
52
+ </div>
53
+ );
54
+ }
@@ -0,0 +1,60 @@
1
+ import type { Metadata } from 'next';
2
+
3
+ import { RequestPasswordResetForm } from '../../components/account/RequestPasswordResetForm';
4
+ import { ResetPasswordForm } from '../../components/account/ResetPasswordForm';
5
+ import { readLinkToken } from '../../lib/account/account-link';
6
+ import { getRequestLocale } from '../../lib/locale/request-binding';
7
+ import { routeMetadata } from '../../lib/seo/metadata';
8
+ import type { RouteSearchParams } from '../../lib/seo/noindex';
9
+
10
+ /**
11
+ * `/reset-password` — the page the platform's password-reset e-mail links to,
12
+ * and the place a shopper asks for that e-mail in the first place (#1472).
13
+ *
14
+ * ONE route, two modes, chosen by the URL's token:
15
+ *
16
+ * - `?token=…` — set a new password. The token is spent by the SUBMIT, never
17
+ * by the render: unlike verification, the shopper has something to type, so
18
+ * consuming the token on arrival would burn it before they got the chance.
19
+ * - no token — ask for a link. A shopper who lets one expire has nowhere else
20
+ * to go, and sending them back to the mail that is already stale is a dead
21
+ * end; the same address they were mailed at is all this form needs.
22
+ *
23
+ * Both modes render forms and nothing else, so unlike `/verify` there is no
24
+ * request-time hole to stream — the page reads the request and renders.
25
+ */
26
+
27
+ const PAGE_TITLE = 'Reset your password';
28
+
29
+ /** Noindex, and no alternates — for the reasons `/verify`'s metadata states. */
30
+ export async function generateMetadata(): Promise<Metadata> {
31
+ const { binding, shopName, channelResolved } = await getRequestLocale();
32
+ return routeMetadata({
33
+ binding,
34
+ shopName,
35
+ channelResolved,
36
+ pathname: '/reset-password',
37
+ pathsByLocale: {},
38
+ title: PAGE_TITLE,
39
+ });
40
+ }
41
+
42
+ export default async function ResetPasswordPage({
43
+ searchParams,
44
+ }: {
45
+ searchParams: Promise<RouteSearchParams>;
46
+ }) {
47
+ const [params, { binding }] = await Promise.all([searchParams, getRequestLocale()]);
48
+ const token = readLinkToken(params);
49
+
50
+ return (
51
+ <div className="mx-auto max-w-xl space-y-6">
52
+ <h1 className="text-2xl font-bold tracking-tight">{PAGE_TITLE}</h1>
53
+ {token === null ? (
54
+ <RequestPasswordResetForm locale={binding} />
55
+ ) : (
56
+ <ResetPasswordForm token={token} locale={binding} />
57
+ )}
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,69 @@
1
+ import type { MetadataRoute } from 'next';
2
+
3
+ import { NOINDEX_PATHS, NOINDEX_QUERY_KEYS } from '../lib/seo/noindex';
4
+ import { getPublicOrigin } from '../lib/seo/public-origin';
5
+
6
+ /**
7
+ * `robots.txt` (#1347, epic launch#54 W1-9) — the crawler-facing half of the
8
+ * indexability contract in `lib/seo/noindex.ts`.
9
+ *
10
+ * FORCE-DYNAMIC, and that is load-bearing rather than cautious. Next
11
+ * pre-renders metadata routes at build time by default, and a storefront is
12
+ * built once then run with its per-channel environment supplied afterwards. A
13
+ * build-time answer would freeze the BUILDER's environment — which never has a
14
+ * channel's public origin — into `Disallow: /` for every store shipped from
15
+ * that image, permanently, with no way to correct it short of a rebuild. The
16
+ * scaffold is frozen at provisioning time (#1041), so "permanently" is literal.
17
+ */
18
+ export const dynamic = 'force-dynamic';
19
+
20
+ /**
21
+ * Infrastructure routes that are never a destination for a human or a crawler:
22
+ * the tracking relay, the error beacon, the SDK proxy and the health probe.
23
+ *
24
+ * Deliberately NOT derived from the locale grammar's `UNPREFIXED_FIRST_SEGMENTS`
25
+ * despite the overlap. That set answers "which first segments are not a
26
+ * language?" and correctly contains `sitemap.xml`, `robots.txt`, `_next` and
27
+ * `favicon.ico` — every one of which a crawler MUST be allowed to fetch.
28
+ * Sharing one list would make the next infrastructure route silently
29
+ * uncrawlable, or a crawlable asset silently unprefixed.
30
+ */
31
+ const INFRASTRUCTURE_DISALLOW: readonly string[] = [
32
+ '/api/',
33
+ '/__fc/',
34
+ '/__forge_beacon',
35
+ '/ping',
36
+ ];
37
+
38
+ /**
39
+ * Without a public origin the storefront asserts nothing, so `robots.txt`
40
+ * refuses the whole tree. With one, it mirrors the noindex set — the page
41
+ * rules are DERIVED from `noindex.ts` rather than restated, because a
42
+ * `robots.txt` that disagrees with the pages' own `robots` metadata is the
43
+ * exact drift this module exists to prevent.
44
+ *
45
+ * The `Sitemap:` line rides the origin BRANCH, not merely the origin value: the
46
+ * URL it names has to be absolute, and the document it points at is empty
47
+ * without an origin. Announcing an empty sitemap is a claim about the catalog;
48
+ * announcing nothing is the absence of one, which is all a deployment that has
49
+ * not been told its own address is entitled to say.
50
+ */
51
+ export default function robots(): MetadataRoute.Robots {
52
+ const publicOrigin = getPublicOrigin();
53
+ if (publicOrigin === null) {
54
+ return { rules: { userAgent: '*', disallow: '/' } };
55
+ }
56
+
57
+ return {
58
+ rules: {
59
+ userAgent: '*',
60
+ allow: '/',
61
+ disallow: [
62
+ ...NOINDEX_PATHS,
63
+ ...INFRASTRUCTURE_DISALLOW,
64
+ ...NOINDEX_QUERY_KEYS.map((key) => `/*?${key}=`),
65
+ ],
66
+ },
67
+ sitemap: `${publicOrigin}/sitemap.xml`,
68
+ };
69
+ }
@@ -0,0 +1,106 @@
1
+ import type { MetadataRoute } from 'next';
2
+
3
+ import { getProductSeoEntries, getServedPageRoutes } from '../lib/forgecart';
4
+ import { resolveChannelLocales } from '../lib/locale/channel-locales-loader';
5
+ import { getPublicOrigin } from '../lib/seo/public-origin';
6
+ import { readScaffoldedRoutes } from '../lib/seo/scaffolded-routes';
7
+ import { createSitemapCache, sitemapWindowMs } from '../lib/seo/sitemap-cache';
8
+ import {
9
+ MAX_SITEMAP_URLS,
10
+ STATIC_ROUTES,
11
+ collectProductSitemapUrls,
12
+ mergeScaffoldedRoutes,
13
+ pageSitemapRoutes,
14
+ sitemapBudgetWarning,
15
+ staticSitemapUrls,
16
+ } from '../lib/seo/sitemap-entries';
17
+
18
+ /**
19
+ * `sitemap.xml` (#1347, epic launch#54 W1-9) — the discovery half of the SEO
20
+ * contract, listing every URL this deployment is willing to be indexed at.
21
+ *
22
+ * FORCE-DYNAMIC for the same load-bearing reason as `robots.ts`: Next
23
+ * pre-renders metadata routes at build time, the storefront image is built
24
+ * once and handed its channel environment afterwards, and the scaffold is
25
+ * frozen at provisioning time (#1041). A build-time answer would freeze the
26
+ * BUILDER's empty environment — no origin, no channel, no catalog — into an
27
+ * empty sitemap for every store shipped from that image, permanently. The
28
+ * 60s window is held in-process by `createSitemapCache` for exactly the same
29
+ * reason ISR is refused here: `revalidate` would pre-render the first response
30
+ * at build time too, which is the identical failure wearing a TTL.
31
+ */
32
+ export const dynamic = 'force-dynamic';
33
+
34
+ /**
35
+ * An empty document without a public origin, and no `<lastmod>` claims — the
36
+ * same refusal `robots.ts` and every page's `robots` metadata make in that
37
+ * state. Every URL a sitemap lists is absolute, so a deployment with no origin
38
+ * has nothing truthful to put in one.
39
+ *
40
+ * A backend failure is deliberately NOT caught: an unreachable shop makes this
41
+ * route 5xx, which tells a crawler to come back. Serving an empty sitemap
42
+ * instead would assert that the catalog is empty — the one answer that is
43
+ * actively false, and the one a crawler would act on by dropping URLs.
44
+ */
45
+ const cache = createSitemapCache(build, { ttlMs: sitemapWindowMs });
46
+
47
+ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
48
+ return cache.get();
49
+ }
50
+
51
+ /**
52
+ * The refusal is inside the window rather than guarding it, because the origin
53
+ * is memoized for the life of the process: an unconfigured deployment stays
54
+ * unconfigured until it restarts, so caching its empty answer cannot make it
55
+ * any staler than the memo already has.
56
+ */
57
+ async function build(): Promise<MetadataRoute.Sitemap> {
58
+ const publicOrigin = getPublicOrigin();
59
+ if (publicOrigin === null) return [];
60
+
61
+ const { channel } = await resolveChannelLocales(null);
62
+ const { languageCodes, defaultLanguageCode } = channel;
63
+
64
+ // The template's own routes, every page scaffolded since, and every ACF page
65
+ // the shop says it currently SERVES (#1934). Read per build rather than at
66
+ // module load, so a page created a moment ago is in the document on the next
67
+ // window — the reason `createStorefrontPage` writes a row at all, and the
68
+ // reason the ACF feed is a live query rather than a file: an ACF page is
69
+ // published through the API and writes nothing to this pod's disk.
70
+ //
71
+ // PRECEDENCE falls out of the merge and is worth naming: the base table wins
72
+ // over both, and within the appended list the FIRST claimant of a path wins —
73
+ // so a code-scaffolded page at `/pages/x` outranks an ACF page at the same
74
+ // slug. That is also how Next routes them (a literal `app/pages/x/page.tsx`
75
+ // shadows the dynamic `[slug]`), so the document cannot disagree with the
76
+ // router about which page an address serves.
77
+ const [scaffolded, servedPageRoutes] = await Promise.all([
78
+ readScaffoldedRoutes(),
79
+ getServedPageRoutes(defaultLanguageCode),
80
+ ]);
81
+ const routes = mergeScaffoldedRoutes(STATIC_ROUTES, [
82
+ ...scaffolded,
83
+ ...pageSitemapRoutes(servedPageRoutes),
84
+ ]);
85
+ const staticUrls = staticSitemapUrls(languageCodes, defaultLanguageCode, publicOrigin, routes);
86
+ const productUrls = await collectProductSitemapUrls(
87
+ async (skip, take) => getProductSeoEntries(defaultLanguageCode, { skip, take }),
88
+ defaultLanguageCode,
89
+ publicOrigin,
90
+ Math.max(0, MAX_SITEMAP_URLS - staticUrls.length),
91
+ );
92
+
93
+ const urls = [...staticUrls, ...productUrls];
94
+
95
+ // The one place this template writes to the console, and it earns it: a
96
+ // catalog that outgrows a single XML sitemap would otherwise stop being
97
+ // submitted for indexing in complete silence, inside the feature whose whole
98
+ // job is getting pages indexed. There is no logger in a scaffolded storefront
99
+ // and no channelId to tag — only the channel token, which is a credential and
100
+ // must never reach a log — so the pod's stdout is the channel, and the 60s
101
+ // window bounds this to at most one line a minute.
102
+ const warning = sitemapBudgetWarning(urls.length);
103
+ if (warning !== null) console.warn(warning);
104
+
105
+ return urls;
106
+ }