@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
@@ -0,0 +1,107 @@
1
+ import { isUnprefixedPath, parseLocalePath } from './grammar';
2
+
3
+ /**
4
+ * What the middleware does with a request path (#1346, epic launch#54 W1-8 /
5
+ * S1) — the edge stage's whole locale decision, as a value.
6
+ *
7
+ * PURE BY CONTRACT, for the same two reasons `grammar.ts` is: it runs on every
8
+ * request under the edge runtime, and `tool/storefront-template-spec` imports
9
+ * it directly from outside the scaffold. `middleware.ts` itself can never be
10
+ * pinned by that project — it imports `next/server` — so the decision lives
11
+ * here and the middleware is left as dispatch.
12
+ */
13
+
14
+ /** The edge stage's decision for one request path. */
15
+ export type LocaleRewritePlan =
16
+ | { kind: 'pass' }
17
+ | { kind: 'rewrite'; locale: string; routePath: string };
18
+
19
+ /**
20
+ * Plan the locale stage for `pathname`.
21
+ *
22
+ * - `pass` — no locale prefix (the default-language majority of traffic), or
23
+ * a prefix worn over a store-wide route.
24
+ * - `rewrite` — serve `routePath` in `locale`.
25
+ *
26
+ * The `pass` on a prefixed infrastructure route is the load-bearing one.
27
+ * `/ping` never reaches middleware, but `/de/ping` does: the matcher's
28
+ * negative lookahead is anchored at the start of the path, so it is a
29
+ * whole-path prefix test. Rewriting that onto the real readiness probe would
30
+ * give it 676 two-letter spellings that answer 200 — each with a `Set-Cookie`
31
+ * attached, on a route whose contract is to be byte-stable and cacheable —
32
+ * where every one of them previously 404'd. Passing leaves the path whole, so
33
+ * it reaches a route tree that has no such route.
34
+ *
35
+ * The candidate is NOT validated here — that needs the channel's languages,
36
+ * and this stage is zero-fetch by contract. An unknown locale still plans a
37
+ * `rewrite`; `decideRequestLocale` turns it into a 404 at request time.
38
+ *
39
+ * Canonicalization is deliberately absent: Next's own `/:path+/` → `/:path+`
40
+ * redirect and its repeated-slash normalization are ordered BEFORE middleware
41
+ * in `resolve-routes.js`, so a non-canonical path has already been 308'd by
42
+ * the time this runs. The grammar still normalizes, which makes this total for
43
+ * any input, but there is no canonicalizing branch here because there is no
44
+ * request that would reach it.
45
+ */
46
+ export function planLocaleRewrite(pathname: string): LocaleRewritePlan {
47
+ const { localeCandidate, pathname: routePath } = parseLocalePath(pathname);
48
+ if (localeCandidate === null) return { kind: 'pass' };
49
+ if (isUnprefixedPath(routePath)) return { kind: 'pass' };
50
+
51
+ return { kind: 'rewrite', locale: localeCandidate, routePath };
52
+ }
53
+
54
+ /**
55
+ * Headers the middleware puts ON the request for the render to read. They are
56
+ * SERVER-AUTHORED and the render trusts them completely, which is exactly why
57
+ * an inbound copy is an attack rather than input.
58
+ *
59
+ * `ROUTE_PATH` and `SEARCH` exist because the render sees the REWRITTEN path
60
+ * and cannot reconstruct the original — the layout needs both to build the
61
+ * canonical target when it redirects a default-locale prefix.
62
+ */
63
+ export const LOCALE_REQUEST_HEADER = 'x-forgecart-locale';
64
+ export const ROUTE_PATH_REQUEST_HEADER = 'x-forgecart-route-path';
65
+ export const SEARCH_REQUEST_HEADER = 'x-forgecart-search';
66
+
67
+ const SERVER_AUTHORED_HEADERS: readonly string[] = [
68
+ LOCALE_REQUEST_HEADER,
69
+ ROUTE_PATH_REQUEST_HEADER,
70
+ SEARCH_REQUEST_HEADER,
71
+ ];
72
+
73
+ /**
74
+ * Whether a request arrived wearing a header only the server may set.
75
+ *
76
+ * A client sending `x-forgecart-locale: de` to `/products` would otherwise get
77
+ * a German render at the canonical English URL — content negotiation by request
78
+ * header, at a URL whose entire contract is that the URL is the only locale
79
+ * selector. The middleware uses this to decide whether a `pass` needs
80
+ * sanitizing at all, so clean traffic keeps the untouched fast path.
81
+ */
82
+ export function hasServerAuthoredHeaders(headers: Headers): boolean {
83
+ return SERVER_AUTHORED_HEADERS.some((name) => headers.has(name));
84
+ }
85
+
86
+ /**
87
+ * The headers the render should see: every inbound copy of the server-authored
88
+ * set removed, then the real values written when there is a locale to forward.
89
+ *
90
+ * Strip-then-set, always in that order and unconditionally — a `pass` must be
91
+ * left carrying none of them, or a forged value survives into the render on
92
+ * exactly the paths that have no locale of their own.
93
+ */
94
+ export function localeRequestHeaders(
95
+ headers: Headers,
96
+ plan: LocaleRewritePlan,
97
+ search: string,
98
+ ): Headers {
99
+ const sanitized = new Headers(headers);
100
+ for (const name of SERVER_AUTHORED_HEADERS) sanitized.delete(name);
101
+ if (plan.kind !== 'rewrite') return sanitized;
102
+
103
+ sanitized.set(LOCALE_REQUEST_HEADER, plan.locale);
104
+ sanitized.set(ROUTE_PATH_REQUEST_HEADER, plan.routePath);
105
+ sanitized.set(SEARCH_REQUEST_HEADER, search);
106
+ return sanitized;
107
+ }
@@ -0,0 +1,80 @@
1
+ import 'server-only';
2
+
3
+ import { cache } from 'react';
4
+ import { headers } from 'next/headers';
5
+
6
+ import { resolveChannelLocales } from './channel-locales-loader';
7
+ import { decideRequestLocale } from './request-locale';
8
+ import type { LocaleDecision } from './request-locale';
9
+ import type { LocaleBinding } from './localized-path';
10
+ import {
11
+ LOCALE_REQUEST_HEADER,
12
+ ROUTE_PATH_REQUEST_HEADER,
13
+ SEARCH_REQUEST_HEADER,
14
+ } from './middleware-plan';
15
+
16
+ /**
17
+ * The request's locale, resolved once and shared (#1346, W1-8 / S1+S2).
18
+ *
19
+ * This is the reader for the headers the middleware authored, and the only
20
+ * place the four-case decision is taken. A layout cannot pass props to a page
21
+ * — pages arrive as opaque `children` — so every server component that needs
22
+ * the locale calls this rather than receiving it, and React's `cache()` makes
23
+ * that one resolution per request no matter how many callers there are. The
24
+ * same primitive already backs `getVariant`'s request-scoped visitor id.
25
+ *
26
+ * Server-only by construction as well as by declaration: `headers()` throws in
27
+ * a client component, and `cache()` is an unmemoized pass-through in the
28
+ * client build. Client components receive the locale as a PROP instead — see
29
+ * `localized-path.ts` for why reading it from `usePathname()` cannot work
30
+ * under the rewrite.
31
+ */
32
+
33
+ export interface RequestLocale {
34
+ /** What the layout must do with this request. */
35
+ decision: LocaleDecision;
36
+ /** What every link needs: the rendering locale and the channel default. */
37
+ binding: LocaleBinding;
38
+ /** The channel's languages, for the switcher. */
39
+ languageCodes: readonly string[];
40
+ /** The merchant's shop name, for the title suffix (#1347). Null when unset. */
41
+ shopName: string | null;
42
+ /**
43
+ * Whether the channel behind this render was actually established (#1347).
44
+ *
45
+ * Surfaced here rather than read from the loader directly because every
46
+ * `generateMetadata` in the template already awaits this resolution: the
47
+ * fact costs nothing to carry, and a route that has to fetch it separately
48
+ * is a route that will forget to.
49
+ */
50
+ channelResolved: boolean;
51
+ /** The pre-rewrite path and query, for building a canonical redirect. */
52
+ routePath: string;
53
+ search: string;
54
+ }
55
+
56
+ export const getRequestLocale = cache(async (): Promise<RequestLocale> => {
57
+ const requestHeaders = await headers();
58
+ const candidate = requestHeaders.get(LOCALE_REQUEST_HEADER);
59
+ const routePath = requestHeaders.get(ROUTE_PATH_REQUEST_HEADER) ?? '/';
60
+ const search = requestHeaders.get(SEARCH_REQUEST_HEADER) ?? '';
61
+
62
+ const { channel, offered } = await resolveChannelLocales(candidate);
63
+ const decision = decideRequestLocale({ candidate, routePath, channel, offered, search });
64
+
65
+ return {
66
+ decision,
67
+ // A redirect or a 404 never reaches a link, but the binding is built
68
+ // unconditionally so callers never have to branch: the channel default is
69
+ // the only honest locale for a request that is not being rendered.
70
+ binding: {
71
+ locale: decision.kind === 'render' ? decision.locale : channel.defaultLanguageCode,
72
+ defaultLocale: channel.defaultLanguageCode,
73
+ },
74
+ languageCodes: channel.languageCodes,
75
+ shopName: channel.shopName,
76
+ channelResolved: channel.resolved,
77
+ routePath,
78
+ search,
79
+ };
80
+ });
@@ -0,0 +1,66 @@
1
+ import type { ChannelLocales } from './channel-locales';
2
+
3
+ /**
4
+ * The request-time locale decision (#1346, epic launch#54 W1-8 / S1).
5
+ *
6
+ * The middleware cannot make this call — it is zero-fetch by contract — so it
7
+ * rewrites on a CANDIDATE and the real decision happens in the layout's
8
+ * request-time hole, where the channel's languages are reachable.
9
+ *
10
+ * This module is deliberately PURE and deliberately does not import
11
+ * `next/navigation`. Returning a decision instead of calling `notFound()` /
12
+ * `permanentRedirect()` is what keeps the interesting logic — which of four
13
+ * outcomes a request gets — inside the seam the template spec project can
14
+ * import. The layout performs the dispatch, and dispatch is the part with
15
+ * nothing left to get wrong.
16
+ */
17
+
18
+ /** What the layout should do with this request. */
19
+ export type LocaleDecision =
20
+ | { kind: 'render'; locale: string }
21
+ | { kind: 'redirect'; to: string }
22
+ | { kind: 'notFound' };
23
+
24
+ export interface RequestLocaleInput {
25
+ /** The two-letter first segment the middleware stripped, if there was one. */
26
+ candidate: string | null;
27
+ /** The path AFTER the prefix was stripped — what the route tree serves. */
28
+ routePath: string;
29
+ /** The channel's advertised languages. */
30
+ channel: ChannelLocales;
31
+ /**
32
+ * Whether the channel offers `candidate`, already resolved through
33
+ * `channel-locales` (which reloads once on a miss, so a language added a
34
+ * moment ago answers `true` here on its first request).
35
+ */
36
+ offered: boolean;
37
+ /** The original query string, `''` or leading `?` — preserved on redirect. */
38
+ search?: string;
39
+ }
40
+
41
+ /**
42
+ * Decide the outcome for a request whose locale prefix has already been parsed.
43
+ *
44
+ * The four cases, in the order they must be tested:
45
+ *
46
+ * 1. No prefix at all → render in the channel's default language. This is the
47
+ * canonical shape for the default locale.
48
+ * 2. The prefix names the DEFAULT language → permanent redirect to the
49
+ * unprefixed path. `/en/products` and `/products` would otherwise be two
50
+ * URLs serving one page, which is the duplicate-content split the whole
51
+ * grammar exists to avoid. Tested BEFORE `offered`, because the default
52
+ * language is always offered and would otherwise render prefixed.
53
+ * 3. The prefix names a language the channel does not offer → 404. Not a
54
+ * redirect: silently rewriting an unknown locale to the default would tell
55
+ * a crawler that `/zz/x` is a real page.
56
+ * 4. Otherwise → render in that language.
57
+ */
58
+ export function decideRequestLocale(input: RequestLocaleInput): LocaleDecision {
59
+ const { candidate, routePath, channel, offered } = input;
60
+ if (candidate === null) return { kind: 'render', locale: channel.defaultLanguageCode };
61
+ if (candidate === channel.defaultLanguageCode) {
62
+ return { kind: 'redirect', to: `${routePath}${input.search ?? ''}` };
63
+ }
64
+ if (!offered) return { kind: 'notFound' };
65
+ return { kind: 'render', locale: candidate };
66
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The marketing signals a landing URL carries (#1596).
3
+ *
4
+ * A shopper arriving from an ad brings the entire attribution story in the
5
+ * query string and nowhere else: the platform's CLICK ID identifies that exact
6
+ * click to the platform's conversion API, and the UTM tags say which campaign
7
+ * paid for it. Both are gone the moment the visitor reaches a second page — no
8
+ * cookie, no header and no backend record carries them — so they are read on
9
+ * landing or they are lost.
10
+ *
11
+ * This module only READS them. It turns a query string into what it found;
12
+ * WHERE those values go — the identity mutation for the click IDs, the track
13
+ * event for the UTM tags — is the caller's decision, taken somewhere that has a
14
+ * session and can talk to the backend. Keeping the two apart is what lets the
15
+ * reading rules be a table of vectors rather than a browser scenario.
16
+ *
17
+ * PURE by construction, and that is a requirement rather than a preference: it
18
+ * imports nothing, touches no browser global and reads no environment. This
19
+ * template tree sits outside the repository's lint, typecheck and test graph
20
+ * (`eslint.config.mts` ignores it, `package/cli/tsconfig.lib.json` excludes it)
21
+ * and is copied verbatim into every merchant's store, so
22
+ * `tool/storefront-template-spec` — which imports modules like this one by
23
+ * relative path — is the only thing standing between this file and production.
24
+ * A module that stops being pure stops being testable from there, which is an
25
+ * alarm rather than an inconvenience.
26
+ */
27
+
28
+ /** A captured click ID, shaped for the backend's `MarketingIdentifierInput`. */
29
+ export interface MarketingIdentifier {
30
+ /** The registered identifier key this value is stored under. */
31
+ key: string;
32
+ /** The click ID exactly as the ad platform wrote it into the URL. */
33
+ value: string;
34
+ }
35
+
36
+ /**
37
+ * The captured UTM tags, shaped to be spread straight into `TrackEventInput`.
38
+ *
39
+ * Every field is optional and an absent tag is OMITTED rather than set to an
40
+ * empty string, because those are different claims: the backend treats a
41
+ * present `''` as a blank dimension and writes it to ClickHouse, where it can
42
+ * no longer be told apart from a campaign that genuinely has no medium.
43
+ */
44
+ export interface UtmParams {
45
+ utmSource?: string;
46
+ utmMedium?: string;
47
+ utmCampaign?: string;
48
+ utmTerm?: string;
49
+ utmContent?: string;
50
+ }
51
+
52
+ /**
53
+ * What a landing URL turned out to carry.
54
+ *
55
+ * A discriminated union rather than a pair of possibly-empty collections,
56
+ * because "this visitor came from an ad" and "this visitor typed the address"
57
+ * are different situations and the second is by far the common one. The union
58
+ * makes the empty case impossible to walk past: nothing can reach `identifiers`
59
+ * or `utm` without answering the question first, so a visitor with no query
60
+ * parameters produces exactly the tracking behaviour that existed before this
61
+ * module — no extra request, no blank row.
62
+ *
63
+ * The `captured` arm carries at least one signal, though not necessarily both:
64
+ * an ad link with a click ID and no tags yields an empty `utm`, and a
65
+ * hand-written newsletter link yields empty `identifiers`. A caller skips the
66
+ * empty side rather than sending it.
67
+ */
68
+ export type MarketingParams =
69
+ | { kind: 'none' }
70
+ | { kind: 'captured'; identifiers: readonly MarketingIdentifier[]; utm: UtmParams };
71
+
72
+ /** One click ID: what the platform appends, and what we store it as. */
73
+ interface ClickIdParam {
74
+ /** The parameter name an ad platform writes into the landing URL. */
75
+ readonly param: string;
76
+ /** The backend identifier key it is stored under. */
77
+ readonly key: string;
78
+ }
79
+
80
+ /** One UTM tag: the URL parameter, and the field it becomes. */
81
+ interface UtmParam {
82
+ readonly param: string;
83
+ readonly field: keyof UtmParams;
84
+ }
85
+
86
+ /**
87
+ * The click IDs this storefront captures.
88
+ *
89
+ * Each `key` is byte-identical to one the backend pre-registers, in
90
+ * `package/core/src/marketing-identity/marketing-identity.module.ts:85`, and
91
+ * that exactness is load-bearing: the shop mutation looks the key up and
92
+ * REJECTS an unknown one — reporting it in `rejected` rather than failing — so
93
+ * a single typo here loses the attribution silently, behind a response that
94
+ * still looks accepted.
95
+ *
96
+ * Three of the six registered click IDs appear below. The other three
97
+ * (`msclkid`, `liClickId`, `impact_click_id`) are registered backend-side, but
98
+ * an identifier's KEY and its URL-parameter spelling are two separate facts,
99
+ * and for those three they are not the same string — two are not even
100
+ * URL-shaped. Guessing the missing spellings would buy nothing and could cost
101
+ * real data: a guess that never matches is dead code, and a guess that happens
102
+ * to match another platform's parameter writes a foreign click's value into a
103
+ * real shopper's identity. Capturing one of them later is one row here, which
104
+ * is why the two spellings are held apart even where they coincide.
105
+ */
106
+ const CLICK_ID_PARAMS: readonly ClickIdParam[] = [
107
+ { param: 'gclid', key: 'gclid' },
108
+ { param: 'fbclid', key: 'fbclid' },
109
+ { param: 'ttclid', key: 'ttclid' },
110
+ ];
111
+
112
+ /**
113
+ * All five UTM tags, not the three everyone names.
114
+ *
115
+ * `utm_term` and `utm_content` are the two dropped as an afterthought, and
116
+ * dropping them here would be a pure loss for zero saving: the chain downstream
117
+ * already carries five, from `TrackEventInput` through the relay's allowlist in
118
+ * `src/app/__fc/track/route.ts` to the ClickHouse columns and the
119
+ * `MarketingTouchpoint` row. They are also what tells two ads in one ad group
120
+ * apart, which is the granularity a merchant actually optimises at.
121
+ */
122
+ const UTM_PARAMS: readonly UtmParam[] = [
123
+ { param: 'utm_source', field: 'utmSource' },
124
+ { param: 'utm_medium', field: 'utmMedium' },
125
+ { param: 'utm_campaign', field: 'utmCampaign' },
126
+ { param: 'utm_term', field: 'utmTerm' },
127
+ { param: 'utm_content', field: 'utmContent' },
128
+ ];
129
+
130
+ /**
131
+ * Unicode control characters (C0, DEL, C1), stripped from every value.
132
+ *
133
+ * The same expression the server applies in
134
+ * `package/core/src/marketing-event/service/marketing-event.service.ts:38`.
135
+ * Repeating it here is not belt-and-braces — it is what keeps the value SENT
136
+ * identical to the value STORED, so the two can never be reported differently.
137
+ */
138
+ const CONTROL_CHARS_REGEX = /\p{Cc}/gu;
139
+
140
+ /**
141
+ * The cap the server applies to every UTM value (that same service, line 39),
142
+ * mirrored here so a tag cannot change shape in transit. These land in
143
+ * `LowCardinality(String)` ClickHouse columns, where the cap is what stops
144
+ * client garbage from inflating the column dictionary; truncating at the same
145
+ * boundary means the campaign name a merchant reads in a report is the one this
146
+ * module sent.
147
+ */
148
+ export const UTM_VALUE_MAX_LENGTH = 128;
149
+
150
+ /**
151
+ * The cap for a click ID — deliberately NOT the UTM one.
152
+ *
153
+ * There is nothing to mirror here: an identifier value lands in an unbounded
154
+ * `varchar` and the identity path validates the KEY while storing the value
155
+ * exactly as given, so this bound is a garbage fence rather than a truncation
156
+ * the server would reproduce. It therefore has to sit well clear of every real
157
+ * click ID: Meta's `fbclid` routinely runs past 128 characters, and a click ID
158
+ * short by even one character matches nothing at the platform that issued it.
159
+ * That failure is silent — the conversion simply never attributes — and
160
+ * preventing it is the reason this module exists.
161
+ */
162
+ export const CLICK_ID_VALUE_MAX_LENGTH = 512;
163
+
164
+ /**
165
+ * Read the marketing signals out of a landing URL's query string.
166
+ *
167
+ * Accepts the string with or without its leading `?`, so `window.location.
168
+ * search` and a hand-built query read alike. Never throws: this runs on the
169
+ * first render of every page a visitor can land on, and an unreadable query is
170
+ * a reason to capture nothing, never a reason to fail the page the shopper came
171
+ * for.
172
+ *
173
+ * A repeated parameter keeps its FIRST occurrence. `?gclid=a&gclid=b` has no
174
+ * honest reading — one of the two belongs to somebody else's click — and the
175
+ * first is the one the ad platform appended before any later redirect had the
176
+ * chance to add its own.
177
+ */
178
+ export function parseMarketingParams(search: string): MarketingParams {
179
+ const params = new URLSearchParams(search);
180
+
181
+ const identifiers: MarketingIdentifier[] = [];
182
+ for (const { param, key } of CLICK_ID_PARAMS) {
183
+ const value = sanitize(params.get(param), CLICK_ID_VALUE_MAX_LENGTH);
184
+ if (value === undefined) continue;
185
+ identifiers.push({ key, value });
186
+ }
187
+
188
+ const utm: UtmParams = {};
189
+ for (const { param, field } of UTM_PARAMS) {
190
+ const value = sanitize(params.get(param), UTM_VALUE_MAX_LENGTH);
191
+ if (value === undefined) continue;
192
+ utm[field] = value;
193
+ }
194
+
195
+ if (identifiers.length === 0 && Object.keys(utm).length === 0) return { kind: 'none' };
196
+ return { kind: 'captured', identifiers, utm };
197
+ }
198
+
199
+ /**
200
+ * Clean one raw parameter value, or report it absent.
201
+ *
202
+ * The order matters and matches the server's `sanitizeLowCardinalityValue`
203
+ * (`marketing-event.service.ts:343`): strip control characters, trim, drop what
204
+ * remains if it is empty, and only then cap. Capping first would let trailing
205
+ * whitespace eat the budget, and treating a blank as present would send
206
+ * `?utm_source=` upstream as a campaign whose source is the empty string.
207
+ */
208
+ function sanitize(raw: string | null, maxLength: number): string | undefined {
209
+ if (raw === null) return undefined;
210
+ const stripped = raw.replace(CONTROL_CHARS_REGEX, '').trim();
211
+ if (stripped.length === 0) return undefined;
212
+ return stripped.slice(0, maxLength);
213
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Minor-unit money arithmetic — the one place the ISO-4217 exponent is decided.
3
+ *
4
+ * ForgeCart prices are integers in the currency's smallest denomination, and
5
+ * the divisor is NOT always a hundred: 2500 is $25.00 (two-decimal cents) but
6
+ * ¥2,500 (zero-decimal yen) and BHD 2.500 (three-decimal fils). A hardcoded
7
+ * hundred is therefore wrong for real currencies, silently, and only for
8
+ * merchants outside the two-decimal world.
9
+ *
10
+ * It lives here rather than inside the price formatter because there are now
11
+ * two consumers with different output: the shopper-facing string
12
+ * (`lib/format.ts`) and the machine-readable `Offer.price` a crawler reads
13
+ * (`lib/seo/json-ld.ts`). Those two disagreeing about what 2500 means would
14
+ * publish one price to people and another to search engines — a discrepancy
15
+ * that reads as deceptive pricing, and one nothing in the template would
16
+ * report. One derivation, two consumers, no room to drift.
17
+ *
18
+ * Pure by construction — no SDK types, no `next` — so it is a table of vectors
19
+ * rather than something only a rendered page can exercise.
20
+ */
21
+
22
+ /**
23
+ * The currency's fraction digits, from the same CLDR data that renders it.
24
+ *
25
+ * Derived rather than tabulated: a hand-maintained exponent table is a second
26
+ * source of truth that goes stale silently. The fallback of 2 covers a
27
+ * formatter that resolves no digits at all (an unknown code), which is the
28
+ * only case CLDR cannot answer.
29
+ */
30
+ export function currencyExponent(currency: string): number {
31
+ const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency });
32
+ return formatter.resolvedOptions().maximumFractionDigits ?? 2;
33
+ }
34
+
35
+ /** A minor-unit integer as its major-unit value — 2500 USD → 25, 2500 JPY → 2500. */
36
+ export function toMajorUnits(minorUnits: number, currency: string): number {
37
+ return minorUnits / 10 ** currencyExponent(currency);
38
+ }
39
+
40
+ /**
41
+ * A minor-unit integer as the decimal string schema.org wants for a price.
42
+ *
43
+ * Fixed to the currency's own digit count, deliberately: `25` and `25.00` are
44
+ * the same number but not the same claim to a price parser, and a
45
+ * zero-decimal currency must not acquire cents it does not have.
46
+ */
47
+ export function toPriceString(minorUnits: number, currency: string): string {
48
+ const exponent = currencyExponent(currency);
49
+ return (minorUnits / 10 ** exponent).toFixed(exponent);
50
+ }
@@ -0,0 +1,120 @@
1
+ import { localizedPath } from '../locale/localized-path';
2
+ import type { LocaleBinding } from '../locale/localized-path';
3
+
4
+ /**
5
+ * Self-canonical + reciprocal hreflang (#1347, epic launch#54 W1-9).
6
+ *
7
+ * PURE, with no env read of its own: the origin arrives as a parameter and the
8
+ * only caller is `metadata.ts`, which composes these claims together with the
9
+ * title, the robots directive and the social card. Routes do not call in here
10
+ * directly — five separate claims about one address, assembled separately, can
11
+ * disagree in ways nothing in the template would notice.
12
+ *
13
+ * Two claims, and both are claims about OTHER URLs, which is what makes them
14
+ * dangerous to derive loosely:
15
+ *
16
+ * - the canonical says "this content lives at exactly this address", which
17
+ * tells a crawler to collapse every other URL that serves it;
18
+ * - each hreflang says "the same content exists, in that language, over
19
+ * there" — and search engines require reciprocity, so an alternate pointing
20
+ * at a page that does not point back is discarded, taking the whole cluster's
21
+ * credibility with it.
22
+ *
23
+ * Reciprocity is therefore achieved BY CONSTRUCTION rather than by asserting
24
+ * it: every locale in the set is rendered from the same `pathsByLocale` map, so
25
+ * each page in a cluster emits an identical alternate set. Two pages built from
26
+ * one map cannot disagree.
27
+ */
28
+
29
+ /** Unprefixed path per language that genuinely HAS this content. */
30
+ export type PathsByLocale = Readonly<Record<string, string>>;
31
+
32
+ export interface AlternatesInput {
33
+ /** The locale this page is being rendered in. */
34
+ binding: LocaleBinding;
35
+ /**
36
+ * Every language with real content, mapped to its own unprefixed path.
37
+ *
38
+ * Membership is the ONLY thing that decides which alternates are emitted:
39
+ * a language a merchant has not translated must never appear, or the cluster
40
+ * advertises a page that answers a fallback copy.
41
+ */
42
+ pathsByLocale: PathsByLocale;
43
+ /** The deployment's public origin, or null when it has none. */
44
+ publicOrigin: string | null;
45
+ }
46
+
47
+ /** What a page declares about its own address and its siblings. */
48
+ export interface Alternates {
49
+ canonical: string;
50
+ languages: Record<string, string>;
51
+ }
52
+
53
+ /**
54
+ * The alternates for this page, or undefined when it must not make the claim.
55
+ *
56
+ * Undefined in exactly one case: this locale is not in `pathsByLocale`, which
57
+ * means the shopper is looking at a derived fallback copy. Such a page has no
58
+ * address of its own to be canonical about, and pointing hreflang at the
59
+ * languages that DO have the content would invite a crawler to treat the
60
+ * fallback as part of a reciprocal set it is not a member of. Asserting
61
+ * nothing is the honest answer — the same posture the preview deployment takes.
62
+ */
63
+ export function buildAlternates({
64
+ binding,
65
+ pathsByLocale,
66
+ publicOrigin,
67
+ }: AlternatesInput): Alternates | undefined {
68
+ const ownPath = pathsByLocale[binding.locale];
69
+ if (ownPath === undefined) return undefined;
70
+
71
+ const languages: Record<string, string> = {};
72
+ for (const [languageCode, path] of Object.entries(pathsByLocale)) {
73
+ languages[languageCode] = absolute(
74
+ localizedPath(path, { locale: languageCode, defaultLocale: binding.defaultLocale }),
75
+ publicOrigin,
76
+ );
77
+ }
78
+
79
+ // `x-default` names where an unmatched visitor should land, and the channel's
80
+ // default language is the only defensible answer — it is the copy served
81
+ // unprefixed. Emitted only when that language actually has the content:
82
+ // pointing x-default at a page that does not exist is worse than omitting it.
83
+ const defaultPath = pathsByLocale[binding.defaultLocale];
84
+ if (defaultPath !== undefined) {
85
+ languages['x-default'] = absolute(
86
+ localizedPath(defaultPath, { locale: binding.defaultLocale, defaultLocale: binding.defaultLocale }),
87
+ publicOrigin,
88
+ );
89
+ }
90
+
91
+ return { canonical: absolute(localizedPath(ownPath, binding), publicOrigin), languages };
92
+ }
93
+
94
+ /**
95
+ * The path map for a route that exists in EVERY language — the home page, the
96
+ * catalog, and any page a merchant scaffolds.
97
+ *
98
+ * Structural routes are not translated content: `/products` is the same route
99
+ * in every cluster, so every offered language has it and each one is a genuine
100
+ * alternate. That is the opposite of a product, where membership has to be
101
+ * earned per language (see `productPathsByLocale`).
102
+ */
103
+ export function staticPathsByLocale(
104
+ languageCodes: readonly string[],
105
+ path: string,
106
+ ): Record<string, string> {
107
+ return Object.fromEntries(languageCodes.map((languageCode) => [languageCode, path]));
108
+ }
109
+
110
+ /**
111
+ * Absolute against the public origin, relative without one.
112
+ *
113
+ * A relative canonical is valid and resolves against the document's own URL, so
114
+ * it stays truthful on a preview host — where an absolute URL would either name
115
+ * an origin we do not have or invent one. Those pages are noindex anyway
116
+ * (`noindex.ts`), so this is belt and braces rather than the primary defence.
117
+ */
118
+ function absolute(path: string, publicOrigin: string | null): string {
119
+ return publicOrigin === null ? path : `${publicOrigin}${path}`;
120
+ }