@forgecart/cli 2.202610052143.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,166 @@
1
+ /**
2
+ * The storefront's public origin (#1347, epic launch#54 W1-9).
3
+ *
4
+ * This one value decides whether the deployment is ENTITLED to make claims to
5
+ * a search engine. A storefront reachable only at an ephemeral preview URL has
6
+ * no stable address to be canonical about, so every absolute claim it could
7
+ * emit — canonical, hreflang, sitemap entry — would name a host that stops
8
+ * existing when the pod is reaped. The posture that follows is "assert
9
+ * nothing": see `noindex.ts`, which reads this and nothing else to decide the
10
+ * deployment-wide floor.
11
+ *
12
+ * The value is supplied by `forgecart init` (`--public-origin`), which writes
13
+ * `FORGECART_PUBLIC_ORIGIN` for a published storefront and OMITS it for
14
+ * preview pods. Absence is therefore a real, expected, meaningful state — not
15
+ * a misconfiguration to warn about.
16
+ *
17
+ * ABSENT and REJECTED are different states and are reported differently
18
+ * (#1677). Both produce a null origin and therefore `Disallow: /`, and for a
19
+ * long time both did so in complete silence — so a deployment configured with,
20
+ * say, a scheme-less `DEPLOYMENT_URL_TEMPLATE` built, deployed, served 200s,
21
+ * rendered correctly to a human, and told every crawler not to index it, with
22
+ * no error anywhere. The first signal was an unexplained traffic collapse.
23
+ *
24
+ * The asymmetry that caused it: this module is a SECURITY boundary and must
25
+ * reject anything it cannot vouch for, while the OPERATOR needs to know their
26
+ * configuration was rejected. Silence served the first obligation and betrayed
27
+ * the second. The rejections below are unchanged — they were always correct —
28
+ * and a rejected value is now stated on stdout instead of being swallowed.
29
+ */
30
+
31
+ /** Why a present value could not be used as a public origin. */
32
+ export type PublicOriginRejection =
33
+ | 'not-a-url'
34
+ | 'unsupported-scheme'
35
+ | 'carries-path'
36
+ | 'carries-query-or-fragment';
37
+
38
+ /**
39
+ * The three genuinely different states of this variable.
40
+ *
41
+ * A discriminated union rather than `string | null`, because the null was
42
+ * doing two incompatible jobs: "this is a preview deployment, as intended" and
43
+ * "your configuration is broken". Collapsing them is what made the second one
44
+ * invisible.
45
+ */
46
+ export type PublicOriginResolution =
47
+ | { kind: 'absent' }
48
+ | { kind: 'ok'; origin: string }
49
+ | { kind: 'rejected'; raw: string; reason: PublicOriginRejection };
50
+
51
+ /**
52
+ * Classify a raw `FORGECART_PUBLIC_ORIGIN`.
53
+ *
54
+ * Never throws: this runs inside `robots.txt` and every page's metadata, where
55
+ * a throw would surface as a 500 on the one route whose entire job is to answer
56
+ * conservatively — strictly worse than the `Disallow: /` a refusal produces.
57
+ *
58
+ * Rejected on purpose:
59
+ * - non-http(s) schemes — `javascript:` and `data:` in a canonical `<link>` or
60
+ * a `Sitemap:` line are injection surfaces, not addresses;
61
+ * - anything carrying a path, query or fragment — an origin with a path
62
+ * silently produces double-pathed canonicals (`https://x/shop/products` from
63
+ * origin `https://x/shop`), which is a duplicate-content bug that looks
64
+ * correct in review.
65
+ */
66
+ export function resolvePublicOrigin(raw: string | undefined): PublicOriginResolution {
67
+ const trimmed = raw?.trim();
68
+ if (!trimmed) return { kind: 'absent' };
69
+
70
+ let parsed: URL;
71
+ try {
72
+ parsed = new URL(trimmed);
73
+ } catch {
74
+ return { kind: 'rejected', raw: trimmed, reason: 'not-a-url' };
75
+ }
76
+
77
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
78
+ return { kind: 'rejected', raw: trimmed, reason: 'unsupported-scheme' };
79
+ }
80
+ // Unreachable for http/https — they are WHATWG "special" schemes, for which
81
+ // an empty host is a parse failure that the catch above already took. Kept
82
+ // as a belt on a security boundary, and classified as malformed because that
83
+ // is what an http URL with no host is.
84
+ if (!parsed.host) return { kind: 'rejected', raw: trimmed, reason: 'not-a-url' };
85
+ // `new URL('https://x')` normalizes pathname to '/', so '/' is the only
86
+ // pathname a bare origin can have.
87
+ if (parsed.pathname !== '/') {
88
+ return { kind: 'rejected', raw: trimmed, reason: 'carries-path' };
89
+ }
90
+ if (parsed.search || parsed.hash) {
91
+ return { kind: 'rejected', raw: trimmed, reason: 'carries-query-or-fragment' };
92
+ }
93
+
94
+ return { kind: 'ok', origin: `${parsed.protocol}//${parsed.host}` };
95
+ }
96
+
97
+ /**
98
+ * The origin, or null when there is none to use.
99
+ *
100
+ * Kept as the shape every caller wants — `noindex.ts` and `alternates.ts` ask
101
+ * "do we have an address", not "why not". The classification above is for the
102
+ * one caller that has to REPORT, immediately below.
103
+ */
104
+ export function parsePublicOrigin(raw: string | undefined): string | null {
105
+ const resolution = resolvePublicOrigin(raw);
106
+ return resolution.kind === 'ok' ? resolution.origin : null;
107
+ }
108
+
109
+ /** What an operator needs to hear, per rejection reason. */
110
+ function rejectionDetail(reason: PublicOriginRejection): string {
111
+ switch (reason) {
112
+ case 'not-a-url':
113
+ // The scheme hint is safe to give unconditionally: every value this
114
+ // reason can describe fails to parse as an absolute URL, and every
115
+ // absolute URL this module would accept begins with one of those two
116
+ // schemes. It is also the likeliest cause by far — a deployment template
117
+ // written as a host pattern rather than a URL pattern.
118
+ return 'it is not a parseable absolute URL (an origin must begin with the http:// or https:// scheme)';
119
+ case 'unsupported-scheme':
120
+ return 'it has no http:// or https:// scheme (a bare host is not an origin)';
121
+ case 'carries-path':
122
+ return 'it carries a path — an origin is scheme + host only';
123
+ case 'carries-query-or-fragment':
124
+ return 'it carries a query or fragment — an origin is scheme + host only';
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Cached because the origin is deployment identity: it cannot change without
130
+ * a new process, and every rendered route asks for it.
131
+ *
132
+ * Resolved LAZILY rather than at module load. A module-level constant is
133
+ * evaluated when the bundle is first imported, which for a `next build` can be
134
+ * build time — freezing whatever the builder's environment happened to hold
135
+ * into the shipped bundle. Storefronts are built once and run with per-channel
136
+ * env supplied later, so a build-time read would bake "no origin" into every
137
+ * published store permanently.
138
+ */
139
+ let cached: { readonly value: string | null } | null = null;
140
+
141
+ /**
142
+ * The deployment's public origin, or null when it has none.
143
+ *
144
+ * A REJECTED value is reported here, exactly once per process — the memo makes
145
+ * that free, and once is right: this is deployment identity, so repeating it
146
+ * per request would bury the line it is trying to surface. An ABSENT value
147
+ * says nothing, because a preview pod with no origin is working as designed
148
+ * and a warning there would train operators to ignore this channel.
149
+ *
150
+ * `console.error` and not a thrown error, for the reason the classifier above
151
+ * does not throw either: the storefront must keep serving. It just must stop
152
+ * doing so silently.
153
+ */
154
+ export function getPublicOrigin(): string | null {
155
+ if (cached === null) {
156
+ const resolution = resolvePublicOrigin(process.env.FORGECART_PUBLIC_ORIGIN);
157
+ if (resolution.kind === 'rejected') {
158
+ console.error(
159
+ `[public-origin] FORGECART_PUBLIC_ORIGIN is set to ${JSON.stringify(resolution.raw)} but was REJECTED: ${rejectionDetail(resolution.reason)}. ` +
160
+ 'This storefront will serve "Disallow: /" and every page will be noindex until it is corrected — the site stays reachable, but no search engine will index it.',
161
+ );
162
+ }
163
+ cached = { value: resolution.kind === 'ok' ? resolution.origin : null };
164
+ }
165
+ return cached.value;
166
+ }
@@ -0,0 +1,86 @@
1
+ import type { RedirectRule } from '../../seo/redirects';
2
+ import { isUnprefixedPath, parseLocalePath } from '../locale/grammar';
3
+
4
+ /**
5
+ * The rename-redirect rule (#1347, epic launch#54 W1-9).
6
+ *
7
+ * Separate from the table it reads (`src/seo/redirects.ts`) on purpose. That
8
+ * file is edited by a merchant and rewritten by the agent on every rename; if
9
+ * the matching logic lived beside the data, an edit meant to add one row could
10
+ * silently take the rule with it. Data there, machinery here, and the boundary
11
+ * is what makes an automated edit to that file structurally safe.
12
+ *
13
+ * Pure, like every other rule in this layer — the middleware imports
14
+ * `next/server` and is therefore unreachable by the template spec project, so
15
+ * a decision left in the middleware could only ever be exercised by booting a
16
+ * server. This is the same split `planLocaleRewrite` uses.
17
+ */
18
+
19
+ /** How many links of a redirect chain are followed before giving up. */
20
+ export const MAX_REDIRECT_HOPS = 8;
21
+
22
+ /**
23
+ * Where this request should be sent, or null when it should be served.
24
+ *
25
+ * Returns the FINAL destination of a chain rather than the next link in it.
26
+ * `/a → /b → /c` answers one 308 to `/c`: each additional hop costs a
27
+ * round-trip, and search engines both dilute the signal they carry across a
28
+ * chain and stop following one entirely after a handful of links, so a chain
29
+ * served link-by-link quietly loses exactly what the redirect existed to save.
30
+ *
31
+ * A cycle resolves to null — the page is served (or 404s) instead. That is the
32
+ * strictly better failure: a browser caught in a redirect loop shows an error
33
+ * page for every URL in the cycle, while a missing redirect costs only the one
34
+ * URL that was renamed. The hop cap makes the same choice for a chain too long
35
+ * to be worth following.
36
+ */
37
+ export function planRedirect(pathname: string, table: readonly RedirectRule[]): string | null {
38
+ // Infrastructure routes are not pages and must never be redirected: a
39
+ // merchant entry for `/robots.txt` or `/api/...` would break crawling or the
40
+ // SDK proxy, and nothing downstream would report it.
41
+ if (isUnprefixedPath(pathname)) return null;
42
+
43
+ const { localeCandidate, pathname: routePath } = parseLocalePath(pathname);
44
+
45
+ const seen = new Set<string>([routePath]);
46
+ let current = routePath;
47
+ for (let hop = 0; hop < MAX_REDIRECT_HOPS; hop += 1) {
48
+ const next = table.find((rule) => normalize(rule.from) === current);
49
+ if (!next) {
50
+ // Unmoved on the first look means there is nothing to do at all; unmoved
51
+ // later means the chain ended, and `current` is where it ended.
52
+ return current === routePath ? null : prefixed(current, localeCandidate);
53
+ }
54
+ const target = normalize(next.to);
55
+ if (seen.has(target)) return null;
56
+ seen.add(target);
57
+ current = target;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * The redirect re-enters the language it left from.
64
+ *
65
+ * The candidate is re-attached unvalidated, deliberately: middleware is
66
+ * zero-fetch by contract and cannot know which languages the channel offers.
67
+ * `/zz/summer-sale` therefore redirects to `/zz/promotions/summer`, which the
68
+ * layout then 404s — the same answer `/zz/promotions/summer` would have given
69
+ * if it had been requested directly, which is the point. Validity is decided
70
+ * in one place, and it is not this one.
71
+ */
72
+ function prefixed(path: string, localeCandidate: string | null): string {
73
+ return localeCandidate === null ? path : `/${localeCandidate}${path}`;
74
+ }
75
+
76
+ /**
77
+ * A table entry as an address, so `/summer-sale/` and `/summer-sale` are the
78
+ * same entry. Matching is otherwise EXACT — no prefixes, no patterns: a rule
79
+ * that matched `/summer-sale/tents` because `/summer-sale` moved would drag
80
+ * along URLs nobody decided to move.
81
+ */
82
+ function normalize(path: string): string {
83
+ const trimmed = path.trim();
84
+ const rooted = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
85
+ return rooted.length > 1 && rooted.endsWith('/') ? rooted.slice(0, -1) : rooted;
86
+ }
@@ -0,0 +1,107 @@
1
+ import { localizedPath } from '../locale/localized-path';
2
+ import type { LocaleBinding } from '../locale/localized-path';
3
+ import { advertisedPathsByLocale, ownContentRow } from './sidecar';
4
+ import type { SeoSidecar } from './sidecar';
5
+
6
+ /**
7
+ * The language-first slug law for product URLs (#1347, epic launch#54 W1-9).
8
+ *
9
+ * Every product URL must end in one of four honest answers, and which one is
10
+ * decided HERE rather than in the route, so the rule is a table of vectors
11
+ * instead of a branch nobody can enumerate.
12
+ *
13
+ * The whole law rests on one backend guarantee (#1339): a product fetched
14
+ * through a locale's own client answers with THAT locale's current slug in
15
+ * `slug`, and `resolvedLanguageCode` names the language whose current *or
16
+ * previous* slug actually matched the lookup. So a stale slug, a renamed
17
+ * product's previous slug, and a request that used another language's slug all
18
+ * arrive here as the same observable fact — `product.slug` differs from what
19
+ * was asked for — and all three deserve the same answer: one permanent redirect
20
+ * to the address this locale actually uses.
21
+ *
22
+ * That collapse is deliberate. Treating them as three cases would mean three
23
+ * lookups of "which language was this slug from", and the backend has already
24
+ * done that work.
25
+ */
26
+
27
+ /** One language's slug for a product. */
28
+ export interface ProductTranslationRow {
29
+ languageCode: string;
30
+ slug: string;
31
+ }
32
+
33
+ /** The product fields the law reads — a structural subset of the SDK's product. */
34
+ export interface ResolvableProduct {
35
+ /** The CURRENT slug in the requesting locale's language. */
36
+ slug: string;
37
+ /** Every language's slug for this product, translated or not. */
38
+ translations: readonly ProductTranslationRow[];
39
+ seo: SeoSidecar;
40
+ }
41
+
42
+ /**
43
+ * The languages that may be ADVERTISED for this product, mapped to their paths.
44
+ *
45
+ * A product's ADDRESS is earned per language — `translations` says a language
46
+ * has a SLUG — and the sidecar's intersection rule (`advertisedPathsByLocale`)
47
+ * keeps only the ones that also have CONTENT, so a slug-bearing fallback copy
48
+ * is never advertised as an hreflang alternate.
49
+ */
50
+ export function productPathsByLocale(product: ResolvableProduct): Record<string, string> {
51
+ const addresses = Object.fromEntries(
52
+ product.translations.map((row) => [row.languageCode, `/products/${row.slug}`]),
53
+ );
54
+ return advertisedPathsByLocale(addresses, product.seo);
55
+ }
56
+
57
+ /** What the route must do with this URL. */
58
+ export type PathResolution =
59
+ | { kind: 'ok'; indexable: boolean }
60
+ | { kind: 'redirect'; to: string }
61
+ | { kind: 'fallback' }
62
+ | { kind: 'notFound' };
63
+
64
+ export interface ResolveProductPathInput {
65
+ /** The slug exactly as it appeared in the URL. */
66
+ requestedSlug: string;
67
+ /** The product as fetched through THIS locale's client, or null when absent. */
68
+ product: ResolvableProduct | null;
69
+ binding: LocaleBinding;
70
+ }
71
+
72
+ /**
73
+ * Decide the honest answer for a product URL.
74
+ *
75
+ * Ordered so that each arm can assume the ones above it did not fire.
76
+ */
77
+ export function resolveProductPath({
78
+ requestedSlug,
79
+ product,
80
+ binding,
81
+ }: ResolveProductPathInput): PathResolution {
82
+ // No product answered to this slug in any language — a real 404, not a
83
+ // rendered "not found" page at a 200. A crawler indexes a soft-404; it drops
84
+ // a real one.
85
+ if (!product) return { kind: 'notFound' };
86
+
87
+ // The locale addresses this product by a different slug than the one asked
88
+ // for. One redirect covers a stale slug, a renamed product's previous slug
89
+ // (#1339's `previousSlug`), and a request that used another language's slug:
90
+ // the backend already resolved which, and the answer is the same regardless.
91
+ if (product.slug !== requestedSlug) {
92
+ return { kind: 'redirect', to: localizedPath(`/products/${product.slug}`, binding) };
93
+ }
94
+
95
+ // No content of this language's own (`ownContentRow` — a missing row reads
96
+ // as untranslated) means the shopper is looking at a derived copy. It
97
+ // renders, but it makes no claims: noindex, no hreflang, no canonical.
98
+ // Indexing it would put near-duplicate copy in competition with the language
99
+ // that genuinely has the content.
100
+ const row = ownContentRow(product.seo, binding.locale);
101
+ if (row === null) return { kind: 'fallback' };
102
+
103
+ // Real content in this language. The merchant's own indexability choice is
104
+ // the last word, and it rides on the resolution rather than being re-derived
105
+ // by the caller — one lookup, one answer.
106
+ return { kind: 'ok', indexable: row.indexable };
107
+ }
@@ -0,0 +1,83 @@
1
+ import 'server-only';
2
+
3
+ import { readdir, readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+
6
+ import { SCAFFOLDED_ROUTES_DIR, parseScaffoldedRoute } from './sitemap-entries';
7
+ import type { StaticRoute } from './sitemap-entries';
8
+
9
+ /**
10
+ * Reads the rows scaffolded pages dropped into `src/seo/routes.d/` (#1347).
11
+ *
12
+ * Separate from `sitemap-entries.ts` because it touches the filesystem, and
13
+ * that module is imported by the template spec project — an `node:fs` read
14
+ * there would move the sitemap's rules out of the vector layer and into
15
+ * something only a running server can exercise. Rules there, IO here.
16
+ *
17
+ * A directory rather than a generated barrel file for the same reason it is a
18
+ * directory on the writing side: an import list would have to be read, edited
19
+ * and rewritten, which two concurrent scaffolds can do in an order that loses
20
+ * one of them. Reading a directory has no such step.
21
+ */
22
+
23
+ /**
24
+ * Every scaffolded route, or an empty list.
25
+ *
26
+ * Never throws, and that is deliberate: this runs inside `sitemap.xml`, where
27
+ * the alternatives to an empty list are a 5xx (which tells a crawler to come
28
+ * back to a broken document) or a half-built sitemap presented as complete. A
29
+ * missing directory is the NORMAL state — a scaffold that has created no pages
30
+ * has none — so it is not even a warning.
31
+ *
32
+ * A fragment that does not parse is skipped and named on stdout. Skipping is
33
+ * the honest half: the file is written by another process and editable by
34
+ * hand, so a malformed one is a bug in the writer, and guessing what it meant
35
+ * would publish a URL nobody chose. Naming it is the other half — a route
36
+ * silently missing from the sitemap is exactly the failure this feature
37
+ * exists to prevent, so it must not be able to happen quietly.
38
+ */
39
+ export async function readScaffoldedRoutes(): Promise<StaticRoute[]> {
40
+ const directory = join(process.cwd(), SCAFFOLDED_ROUTES_DIR);
41
+
42
+ let files: string[];
43
+ try {
44
+ files = (await readdir(directory)).filter((name) => name.endsWith('.json'));
45
+ } catch {
46
+ // Absent (nothing scaffolded yet) or unreadable. Both mean the same thing
47
+ // to a sitemap: there are no scaffolded routes to list.
48
+ return [];
49
+ }
50
+
51
+ // Async, and read in parallel, because this runs on a request path: a
52
+ // merchant with a few hundred scaffolded pages is a few hundred file reads,
53
+ // and doing them synchronously would block the event loop for every other
54
+ // request in flight. The 60s window makes it rare, not free.
55
+ const named = files.sort((a, b) => a.localeCompare(b));
56
+ const parsed = await Promise.all(named.map((name) => readOne(join(directory, name))));
57
+
58
+ const routes: StaticRoute[] = [];
59
+ for (const [index, route] of parsed.entries()) {
60
+ if (route === null) {
61
+ console.warn(
62
+ `[sitemap] ignoring ${SCAFFOLDED_ROUTES_DIR}/${named[index]} — it is not a {"path","indexable"} row, so the page it names is NOT in the XML sitemap.`,
63
+ );
64
+ continue;
65
+ }
66
+ routes.push(route);
67
+ }
68
+ return routes;
69
+ }
70
+
71
+ /**
72
+ * One fragment, or null when it is unusable for any reason — unreadable,
73
+ * not JSON, or not the shape. The caller reports; this only decides.
74
+ */
75
+ async function readOne(path: string): Promise<StaticRoute | null> {
76
+ let parsed: unknown;
77
+ try {
78
+ parsed = JSON.parse(await readFile(path, 'utf8'));
79
+ } catch {
80
+ return null;
81
+ }
82
+ return parseScaffoldedRoute(parsed);
83
+ }
@@ -0,0 +1,75 @@
1
+ import type { PathsByLocale } from './alternates';
2
+
3
+ /**
4
+ * What the per-language SEO sidecar says about an entity's COPIES (#1341,
5
+ * ACF pages #1375): which languages hold content of their own, and whether
6
+ * this locale's copy may be indexed.
7
+ *
8
+ * One rule for every entity that carries a sidecar. A product and an ACF page
9
+ * entry ask the same two questions of the same rows, and two spellings of the
10
+ * answer would let a product and a page disagree about what an untranslated
11
+ * language is — one advertising a fallback copy the other refuses to (#1375
12
+ * ruling Q6: pages take product parity).
13
+ *
14
+ * PURE, for the reason every module under `lib/seo/` is: the template-spec
15
+ * imports it by relative path from outside the scaffold, so nothing reachable
16
+ * from here may need the template's Next toolchain.
17
+ */
18
+
19
+ /** One language's row in the sidecar, narrowed to what these claims read. */
20
+ export interface SeoLanguageRow {
21
+ languageCode: string;
22
+ /** Whether this language has real content, as opposed to a derived fallback. */
23
+ translated: boolean;
24
+ /** The merchant's explicit indexability choice for this language. */
25
+ indexable: boolean;
26
+ }
27
+
28
+ /** An entity's sidecar, as far as these claims read it. */
29
+ export interface SeoSidecar {
30
+ languages: readonly SeoLanguageRow[];
31
+ }
32
+
33
+ /**
34
+ * This locale's row when the locale holds content of its own, or null when the
35
+ * shopper is looking at a derived fallback copy — real content in another
36
+ * language, shown here so the URL is not a dead end.
37
+ *
38
+ * A MISSING row is treated exactly like `translated: false`. The sidecar is
39
+ * supposed to carry one entry per channel language, so absence means the
40
+ * sidecar and the channel disagree — and the safe reading of a disagreement
41
+ * is the one that makes no claim.
42
+ */
43
+ export function ownContentRow(seo: SeoSidecar, locale: string): SeoLanguageRow | null {
44
+ const row = seo.languages.find((entry) => entry.languageCode === locale);
45
+ return row?.translated ? row : null;
46
+ }
47
+
48
+ /**
49
+ * The languages that may be ADVERTISED for this entity, mapped to their paths:
50
+ * every language that has an address in `addresses` AND content of its own.
51
+ *
52
+ * The intersection of two different facts, and taking either alone is a bug.
53
+ * An address says the language can be REACHED — a product's per-language slug,
54
+ * or a page route that exists in every language the channel offers; the
55
+ * sidecar's `translated` says the language has CONTENT. A language can have the
56
+ * first without the second — that is exactly the fallback copy — and
57
+ * advertising it as an hreflang alternate would point a crawler at a derived
58
+ * page and pull it into a reciprocal set it does not belong to.
59
+ *
60
+ * `indexable: false` does NOT remove a language: the merchant suppressing a
61
+ * page they own leaves the translation in place, so its siblings must keep
62
+ * naming it or the cluster's reciprocity breaks. Indexability is `noindex.ts`'s
63
+ * job, not this map's. The order is the addresses' own.
64
+ */
65
+ export function advertisedPathsByLocale(
66
+ addresses: PathsByLocale,
67
+ seo: SeoSidecar,
68
+ ): Record<string, string> {
69
+ const translated = new Set(
70
+ seo.languages.filter((row) => row.translated).map((row) => row.languageCode),
71
+ );
72
+ return Object.fromEntries(
73
+ Object.entries(addresses).filter(([languageCode]) => translated.has(languageCode)),
74
+ );
75
+ }
@@ -0,0 +1,98 @@
1
+ import 'server-only';
2
+
3
+ import { cache } from 'react';
4
+
5
+ import { getShopClient } from '../forgecart';
6
+ import type { SiteVerificationMeta } from './metadata';
7
+
8
+ /**
9
+ * What the channel's SEO providers need this storefront to serve (#1530).
10
+ *
11
+ * A provider proves the merchant owns this address by asking them to place a
12
+ * token on it. The platform mints that token per account and per origin, and
13
+ * this read is the storefront's half of the handshake: it asks the shop API
14
+ * what to serve, and `metadata.ts` turns each descriptor into one
15
+ * `<meta name content>` tag. Nothing here knows which provider minted what —
16
+ * the NAME arrives in the descriptor, so a second provider is a server change
17
+ * and no template change at all.
18
+ *
19
+ * Only `META` placements are served. The mechanism rides in the descriptor
20
+ * precisely so that a provider verifying some other way — a DNS record, a file
21
+ * at a path this template does not route — cannot have its token silently
22
+ * rendered as a meta tag, which would be a placement that looks made and is
23
+ * not.
24
+ *
25
+ * PER REQUEST, and no further. React's `cache()` collapses the callers of one
26
+ * render into a single read; there is deliberately no TTL and no process memo
27
+ * above it. A verifier makes ONE fetch, and a token re-minted while a replica
28
+ * still serves a cached copy of the old one is a verification that fails with
29
+ * every part of the system reporting health — the same reason
30
+ * `sitemap-cache.ts`'s window is safe (a stale URL list costs a crawl) and one
31
+ * here would not be.
32
+ *
33
+ * Failure is ALWAYS an empty list, never a throw. This read happens inside the
34
+ * root layout's `generateMetadata`, structurally outside every Suspense
35
+ * boundary (D8), so a throw here is a 500 on every route of the store — and
36
+ * what is lost instead is one meta tag whose absence the platform already
37
+ * detects and retries on its next sync. An unconfigured scaffold reaches the
38
+ * same arm: `getShopClient()` throws when `.env` carries no coordinates, and
39
+ * the prewarm contract says a storefront with no configuration must SERVE.
40
+ */
41
+
42
+ /** The answer whenever there is nothing honest to serve. */
43
+ const NONE: readonly SiteVerificationMeta[] = [];
44
+
45
+ /**
46
+ * The one mechanism this template can satisfy — a tag in the document it
47
+ * renders. Compared against the descriptor's own value rather than assumed.
48
+ */
49
+ const META_METHOD = 'META';
50
+
51
+ /**
52
+ * How long the document shell may wait for this read.
53
+ *
54
+ * The same bound, for the same reason, as the channel read in
55
+ * `locale/channel-locales-loader.ts`: the shell has no Suspense boundary above
56
+ * it, so an unbounded wait holds the whole document open with nothing to time
57
+ * it out. Past this the storefront serves without the tag, which is the lesser
58
+ * of the two failures by a wide margin.
59
+ */
60
+ const VERIFICATION_READ_TIMEOUT_MS = 2_000;
61
+
62
+ /**
63
+ * The placements this storefront must serve, for THIS request.
64
+ *
65
+ * Read through the generated SDK like every other shop-API call in the
66
+ * template — there are no hand-written query documents here, which is what
67
+ * keeps the storefront's reads and the API's schema from drifting apart.
68
+ *
69
+ * The request's own client is used rather than one pinned to the channel
70
+ * default: the answer is the same in every language, so the cheaper of two
71
+ * correct options wins, and that is the socket this render already has open.
72
+ */
73
+ export const getSiteVerifications = cache(async (): Promise<readonly SiteVerificationMeta[]> => {
74
+ let timer: ReturnType<typeof setTimeout> | undefined;
75
+ try {
76
+ return await Promise.race([
77
+ readPlacements(),
78
+ new Promise<readonly SiteVerificationMeta[]>((resolve) => {
79
+ timer = setTimeout(() => {
80
+ resolve(NONE);
81
+ }, VERIFICATION_READ_TIMEOUT_MS);
82
+ }),
83
+ ]);
84
+ } catch {
85
+ // The template's sanctioned catch: an external SDK call, wrapped at its
86
+ // call site, exactly as `forgecart.ts#runAccountOperation` and
87
+ // `shop-session.ts` do. Nothing is swallowed that anyone here could act on
88
+ // — the platform's sync ladder is what observes an unserved placement.
89
+ return NONE;
90
+ } finally {
91
+ clearTimeout(timer);
92
+ }
93
+ });
94
+
95
+ async function readPlacements(): Promise<readonly SiteVerificationMeta[]> {
96
+ const { seoSiteVerifications } = await (await getShopClient()).seo.seoSiteVerifications();
97
+ return seoSiteVerifications.filter((placement) => placement.method === META_METHOD);
98
+ }