@forgecart/cli 2.202608300703.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.
- package/dist/src/commands/init.d.ts +21 -2
- package/dist/src/commands/init.js +18 -2
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/README.md +42 -4
- package/templates/storefront/next.config.js +29 -7
- package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
- package/templates/storefront/src/app/cart/page.tsx +14 -2
- package/templates/storefront/src/app/checkout/page.tsx +14 -2
- package/templates/storefront/src/app/layout.tsx +85 -22
- package/templates/storefront/src/app/page.tsx +63 -20
- package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
- package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
- package/templates/storefront/src/app/ping/route.ts +1 -1
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
- package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
- package/templates/storefront/src/app/products/page.tsx +41 -6
- package/templates/storefront/src/app/register/page.tsx +54 -0
- package/templates/storefront/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront/src/app/robots.ts +69 -0
- package/templates/storefront/src/app/sitemap.ts +106 -0
- package/templates/storefront/src/app/verify/page.tsx +155 -0
- package/templates/storefront/src/components/CartView.tsx +26 -7
- package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
- package/templates/storefront/src/components/Header.tsx +30 -10
- package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
- package/templates/storefront/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront/src/components/ProductCard.tsx +10 -4
- package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
- package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
- package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
- package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
- package/templates/storefront/src/lib/account/account-link.ts +76 -0
- package/templates/storefront/src/lib/account/register-state.ts +133 -0
- package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront/src/lib/account-actions.ts +76 -0
- package/templates/storefront/src/lib/account-session.ts +47 -0
- package/templates/storefront/src/lib/asset-alt.ts +34 -0
- package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront/src/lib/experiments.ts +1 -1
- package/templates/storefront/src/lib/forgecart.ts +300 -27
- package/templates/storefront/src/lib/format.ts +12 -14
- package/templates/storefront/src/lib/identify-forward.ts +152 -0
- package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront/src/lib/marketing-params.ts +213 -0
- package/templates/storefront/src/lib/money.ts +50 -0
- package/templates/storefront/src/lib/seo/alternates.ts +120 -0
- package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront/src/lib/seo/metadata.ts +323 -0
- package/templates/storefront/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
- package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront/src/lib/session-actions.ts +15 -8
- package/templates/storefront/src/lib/session-cookies.ts +98 -0
- package/templates/storefront/src/lib/shop-config.ts +9 -2
- package/templates/storefront/src/lib/shop-session.ts +20 -1
- package/templates/storefront/src/lib/track-forward.ts +43 -14
- package/templates/storefront/src/middleware.ts +150 -8
- package/templates/storefront/src/seo/redirects.ts +44 -0
- package/templates/storefront/src/server/runner.ts +1 -2
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { getAssetAlt } from '../asset-alt';
|
|
4
|
+
import type { PageEntry, PageFieldValue } from '../forgecart';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The generic renderer for an ACF page record (#1934, epic launch#50).
|
|
8
|
+
*
|
|
9
|
+
* A page definition is whatever the merchant built in the dashboard, so this
|
|
10
|
+
* file cannot know the fields it will be handed — it renders by ACF field TYPE,
|
|
11
|
+
* driven by the `__typename` the shop API discriminates every value with. The
|
|
12
|
+
* layout is deliberately plain: this is the scaffold a merchant edits, and a
|
|
13
|
+
* template that guessed at a design would be undone before it shipped.
|
|
14
|
+
*
|
|
15
|
+
* Order and labels come from the DEFINITIONS, not the values: the shop API
|
|
16
|
+
* returns field definitions in the order the editor arranged them, while the
|
|
17
|
+
* value list is storage order and means nothing to a reader. A definition with
|
|
18
|
+
* no value (a display-only field — a divider, a chart) simply has nothing to
|
|
19
|
+
* match and renders nothing, which is why no field-category test appears here.
|
|
20
|
+
*
|
|
21
|
+
* Repeater rows render one level deep, and the bound is a real one rather than
|
|
22
|
+
* an oversight: the shop operation unrolls the group tree three levels
|
|
23
|
+
* (fragments cannot recurse), so each level is a DIFFERENT generated type and a
|
|
24
|
+
* general recursion has no single shape to stand on. One level covers the
|
|
25
|
+
* "sections of a page" shape this route exists for; deeper trees are a
|
|
26
|
+
* follow-up with the operation change they need.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** What this renderer needs of a field definition — nothing about its storage. */
|
|
30
|
+
export interface RenderableDefinition {
|
|
31
|
+
readonly fieldName: string;
|
|
32
|
+
readonly title?: string | null;
|
|
33
|
+
readonly hideTitle?: boolean | null;
|
|
34
|
+
readonly children?: readonly RenderableDefinition[] | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One repeater as an entry carries it: a named group of rows. */
|
|
38
|
+
type PageRepeater = PageEntry['groups'][number];
|
|
39
|
+
|
|
40
|
+
export function PageFields({
|
|
41
|
+
definitions,
|
|
42
|
+
entry,
|
|
43
|
+
}: {
|
|
44
|
+
definitions: readonly RenderableDefinition[];
|
|
45
|
+
entry: PageEntry;
|
|
46
|
+
}): ReactNode {
|
|
47
|
+
return (
|
|
48
|
+
<div className="space-y-8">
|
|
49
|
+
{definitions.map((definition) => (
|
|
50
|
+
<PageField
|
|
51
|
+
key={definition.fieldName}
|
|
52
|
+
definition={definition}
|
|
53
|
+
value={entry.fields.find((field) => field.name === definition.fieldName)}
|
|
54
|
+
repeater={entry.groups.find((group) => group.name === definition.fieldName)}
|
|
55
|
+
/>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One definition's section — its label and whatever it holds.
|
|
63
|
+
*
|
|
64
|
+
* A field with neither a value nor rows renders NOTHING, not an empty section:
|
|
65
|
+
* an unpublished column reads as null (the shop API's live-column gating) and a
|
|
66
|
+
* merchant who left a field blank meant to leave it blank. Either way a bare
|
|
67
|
+
* heading over nothing is noise on a public page.
|
|
68
|
+
*/
|
|
69
|
+
function PageField({
|
|
70
|
+
definition,
|
|
71
|
+
value,
|
|
72
|
+
repeater,
|
|
73
|
+
}: {
|
|
74
|
+
definition: RenderableDefinition;
|
|
75
|
+
value: PageFieldValue | undefined;
|
|
76
|
+
repeater: PageRepeater | undefined;
|
|
77
|
+
}): ReactNode {
|
|
78
|
+
const body = repeater ? renderRepeater(repeater, definition.children ?? []) : renderValue(value);
|
|
79
|
+
if (body === null) return null;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
// `data-fc-field` carries the field's identity into the DOM: it is what a
|
|
83
|
+
// merchant styles a single field by, and the stable hook the storefront
|
|
84
|
+
// e2e reads.
|
|
85
|
+
<section className="space-y-2" data-fc-field={definition.fieldName}>
|
|
86
|
+
{definition.hideTitle !== true && definition.title ? (
|
|
87
|
+
<h2 className="text-xl font-semibold tracking-tight">{definition.title}</h2>
|
|
88
|
+
) : null}
|
|
89
|
+
{body}
|
|
90
|
+
</section>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A repeater's rows, each row rendering its own members through the same law. */
|
|
95
|
+
function renderRepeater(
|
|
96
|
+
repeater: PageRepeater,
|
|
97
|
+
members: readonly RenderableDefinition[],
|
|
98
|
+
): ReactNode {
|
|
99
|
+
if (repeater.entries.length === 0) return null;
|
|
100
|
+
return (
|
|
101
|
+
<div className="space-y-6">
|
|
102
|
+
{repeater.entries.map((row) => (
|
|
103
|
+
<div key={row.id} className="space-y-2">
|
|
104
|
+
{members.map((member) => {
|
|
105
|
+
const body = renderValue(row.fields.find((field) => field.name === member.fieldName));
|
|
106
|
+
if (body === null) return null;
|
|
107
|
+
return (
|
|
108
|
+
<div key={member.fieldName} data-fc-field={member.fieldName}>
|
|
109
|
+
{member.hideTitle !== true && member.title ? (
|
|
110
|
+
<h3 className="text-sm font-semibold text-base-content/70">{member.title}</h3>
|
|
111
|
+
) : null}
|
|
112
|
+
{body}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One stored value as markup, or `null` when it holds nothing to show.
|
|
124
|
+
*
|
|
125
|
+
* An exhaustive `switch` over the discriminated union rather than a lookup
|
|
126
|
+
* table, and the `default` arm is the reason: it assigns the value to `never`,
|
|
127
|
+
* so adding an ACF field type without teaching this file about it is a COMPILE
|
|
128
|
+
* error in the merchant's scaffold instead of a section that silently renders
|
|
129
|
+
* blank on a live page.
|
|
130
|
+
*/
|
|
131
|
+
function renderValue(value: PageFieldValue | undefined): ReactNode {
|
|
132
|
+
if (value === undefined) return null;
|
|
133
|
+
|
|
134
|
+
switch (value.__typename) {
|
|
135
|
+
case 'AcfStringValue':
|
|
136
|
+
return text(value.stringValue);
|
|
137
|
+
case 'AcfIntValue':
|
|
138
|
+
return absent(value.intValue) ? null : text(String(value.intValue));
|
|
139
|
+
case 'AcfFloatValue':
|
|
140
|
+
return absent(value.floatValue) ? null : text(String(value.floatValue));
|
|
141
|
+
case 'AcfBoolValue':
|
|
142
|
+
return absent(value.boolValue) ? null : text(value.boolValue ? 'Yes' : 'No');
|
|
143
|
+
case 'AcfDateValue':
|
|
144
|
+
// The API serializes DateTime as ISO 8601; rendered as `<time>` with the
|
|
145
|
+
// machine-readable value intact, and NOT locale-formatted here — a
|
|
146
|
+
// formatted date is a merchant presentation choice, and guessing one in
|
|
147
|
+
// the scaffold would be a decision nobody made.
|
|
148
|
+
return absent(value.dateValue) ? null : (
|
|
149
|
+
<time dateTime={value.dateValue} className="text-base-content/80">
|
|
150
|
+
{value.dateValue}
|
|
151
|
+
</time>
|
|
152
|
+
);
|
|
153
|
+
case 'AcfStringListValue':
|
|
154
|
+
return list(value.stringListValue);
|
|
155
|
+
case 'AcfJsonValue':
|
|
156
|
+
// Shown verbatim in a code block: JSON on a page is structured data the
|
|
157
|
+
// merchant's own components consume, and pretty-printing it here would be
|
|
158
|
+
// this file inventing a shape for someone else's payload.
|
|
159
|
+
return absent(value.jsonValue) ? null : (
|
|
160
|
+
<pre className="overflow-x-auto rounded-box bg-base-200 p-4 text-xs">{value.jsonValue}</pre>
|
|
161
|
+
);
|
|
162
|
+
case 'AcfRichTextValue':
|
|
163
|
+
// The server-rendered projection, never the raw delta: `html` is produced
|
|
164
|
+
// by the API's own sanitizing renderer, which is the one place that
|
|
165
|
+
// decision belongs.
|
|
166
|
+
//
|
|
167
|
+
// The guard is also what satisfies the seam: `dangerouslySetInnerHTML`
|
|
168
|
+
// takes `string | TrustedHTML`, so the projection is rendered only once
|
|
169
|
+
// it IS a string — never coerced past an absence with a `?? ''`, which
|
|
170
|
+
// would publish an empty rich-text section as if the merchant had
|
|
171
|
+
// written one.
|
|
172
|
+
return absent(value.html) ? null : (
|
|
173
|
+
<div
|
|
174
|
+
className="max-w-none text-base leading-relaxed text-base-content/80"
|
|
175
|
+
dangerouslySetInnerHTML={{ __html: value.html }}
|
|
176
|
+
/>
|
|
177
|
+
);
|
|
178
|
+
case 'AcfAssetValue':
|
|
179
|
+
return value.asset ? <AssetImage asset={value.asset} /> : null;
|
|
180
|
+
case 'AcfAssetListValue':
|
|
181
|
+
return value.assets.length === 0 ? null : (
|
|
182
|
+
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
|
183
|
+
{value.assets.map((asset) => (
|
|
184
|
+
<AssetImage key={asset.id} asset={asset} />
|
|
185
|
+
))}
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
case 'AcfRelationValue':
|
|
189
|
+
// The API resolves the related row's display summary; the id alone would
|
|
190
|
+
// be meaningless on a storefront, and a dangling reference resolves to
|
|
191
|
+
// no summary at all, which renders as nothing rather than as a raw uuid.
|
|
192
|
+
return value.entity ? text(value.entity.label) : null;
|
|
193
|
+
case 'AcfRelationListValue':
|
|
194
|
+
return list(value.entities.map((entity) => entity.label));
|
|
195
|
+
default:
|
|
196
|
+
return exhausted(value);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One asset as an image — decorative unless the merchant described it. */
|
|
201
|
+
function AssetImage({ asset }: { asset: { preview: string; altText?: string | null } }): ReactNode {
|
|
202
|
+
return (
|
|
203
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
204
|
+
<img
|
|
205
|
+
src={asset.preview}
|
|
206
|
+
alt={getAssetAlt(asset)}
|
|
207
|
+
className="w-full rounded-box border border-base-300 object-cover"
|
|
208
|
+
/>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function text(content: string | null | undefined): ReactNode {
|
|
213
|
+
if (absent(content) || content.trim() === '') return null;
|
|
214
|
+
return <p className="text-base leading-relaxed text-base-content/80">{content}</p>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function list(items: readonly string[] | null | undefined): ReactNode {
|
|
218
|
+
if (absent(items) || items.length === 0) return null;
|
|
219
|
+
return (
|
|
220
|
+
<ul className="list-inside list-disc space-y-1 text-base-content/80">
|
|
221
|
+
{items.map((item, index) => (
|
|
222
|
+
<li key={`${item}-${String(index)}`}>{item}</li>
|
|
223
|
+
))}
|
|
224
|
+
</ul>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Is there nothing here?
|
|
230
|
+
*
|
|
231
|
+
* The generated SDK marks every nullable selection OPTIONAL as well as
|
|
232
|
+
* nullable (`stringValue?: Maybe<string>`), so "the merchant left this empty"
|
|
233
|
+
* reaches this file as `null` from the API answering today and as `undefined`
|
|
234
|
+
* from any pod whose SDK does not carry the selection at all. Both mean the
|
|
235
|
+
* same thing to a reader, and collapsing them in ONE predicate is what keeps a
|
|
236
|
+
* `String(undefined)`, an empty `<time>` or a bare `No` off a live page — each
|
|
237
|
+
* of which a `=== null` check alone would have rendered.
|
|
238
|
+
*/
|
|
239
|
+
function absent<T>(value: T | null | undefined): value is null | undefined {
|
|
240
|
+
return value === null || value === undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The compile-time exhaustiveness check for the switch above: the parameter is
|
|
245
|
+
* `never`, so a new ACF field type stops this file from building until it is
|
|
246
|
+
* handled.
|
|
247
|
+
*
|
|
248
|
+
* At RUNTIME it renders nothing rather than throwing, and the asymmetry is
|
|
249
|
+
* deliberate. The only way to reach it is a pod whose generated SDK is older
|
|
250
|
+
* than the API answering it, and in that state the honest behaviour is to show
|
|
251
|
+
* the fields this build understands — a throw would blank the merchant's whole
|
|
252
|
+
* live page over one unknown section.
|
|
253
|
+
*/
|
|
254
|
+
function exhausted(_value: never): null {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { isLocaleSegment } from '../locale/grammar';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a storefront page URL resolves to (#1934, epic launch#50) — the WHOLE
|
|
5
|
+
* decision behind `/pages/<route>`, as a value.
|
|
6
|
+
*
|
|
7
|
+
* PURE BY CONTRACT, for the reason `lib/seo/resolve-path.ts` is: this is the
|
|
8
|
+
* law that decides between a rendered page and a real 404, and
|
|
9
|
+
* `tool/storefront-template-spec` imports it directly by relative path from
|
|
10
|
+
* OUTSIDE the scaffold, so nothing reachable from here may need the template's
|
|
11
|
+
* Next toolchain. The route file is left as dispatch — it turns `notFound` into
|
|
12
|
+
* Next's `notFound()` and `ok` into markup, and decides nothing itself.
|
|
13
|
+
*
|
|
14
|
+
* The inputs are declared STRUCTURALLY rather than imported from the SDK for
|
|
15
|
+
* the same reason (`ResolvableProduct` precedent): the generated query types
|
|
16
|
+
* satisfy these shapes structurally, so the scaffold's own `tsc` proves the
|
|
17
|
+
* assignment while this module stays importable with no `paths`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The subset of a page definition this decision reads, declared AS THE SHOP
|
|
22
|
+
* API'S GENERATED TYPES CARRY IT.
|
|
23
|
+
*
|
|
24
|
+
* Every nullable selection reaches a scaffold OPTIONAL as well as nullable
|
|
25
|
+
* (`route?: Maybe<string>`), so a contract spelled `string | null` would refuse
|
|
26
|
+
* the very value the query produces — and, this module having no compiler of
|
|
27
|
+
* its own in this repository, the refusal would surface in the merchant's
|
|
28
|
+
* `next build` instead of here. Admitting the shape is therefore part of the
|
|
29
|
+
* contract, not a laxity: what each absence MEANS is decided in the table
|
|
30
|
+
* below, once, rather than guessed at by each caller.
|
|
31
|
+
*/
|
|
32
|
+
export interface RoutedPageGroup {
|
|
33
|
+
/**
|
|
34
|
+
* `'page'` for a storefront page; anything else — a data definition, or a
|
|
35
|
+
* selection from a pod whose SDK does not carry the field — is not one.
|
|
36
|
+
*/
|
|
37
|
+
readonly kind?: string | null;
|
|
38
|
+
/**
|
|
39
|
+
* The route the definition is mounted at. Absent means mounted NOWHERE: the
|
|
40
|
+
* column is nullable because most definitions are data rather than pages.
|
|
41
|
+
*/
|
|
42
|
+
readonly route?: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Why a page URL is a 404. Four distinct causes, kept apart because they answer
|
|
47
|
+
* different questions about a deployment — "the merchant has not published
|
|
48
|
+
* this yet" and "this URL was never a page" look identical to a visitor and are
|
|
49
|
+
* nothing alike to whoever is debugging.
|
|
50
|
+
*/
|
|
51
|
+
export type PageNotFoundReason =
|
|
52
|
+
/**
|
|
53
|
+
* The slug is a two-letter locale segment. No page may hold one (launch#54
|
|
54
|
+
* W1-7 — the server refuses to mint such a route rather than auto-suffixing
|
|
55
|
+
* it), so this URL can never carry content in any deployment. Judged first
|
|
56
|
+
* and without consulting the lookup, so the rule is stated where it is true
|
|
57
|
+
* rather than inferred from an empty result.
|
|
58
|
+
*/
|
|
59
|
+
| 'reserved-locale-segment'
|
|
60
|
+
/**
|
|
61
|
+
* No page definition of this channel is mounted at this route: the lookup
|
|
62
|
+
* answered nothing, or what it answered carries no route of its own and is
|
|
63
|
+
* therefore mounted nowhere.
|
|
64
|
+
*/
|
|
65
|
+
| 'unknown-route'
|
|
66
|
+
/** Something answered, but it is not a page definition. */
|
|
67
|
+
| 'not-a-page'
|
|
68
|
+
/**
|
|
69
|
+
* The page exists and has nothing a visitor may see: never given a record, or
|
|
70
|
+
* holding only records the publish fence hides — a DRAFT, or one SCHEDULED
|
|
71
|
+
* for a moment that has not arrived. The shop API already removed those rows
|
|
72
|
+
* (it fences every storefront read to published content), so this arm is the
|
|
73
|
+
* renderer HONOURING an empty result, never a second publish check.
|
|
74
|
+
*/
|
|
75
|
+
| 'no-live-content';
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The answer for one page URL.
|
|
79
|
+
*
|
|
80
|
+
* The `ok` arm carries BOTH halves of what the route renders, narrowed to the
|
|
81
|
+
* caller's own types. That is why it is generic rather than a bare verdict: a
|
|
82
|
+
* caller that had to re-check `group !== null` after a resolution that already
|
|
83
|
+
* proved it would be asserting what the law just decided.
|
|
84
|
+
*/
|
|
85
|
+
export type PageResolution<TGroup, TEntry> =
|
|
86
|
+
| { kind: 'ok'; group: TGroup; entry: TEntry }
|
|
87
|
+
| { kind: 'notFound'; reason: PageNotFoundReason };
|
|
88
|
+
|
|
89
|
+
export interface ResolvePageInput<TGroup, TEntry> {
|
|
90
|
+
/** The slug exactly as it appeared in the URL, locale prefix already stripped. */
|
|
91
|
+
requestedRoute: string;
|
|
92
|
+
/** What `pageByRoute` answered, or null when it answered nothing. */
|
|
93
|
+
group: TGroup | null;
|
|
94
|
+
/**
|
|
95
|
+
* The page's visible records, in the order the CALLER asked for. A page is
|
|
96
|
+
* one URL, so it serves one record: the first of these. Which record that is
|
|
97
|
+
* is the caller's decision and belongs at the query (see `getPageEntries`),
|
|
98
|
+
* not here.
|
|
99
|
+
*/
|
|
100
|
+
entries: readonly TEntry[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Decide the honest answer for a page URL.
|
|
105
|
+
*
|
|
106
|
+
* TOTAL over its input and ordered so each arm may assume the ones above it did
|
|
107
|
+
* not fire. Two of the four refusals are the feature's own contract — an
|
|
108
|
+
* unpublished or not-yet-live page answers 404 — and the other two are what
|
|
109
|
+
* make the function total.
|
|
110
|
+
*/
|
|
111
|
+
export function resolvePage<TGroup extends RoutedPageGroup, TEntry>({
|
|
112
|
+
requestedRoute,
|
|
113
|
+
group,
|
|
114
|
+
entries,
|
|
115
|
+
}: ResolvePageInput<TGroup, TEntry>): PageResolution<TGroup, TEntry> {
|
|
116
|
+
if (isLocaleSegment(requestedRoute)) {
|
|
117
|
+
return { kind: 'notFound', reason: 'reserved-locale-segment' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (group === null) return { kind: 'notFound', reason: 'unknown-route' };
|
|
121
|
+
|
|
122
|
+
// A boundary check, not a redundant one: the shop query returns the general
|
|
123
|
+
// field-definition type and nothing in that TYPE says "page", so this is the
|
|
124
|
+
// template asserting that what it is about to render as a page is one — the
|
|
125
|
+
// same posture `parseScaffoldedRoute` takes toward a value another process
|
|
126
|
+
// wrote.
|
|
127
|
+
if (group.kind !== 'page') return { kind: 'notFound', reason: 'not-a-page' };
|
|
128
|
+
|
|
129
|
+
// Mounted-ness, read back off the answer itself, in the same posture as the
|
|
130
|
+
// kind check above: `pageByRoute` matched this group BY its route, so one
|
|
131
|
+
// that carries none contradicts the question it answered. Stating that as a
|
|
132
|
+
// 404 is what keeps the route from rendering a page no URL owns — and it is
|
|
133
|
+
// why the absence is resolved HERE rather than by a caller that would have
|
|
134
|
+
// to invent a meaning for it.
|
|
135
|
+
if (group.route === null || group.route === undefined) {
|
|
136
|
+
return { kind: 'notFound', reason: 'unknown-route' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const entry = entries[0];
|
|
140
|
+
if (entry === undefined) return { kind: 'notFound', reason: 'no-live-content' };
|
|
141
|
+
|
|
142
|
+
return { kind: 'ok', group, entry };
|
|
143
|
+
}
|
|
@@ -10,6 +10,7 @@ import { after } from 'next/server';
|
|
|
10
10
|
import { cache } from 'react';
|
|
11
11
|
import { uuidv7 } from 'uuidv7';
|
|
12
12
|
|
|
13
|
+
import { SESSION_COOKIE } from './session-cookies';
|
|
13
14
|
import { forwardTrackEvent, getUpstreamConfig, isObviousBot } from './track-forward';
|
|
14
15
|
import type { TrackEventInput, UpstreamConfig } from './track-forward';
|
|
15
16
|
|
|
@@ -61,7 +62,6 @@ import type { TrackEventInput, UpstreamConfig } from './track-forward';
|
|
|
61
62
|
*/
|
|
62
63
|
|
|
63
64
|
const VISITOR_COOKIE = 'forgecart-visitor';
|
|
64
|
-
const SESSION_COOKIE = 'forgecart-session';
|
|
65
65
|
const FORCED_ENTRY_COOKIE = 'forgecart-exp-force';
|
|
66
66
|
|
|
67
67
|
/** Delivery-config cache TTL — the design doc's 60s config-plane freshness. */
|