create-ailk 0.30.0 → 0.31.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 (67) hide show
  1. package/README.md +71 -3
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/cli.js +99 -0
  5. package/dist/cutover.d.ts +109 -0
  6. package/dist/cutover.js +494 -0
  7. package/dist/design-presets.js +20 -4
  8. package/dist/export-content.d.ts +55 -0
  9. package/dist/export-content.js +303 -0
  10. package/dist/inventory.d.ts +216 -0
  11. package/dist/inventory.js +833 -0
  12. package/dist/language-import.d.ts +114 -0
  13. package/dist/language-import.js +435 -0
  14. package/dist/lib/env-catalog.js +1 -1
  15. package/dist/lib/import-mdx.d.ts +38 -0
  16. package/dist/lib/import-mdx.js +411 -64
  17. package/dist/lib/render-comparison.d.ts +200 -0
  18. package/dist/lib/render-comparison.js +374 -0
  19. package/dist/migrate-content.d.ts +6 -2
  20. package/dist/migrate-content.js +60 -14
  21. package/dist/seed-workspace.d.ts +3 -1
  22. package/dist/seed-workspace.js +45 -16
  23. package/dist/topology.d.ts +12 -0
  24. package/dist/topology.js +24 -10
  25. package/package.json +3 -3
  26. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  27. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  28. package/templates/.claude/settings.json +9 -0
  29. package/templates/.github/workflows/ci.yml +2 -0
  30. package/templates/CLAUDE.md +5 -0
  31. package/templates/apps/mcp/CLAUDE.md +8 -4
  32. package/templates/apps/web/.env.example +4 -2
  33. package/templates/apps/web/CLAUDE.md +16 -5
  34. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
  35. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  36. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  37. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  38. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  39. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  40. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  41. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  42. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  43. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  44. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  45. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  46. package/templates/apps/web/app/llms.txt/route.ts +16 -9
  47. package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
  48. package/templates/apps/web/jest.config.cjs +46 -1
  49. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  50. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  51. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
  52. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  53. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  54. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
  55. package/templates/apps/web/lib/bot-check.ts +113 -13
  56. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  57. package/templates/apps/web/lib/page-chrome.ts +65 -5
  58. package/templates/apps/web/lib/relay.ts +22 -3
  59. package/templates/apps/web/lib/site-identity.ts +75 -24
  60. package/templates/apps/web/messages/en.json +5 -1
  61. package/templates/apps/web/next.config.mjs +15 -1
  62. package/templates/apps/web/package.json +1 -1
  63. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  64. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  65. package/templates/package.json +2 -1
  66. package/templates/tools/check-content-source.ts +108 -0
  67. package/versions.json +48 -48
@@ -35,6 +35,13 @@
35
35
  * a `thankYou` block, so without this widening the callback would never be
36
36
  * supplied on a recommending flow at all.
37
37
  *
38
+ * Per-tier completion redirect (#10616 D4): when a `scoring.tiers[]` entry
39
+ * carries a `thankYou` slug, the client scores the walk's own answers
40
+ * (`resolveTierThankYouSlug`) and navigates to the scored tier's
41
+ * `/thanks/<slug>` AHEAD of the results permalink and the `thankYou` block;
42
+ * a tier with no slug, or a flow with none, continues exactly as before. The
43
+ * answers are read to score, never written into the href.
44
+ *
38
45
  * Account link (#8771 D5): when the walk completes on an `invite` magnet (the
39
46
  * step response's `next`), the client asks `accountInviteLinkAction` for the
40
47
  * sign-in link with the email the visitor gave already filled in, then shows
@@ -92,6 +99,7 @@ import type {
92
99
  FlowEndingOutcome,
93
100
  FlowState,
94
101
  FlowStepDeliverable,
102
+ WaitlistAnswers,
95
103
  } from "@working-theory/validation";
96
104
  import {
97
105
  deriveFlags,
@@ -107,6 +115,7 @@ import {
107
115
  pruneHiddenState,
108
116
  resolveLocalizedString,
109
117
  resolveThankYouSlug,
118
+ resolveTierThankYouSlug,
110
119
  selectFlowEndingOutcome,
111
120
  SITE_CONSENT_CONFIG_DEFAULTS,
112
121
  visitorNameOf,
@@ -202,12 +211,21 @@ function segmentationQuestionNames(config: FlowConfig): Set<string> {
202
211
  /**
203
212
  * #8840 D9 — the answers the ending is chosen from, assembled as the
204
213
  * permalink path assembles them: the visible steps' answers flattened, then
205
- * the hidden segmentation steps' `whenHidden` answers.
214
+ * the hidden segmentation steps' `whenHidden` answers. Narrowed to the
215
+ * evaluator's string / string[] shape (#10616), which is all any reader keeps.
206
216
  */
207
- function walkAnswers(config: FlowConfig, state: FlowState): Record<string, unknown> {
217
+ function walkAnswers(config: FlowConfig, state: FlowState): WaitlistAnswers {
208
218
  const flags = deriveFlags(config, state);
209
219
  const flat = flattenState(pruneHiddenState(config, state, flags));
210
- return Object.assign(flat, hiddenAnswers(config, state, flags));
220
+ const answers: WaitlistAnswers = {};
221
+ for (const [name, value] of Object.entries(
222
+ Object.assign(flat, hiddenAnswers(config, state, flags)),
223
+ )) {
224
+ if (typeof value === "string" || Array.isArray(value)) {
225
+ answers[name] = value as string | string[];
226
+ }
227
+ }
228
+ return answers;
211
229
  }
212
230
 
213
231
  export function FlowStepperClient({
@@ -225,6 +243,10 @@ export function FlowStepperClient({
225
243
  // client never routes to.
226
244
  const isRecommending =
227
245
  config.mode === "recommending" && config.offers !== undefined;
246
+ // #10616 D4 — a flow whose scoring maps at least one tier to a thank-you
247
+ // page: completion may redirect there, so `onCompleted` is passed.
248
+ const hasTierThankYou =
249
+ config.scoring?.tiers.some((tier) => tier.thankYou !== undefined) === true;
228
250
 
229
251
  // #4770 D1 — the client's own accumulated answers, written from the
230
252
  // RESOLVED result of onSubmitStep (never the inbound payload) so a
@@ -427,6 +449,18 @@ export function FlowStepperClient({
427
449
  segment?: string;
428
450
  aeoScore?: AeoScoreResult;
429
451
  }): void {
452
+ // #10616 D4 — a scored tier's thank-you page wins over the permalink and
453
+ // the `thankYou` block. Scored from the walk's answers as the server's
454
+ // completion payload assembles them; no flags are passed (the server
455
+ // scores with none).
456
+ if (hasTierThankYou) {
457
+ const tierSlug = resolveTierThankYouSlug(config, walkAnswers(config, answersRef.current));
458
+ if (tierSlug !== undefined) {
459
+ goToThanks(tierSlug);
460
+ return;
461
+ }
462
+ }
463
+
430
464
  if (isRecommending) {
431
465
  // D1 (hidden-step pruning) — apply the shipped visibility resolver to
432
466
  // the accumulated state before encoding, so an abandoned branch's
@@ -481,7 +515,12 @@ export function FlowStepperClient({
481
515
  }
482
516
 
483
517
  if (!thankYou) return;
484
- const thankYouSlug = resolveThankYouSlug(thankYou, result.segment);
518
+ goToThanks(resolveThankYouSlug(thankYou, result.segment));
519
+ }
520
+
521
+ // The thank-you redirect's tail, shared by the `thankYou` block and a
522
+ // scored tier's slug (#10616 D4): the invite hand-off, then the push.
523
+ function goToThanks(thankYouSlug: string): void {
485
524
  // #8771 D5 — hand the link to /thanks through the per-tab sessionStorage,
486
525
  // never the URL; with no link, clear an earlier walk's so it never shows
487
526
  // on this page. A storage that throws gives no link.
@@ -587,7 +626,7 @@ export function FlowStepperClient({
587
626
  locale={locale}
588
627
  messages={messages}
589
628
  onSubmitStep={handleSubmitStep}
590
- {...(!endingWins && (thankYou !== undefined || isRecommending)
629
+ {...(!endingWins && (thankYou !== undefined || isRecommending || hasTierThankYou)
591
630
  ? { onCompleted: handleCompleted }
592
631
  : {})}
593
632
  {...(completedAction !== undefined ? { completedAction } : {})}
@@ -0,0 +1,306 @@
1
+ // oss: false
2
+ /**
3
+ * Tests for FlowStepperClient's per-tier thank-you redirect on a scored
4
+ * recommending flow (#10616 D4).
5
+ *
6
+ * A SEPARATE file from the six existing `FlowStepperClient.*.test.tsx`
7
+ * suites, which stay UNEDITED as the AC-4 regression floor. It mocks
8
+ * `FlowStepper` the way `FlowStepperClient.permalink.test.tsx` does — a stub
9
+ * that captures the `onSubmitStep`/`onCompleted` props — because this file's
10
+ * scope is the client's own completion wiring, not the stepper.
11
+ *
12
+ * AC-3(b) — a finished walk whose scored tier carries a `thankYou` slug
13
+ * pushes exactly `/<locale>/thanks/<slug>`, ahead of the results
14
+ * permalink; the URL carries no submitted value, no segmentation answer
15
+ * and no session id; an unknown locale falls back to the default.
16
+ * AC-3(a) — the client scores with EMPTY flags (the server's input), so a
17
+ * flag-gated tier never wins even when the walk's own `flags` would set it.
18
+ * AC-3(c) — a flow with tier slugs but no `thankYou` and no `offers` still
19
+ * receives `onCompleted`.
20
+ * AC-4 — a tier with no slug pushes today's permalink; a `thankYou`-only
21
+ * flow pushes `thankYou.default`; an `ending` still wins (no `onCompleted`).
22
+ */
23
+ import { render } from "@testing-library/react";
24
+ import type { FlowConfig } from "@working-theory/validation";
25
+ import { decodePermalink } from "@working-theory/validation";
26
+ import type { ReactElement } from "react";
27
+
28
+ const mockPush = jest.fn();
29
+ jest.mock("next/navigation", () => ({
30
+ useRouter: () => ({ push: mockPush }),
31
+ }));
32
+
33
+ jest.mock("next-intl", () => ({
34
+ useTranslations: () => (key: string) => key,
35
+ useMessages: () => ({}),
36
+ }));
37
+
38
+ // FlowStepperClient binds onSubmitStep directly to this Server Action, and
39
+ // the boundary under test here is the onCompleted -> router.push wiring, not
40
+ // the action itself (covered by flow.actions.test.ts) — mirrors the sibling
41
+ // suites' identical narrow-scope justification.
42
+ // eslint-disable-next-line no-restricted-syntax -- see comment above
43
+ jest.mock("../flow.actions", () => ({
44
+ submitFlowStepAction: jest.fn(),
45
+ }));
46
+
47
+ interface MockFlowStepperProps {
48
+ onSubmitStep: (payload: {
49
+ stepKey: string;
50
+ values: Record<string, unknown>;
51
+ locale?: string;
52
+ }) => Promise<{
53
+ ok: boolean;
54
+ kind?: string;
55
+ response?: { completed: boolean; segment?: string };
56
+ }>;
57
+ onCompleted?: (result: { segment?: string }) => void;
58
+ }
59
+
60
+ let captured: MockFlowStepperProps | undefined;
61
+
62
+ jest.mock("@working-theory/ui-signal-collectors", () => ({
63
+ FlowStepper: (props: MockFlowStepperProps) => {
64
+ captured = props;
65
+ return require("react").createElement("div", {
66
+ "data-testid": "mock-flow-stepper",
67
+ }) as ReactElement;
68
+ },
69
+ }));
70
+
71
+ import { submitFlowStepAction } from "../flow.actions";
72
+ import { FlowStepperClient } from "../FlowStepperClient";
73
+
74
+ const mockSubmitFlowStepAction = submitFlowStepAction as jest.MockedFunction<
75
+ typeof submitFlowStepAction
76
+ >;
77
+
78
+ const SLUG = "founder-diagnostic";
79
+ const SESSION_ID = "sess-1a2b3c";
80
+
81
+ type Scoring = NonNullable<FlowConfig["scoring"]>;
82
+
83
+ const PATH_A_ANSWERS = { revenue: "1m-plus", timing: "now" };
84
+ const PATH_B_ANSWERS = { revenue: "under-500k", timing: "exploring" };
85
+ const PII_PAYLOAD = {
86
+ email: "jane@example.com",
87
+ name: "Jane Doe",
88
+ phone: "555-123-4567",
89
+ message: "Please reach out about the enterprise plan as soon as possible",
90
+ };
91
+
92
+ /**
93
+ * path_a: revenue in the top bands AND timing not "exploring" — a rule over
94
+ * two answers, which `thankYou.bySegment` cannot express; path_b is the
95
+ * default. Each tier carries its own ThankYouPage slug.
96
+ */
97
+ function tierScoring(): Scoring {
98
+ return {
99
+ version: 1,
100
+ points: [
101
+ {
102
+ name: "revenue_points",
103
+ question: "revenue",
104
+ kind: "optionWeights",
105
+ weights: { "under-500k": 0, "500k-1m": 1, "1m-plus": 2 },
106
+ },
107
+ ],
108
+ scores: { total: { sum: ["revenue_points"] } },
109
+ tiers: [
110
+ {
111
+ key: "path_a",
112
+ when: {
113
+ allOf: [
114
+ { answer: "revenue", in: ["500k-1m", "1m-plus"] },
115
+ { not: { answer: "timing", eq: "exploring" } },
116
+ ],
117
+ },
118
+ thankYou: "path-a-thanks",
119
+ },
120
+ { key: "path_b", when: { answer: "timing", eq: "exploring" }, thankYou: "path-b-thanks" },
121
+ ],
122
+ default: "path_b",
123
+ };
124
+ }
125
+
126
+ /** A recommending flow — `offers` plus the scoring above — whose field step
127
+ * submits an email, a name, a phone number and free text. */
128
+ function scoredFlowConfig(overrides: Partial<FlowConfig> = {}): FlowConfig {
129
+ return {
130
+ slug: SLUG,
131
+ defaultLocale: "en",
132
+ mode: "recommending",
133
+ steps: [
134
+ {
135
+ key: "fit",
136
+ type: "segmentation",
137
+ copy: { title: { en: "Fit" } },
138
+ questions: [
139
+ {
140
+ name: "revenue",
141
+ options: [
142
+ { value: "under-500k", label: { en: "Under $500K" } },
143
+ { value: "500k-1m", label: { en: "$500K–$1M" } },
144
+ { value: "1m-plus", label: { en: "$1M+" } },
145
+ ],
146
+ },
147
+ {
148
+ name: "timing",
149
+ options: [
150
+ { value: "now", label: { en: "Now" } },
151
+ { value: "exploring", label: { en: "Just exploring" } },
152
+ ],
153
+ },
154
+ ],
155
+ },
156
+ {
157
+ key: "contact",
158
+ type: "field",
159
+ copy: { title: { en: "Contact" } },
160
+ fields: [
161
+ { name: "email", inputType: "email", required: true, copy: { label: { en: "Email" } } },
162
+ { name: "name", inputType: "text", required: true, copy: { label: { en: "Name" } } },
163
+ { name: "phone", inputType: "tel", copy: { label: { en: "Phone" } } },
164
+ { name: "message", inputType: "textarea", copy: { label: { en: "Message" } } },
165
+ ],
166
+ },
167
+ ],
168
+ offers: [
169
+ {
170
+ key: "call",
171
+ copy: { name: { en: "A call" } },
172
+ price: { kind: "label", label: { en: "Free" } },
173
+ rules: [{ when: { answer: "revenue", eq: "1m-plus" }, reason: { en: "Ready" } }],
174
+ },
175
+ ],
176
+ scoring: tierScoring(),
177
+ ...overrides,
178
+ } as FlowConfig;
179
+ }
180
+
181
+ /** Render the client, submit the segmentation step then the PII-laden field
182
+ * step (the second completing the walk), and fire `onCompleted`. */
183
+ async function completeWalk(
184
+ config: FlowConfig,
185
+ answers: Record<string, string>,
186
+ locale = "en",
187
+ ): Promise<void> {
188
+ mockSubmitFlowStepAction
189
+ .mockResolvedValueOnce({
190
+ ok: true,
191
+ response: { sessionId: SESSION_ID, landed: "fit", nextStepKey: "contact", completed: false },
192
+ })
193
+ .mockResolvedValueOnce({
194
+ ok: true,
195
+ response: { sessionId: SESSION_ID, landed: "contact", nextStepKey: null, completed: true },
196
+ });
197
+
198
+ render(<FlowStepperClient config={config} locale={locale} slug={SLUG} />);
199
+
200
+ await captured!.onSubmitStep({ stepKey: "fit", values: answers, locale });
201
+ await captured!.onSubmitStep({ stepKey: "contact", values: PII_PAYLOAD, locale });
202
+ captured!.onCompleted?.({});
203
+ }
204
+
205
+ function pushedUrl(): string {
206
+ expect(mockPush).toHaveBeenCalledTimes(1);
207
+ return mockPush.mock.calls[0]?.[0] as string;
208
+ }
209
+
210
+ beforeEach(() => {
211
+ mockPush.mockClear();
212
+ mockSubmitFlowStepAction.mockClear();
213
+ captured = undefined;
214
+ window.sessionStorage.clear();
215
+ });
216
+
217
+ describe("FlowStepperClient — a scored tier's thank-you page replaces the permalink (#10616 AC-3(b))", () => {
218
+ it("path-A answers push exactly /en/thanks/path-a-thanks", async () => {
219
+ await completeWalk(scoredFlowConfig(), PATH_A_ANSWERS);
220
+ expect(pushedUrl()).toBe("/en/thanks/path-a-thanks");
221
+ });
222
+
223
+ it("path-B answers push exactly /en/thanks/path-b-thanks", async () => {
224
+ await completeWalk(scoredFlowConfig(), PATH_B_ANSWERS);
225
+ expect(pushedUrl()).toBe("/en/thanks/path-b-thanks");
226
+ });
227
+
228
+ it("the pushed URL carries no submitted value, no segmentation answer and no session id", async () => {
229
+ await completeWalk(scoredFlowConfig(), PATH_A_ANSWERS);
230
+ // The PII reached onSubmitStep (the wiring forwards real form data) —
231
+ // the assertion is that none of it reaches the push.
232
+ expect(mockSubmitFlowStepAction).toHaveBeenCalledWith(
233
+ SLUG,
234
+ expect.objectContaining({ values: PII_PAYLOAD }),
235
+ );
236
+ const url = pushedUrl();
237
+ expect(url).toMatch(/^\/[A-Za-z-]+\/thanks\/[a-z0-9-]+$/);
238
+ for (const value of [
239
+ ...Object.values(PII_PAYLOAD),
240
+ ...Object.values(PATH_A_ANSWERS),
241
+ SESSION_ID,
242
+ ]) {
243
+ expect(url).not.toContain(value);
244
+ }
245
+ });
246
+
247
+ it("an unknown [locale] falls back to the default locale before interpolation", async () => {
248
+ await completeWalk(scoredFlowConfig(), PATH_A_ANSWERS, "xx");
249
+ expect(pushedUrl()).toBe("/en/thanks/path-a-thanks");
250
+ });
251
+ });
252
+
253
+ describe("FlowStepperClient — the client scores with the server's empty flags (#10616 AC-3(a))", () => {
254
+ it("a flag-gated first tier never wins, even when the walk's own flags would set it", async () => {
255
+ const scoring = tierScoring();
256
+ scoring.tiers = [
257
+ { key: "flagged", when: { flag: "pre_launch" }, thankYou: "flagged-thanks" },
258
+ { key: "path_b", when: { answer: "timing", eq: "exploring" }, thankYou: "path-b-thanks" },
259
+ ];
260
+ const config = scoredFlowConfig({
261
+ flags: [{ name: "pre_launch", when: { answer: "timing", eq: "now" } }],
262
+ scoring,
263
+ });
264
+ await completeWalk(config, PATH_A_ANSWERS);
265
+ expect(pushedUrl()).toBe("/en/thanks/path-b-thanks");
266
+ });
267
+ });
268
+
269
+ describe("FlowStepperClient — onCompleted is supplied for a flow with tier slugs alone (#10616 AC-3(c))", () => {
270
+ it("mode recommending with scoring, no offers and no thankYou still receives onCompleted", () => {
271
+ const { offers: _offers, ...noOffers } = scoredFlowConfig();
272
+ render(<FlowStepperClient config={noOffers as FlowConfig} locale="en" slug={SLUG} />);
273
+ expect(captured?.onCompleted).toBeDefined();
274
+ });
275
+ });
276
+
277
+ describe("FlowStepperClient — no mapping is today's behavior (#10616 AC-4)", () => {
278
+ it("(a) a tier with no slug pushes the results permalink, decodable to the segmentation answers", async () => {
279
+ const scoring = tierScoring();
280
+ delete scoring.tiers[1]!.thankYou;
281
+ await completeWalk(scoredFlowConfig({ scoring }), PATH_B_ANSWERS);
282
+
283
+ const match = pushedUrl().match(new RegExp(`^/en/flows/${SLUG}/results/(.+)$`));
284
+ expect(match).not.toBeNull();
285
+ const decoded = decodePermalink(match![1]!);
286
+ expect(decoded.ok).toBe(true);
287
+ if (!decoded.ok) return;
288
+ expect(decoded.value.flowSlug).toBe(SLUG);
289
+ expect(decoded.value.answers).toEqual(PATH_B_ANSWERS);
290
+ });
291
+
292
+ it("(b) a thankYou-only flow with no scoring pushes thankYou.default", async () => {
293
+ const { mode: _mode, offers: _offers, scoring: _scoring, ...plain } = scoredFlowConfig();
294
+ const config = { ...plain, thankYou: { default: "flow-thanks" } } as FlowConfig;
295
+ await completeWalk(config, PATH_A_ANSWERS);
296
+ expect(pushedUrl()).toBe("/en/thanks/flow-thanks");
297
+ });
298
+
299
+ it("(c) a flow with an ending and tier slugs is passed no onCompleted", () => {
300
+ const config = scoredFlowConfig({
301
+ ending: { default: { kind: "screen", headline: { en: "Thanks" } } },
302
+ } as Partial<FlowConfig>);
303
+ render(<FlowStepperClient config={config} locale="en" slug={SLUG} />);
304
+ expect(captured?.onCompleted).toBeUndefined();
305
+ });
306
+ });
@@ -25,10 +25,17 @@
25
25
  // every page in the tree (#4090 item 1).
26
26
  import "../globals.css";
27
27
 
28
+ import { readFileSync } from "node:fs";
29
+ import { join } from "node:path";
30
+
28
31
  import { Telemetry, VerificationMeta } from "@working-theory/analytics";
32
+ import {
33
+ SiteConsentSchema,
34
+ type ConsentAppearance,
35
+ } from "@working-theory/site-config/schema";
29
36
  import { MarketingShell } from "@working-theory/templates/shells";
30
37
  import { themeToShellCss } from "@working-theory/theme";
31
- import { Link, isRtl, localeHref } from "@working-theory/ui";
38
+ import { Link, NewsletterSignupProvider, isRtl, localeHref } from "@working-theory/ui";
32
39
  import { BRAND_PATHS, LocaleSwitcherNav } from "@working-theory/ui/blocks";
33
40
  import {
34
41
  Banner,
@@ -54,6 +61,7 @@ import { cookies, headers } from "next/headers";
54
61
  import { NextIntlClientProvider } from "next-intl";
55
62
  import { getMessages, getTranslations } from "next-intl/server";
56
63
  import type { ReactElement, ReactNode } from "react";
64
+ import { parse as parseYaml } from "yaml";
57
65
 
58
66
  import { GoogleAnalytics } from "../../components/google-analytics";
59
67
  import {
@@ -71,6 +79,8 @@ import {
71
79
  import {
72
80
  inPageView,
73
81
  newsletterSignup,
82
+ type InPageView,
83
+ type NewsletterFormView,
74
84
  readCaptureStatus,
75
85
  readServedMagnet,
76
86
  } from "../../lib/in-page-magnet";
@@ -84,6 +94,7 @@ import { footerColumns, navItems } from "../../lib/nav-config";
84
94
  import { navMessageKeys, usedNavLabelKeys } from "../../lib/nav-label-keys";
85
95
  import { getOgImageUrl } from "../../lib/og";
86
96
  import {
97
+ readClosingFooterSettings,
87
98
  resolveChromePage,
88
99
  resolveHomeChrome,
89
100
  resolvePageChrome,
@@ -98,6 +109,7 @@ import {
98
109
  COPYRIGHT_HOLDER,
99
110
  SITE_DESCRIPTION,
100
111
  SITE_NAME,
112
+ resolveContentRoot,
101
113
  } from "../../lib/site-brand";
102
114
  import { siteChrome, type SiteChrome } from "../../lib/site-chrome";
103
115
  import {
@@ -209,6 +221,38 @@ async function resolveThemeMode(): Promise<ThemeMode> {
209
221
  const DECK_FIRST_SLIDE_SELECTOR =
210
222
  '.ailk-snap-deck .ailk-section-renderer > :not(.ailk-section-separator):not([data-section="separator"])';
211
223
 
224
+ let warnedConsentAppearance = false;
225
+
226
+ /**
227
+ * `_site.mdx`'s `consent.appearance` (#10555), else `undefined`. The same
228
+ * local frontmatter read as `lib/site-header.ts`, validated against the
229
+ * canonical schema. An absent file, frontmatter or key is silent; an invalid
230
+ * value warns once per process, naming the field path and never the value.
231
+ */
232
+ function readConsentAppearance(): ConsentAppearance | undefined {
233
+ try {
234
+ const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
235
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
236
+ if (!match) return undefined;
237
+ const fm: unknown = parseYaml(match[1] ?? "");
238
+ if (!fm || typeof fm !== "object") return undefined;
239
+ const { consent } = fm as { consent?: unknown };
240
+ if (consent === undefined) return undefined;
241
+ const parsed = SiteConsentSchema.safeParse(consent);
242
+ if (parsed.success) return parsed.data.appearance;
243
+ if (!warnedConsentAppearance) {
244
+ warnedConsentAppearance = true;
245
+ const issue = parsed.error.issues[0];
246
+ console.warn(
247
+ `[layout] ignoring _site.mdx ${["consent", ...(issue?.path ?? [])].join(".")}: ${issue?.message ?? "invalid"}`,
248
+ );
249
+ }
250
+ return undefined;
251
+ } catch {
252
+ return undefined;
253
+ }
254
+ }
255
+
212
256
  /**
213
257
  * Whether this visitor has already accepted or declined cookies (#5557).
214
258
  *
@@ -300,12 +344,15 @@ interface Props {
300
344
  }
301
345
 
302
346
  /**
303
- * #8847 D13 — the footer's newsletter signup: the newsletter magnet's compact
304
- * `NewsletterFormInline`, only when the magnet is served (live), maps as a
305
- * one-field signup, and the API accepts signups. The seeded built-in config
306
- * never fills the footer. Both reads are shared for 15 seconds per instance.
347
+ * #8847 D13 — the newsletter magnet's view for the footer's signup, and
348
+ * (#10629 D4) for every bound `newsletter` section: only when the magnet is
349
+ * served (live), maps as a one-field signup, and the API accepts signups. The
350
+ * seeded built-in config never fills either. Both reads are shared for 15
351
+ * seconds per instance.
307
352
  */
308
- async function footerNewsletter(locale: string): Promise<ReactElement | undefined> {
353
+ async function servedNewsletterView(
354
+ locale: string,
355
+ ): Promise<(InPageView & { form: NewsletterFormView }) | undefined> {
309
356
  const [magnet, status] = await Promise.all([
310
357
  readServedMagnet(NEWSLETTER_FLOW_SLUG),
311
358
  readCaptureStatus(),
@@ -313,11 +360,7 @@ async function footerNewsletter(locale: string): Promise<ReactElement | undefine
313
360
  if (magnet === null || status?.newsletter !== true) return undefined;
314
361
  const view = inPageView(magnet, locale, "newsletter");
315
362
  if (view === null || !("kind" in view.form)) return undefined;
316
- return (
317
- <div data-testid="footer-newsletter">
318
- {newsletterSignup({ ...view, form: view.form }, { locale, compact: true })}
319
- </div>
320
- );
363
+ return { ...view, form: view.form };
321
364
  }
322
365
 
323
366
  export default async function LocaleLayout({
@@ -336,6 +379,11 @@ export default async function LocaleLayout({
336
379
  // #7742 D3/D5 — the minimal chrome: the closing slide, the hairline consent
337
380
  // bar and the cue offset.
338
381
  const isMinimalChrome = chrome.chromeStyle === "minimal";
382
+ // #10555 — `_site.mdx`'s `consent.appearance` picks the cookie notice's
383
+ // look; absent, the chrome does (hairline under minimal, else compact).
384
+ const isHairlineConsent =
385
+ (readConsentAppearance() ?? (isMinimalChrome ? "hairline" : "compact")) ===
386
+ "hairline";
339
387
  const consentDecided = await hasConsentChoice();
340
388
  // Server-side session read (#4984) — the same `auth.api.getSession` idiom
341
389
  // the `(authed)` segment guard uses, through the generated topology seam
@@ -343,7 +391,26 @@ export default async function LocaleLayout({
343
391
  // Null when signed out or auth is unset.
344
392
  const session = await readSession(await headers());
345
393
 
346
- const footerSignup = await footerNewsletter(locale);
394
+ const newsletterView = await servedNewsletterView(locale);
395
+ // #8847 D13 — the footer's compact signup.
396
+ const footerSignup =
397
+ newsletterView === undefined ? undefined : (
398
+ <div data-testid="footer-newsletter">
399
+ {newsletterSignup(newsletterView, { locale, compact: true })}
400
+ </div>
401
+ );
402
+ // #10629 D4 — the full-size signup every bound `newsletter` section renders
403
+ // (as on /newsletter), with the on-submit consent gate. Both shell branches
404
+ // render the page under its provider, so a bound section on any page reads it.
405
+ const page = (
406
+ <NewsletterSignupProvider
407
+ {...(newsletterView !== undefined && {
408
+ signup: newsletterSignup(newsletterView, { locale, consentGate: "on-submit" }),
409
+ })}
410
+ >
411
+ {children}
412
+ </NewsletterSignupProvider>
413
+ );
347
414
 
348
415
  // Resolve nav labels server-side so Nav + Footer components stay
349
416
  // i18n-hook-free and are testable without a NextIntlClientProvider wrapper.
@@ -486,6 +553,22 @@ export default async function LocaleLayout({
486
553
  const deckFooterActions = session
487
554
  ? [{ labelKey: "account", path: "/account", variant: "outline-strong" as const }]
488
555
  : [...authActions];
556
+ // #10550 D4 — the closing footer's site settings: its locality line, its
557
+ // actions (the primary, then the secondary — they replace the auth links
558
+ // on the closing footer in every header state) and its legal bar. The
559
+ // site's labels ride `itemLabels` under keys no nav key can collide with
560
+ // (no nav key contains a dot).
561
+ const closingFooter = readClosingFooterSettings();
562
+ const closingFooterActions = closingFooter.actions?.map(
563
+ (action, i) => ({
564
+ labelKey: `closingFooter.action.${i}`,
565
+ path: action.href,
566
+ variant: i === 0 ? ("primary" as const) : ("ghost" as const),
567
+ }),
568
+ );
569
+ const closingFooterLabels = Object.fromEntries(
570
+ (closingFooter.actions ?? []).map((action, i) => [`closingFooter.action.${i}`, action.label]),
571
+ );
489
572
  // A bare inner page's auth (#7721 D3/D4) rides the one-bar footer's link
490
573
  // group: Sign in / Sign up signed out, Account signed in (where Sign out
491
574
  // lives). Labels from the existing `nav` namespace. Under a full header
@@ -788,7 +871,7 @@ export default async function LocaleLayout({
788
871
  * consent Banner below. The booking paths (#8843 D10) and a magnet
789
872
  * walk (#8840 D10) take it and keep the Banner.
790
873
  */
791
- children
874
+ page
792
875
  ) : (
793
876
  <MarketingShell
794
877
  contentOwnsMain
@@ -925,13 +1008,23 @@ export default async function LocaleLayout({
925
1008
  // #10049 D5 — the style is `pageChrome.footer`, and every
926
1009
  // footer with an actions row takes the auth links when the
927
1010
  // header is not full.
1011
+ // #10550 D4 — a site's own `footer.locality`, `footer.actions`
1012
+ // and `footer.legalBar` set the closing footer's locality line,
1013
+ // its actions (in place of the auth links, whatever the header)
1014
+ // and its legal bar.
928
1015
  {...(pageChrome.footer === "closing"
929
1016
  ? {
930
1017
  layout: "closing" as const,
931
- contactLines: readSiteContactLines(),
932
- ...(!headerIsFull && !(isHome && isMinimalChrome)
933
- ? { actions: deckFooterActions }
934
- : {}),
1018
+ contactLines: readSiteContactLines(closingFooter.locality),
1019
+ ...(closingFooterActions !== undefined
1020
+ ? {
1021
+ actions: closingFooterActions,
1022
+ itemLabels: { ...navLabels.items, ...closingFooterLabels },
1023
+ }
1024
+ : !headerIsFull && !(isHome && isMinimalChrome)
1025
+ ? { actions: deckFooterActions }
1026
+ : {}),
1027
+ ...(closingFooter.legalBar ? { legalBar: true } : {}),
935
1028
  }
936
1029
  : pageChrome.footer === "line"
937
1030
  ? { layout: "line" as const, accountLinks }
@@ -967,7 +1060,7 @@ export default async function LocaleLayout({
967
1060
  )
968
1061
  }
969
1062
  >
970
- {children}
1063
+ {page}
971
1064
  </MarketingShell>
972
1065
  )}
973
1066
  {/*
@@ -996,11 +1089,11 @@ export default async function LocaleLayout({
996
1089
  consentCookieName={CONSENT_COOKIE_NAME}
997
1090
  consentDecided={consentDecided}
998
1091
  title={tConsent("title")}
999
- // #9656 D7 — the standard chrome mounts the compact floating
1000
- // card on every width, its Privacy Policy link on the button
1001
- // row; the minimal chrome keeps its hairline band (#7742 D5),
1002
- // the statement with the link inline.
1003
- {...(isMinimalChrome
1092
+ // #9656 D7 — the compact floating card on every width, its
1093
+ // Privacy Policy link on the button row; the hairline band
1094
+ // (#7742 D5), the statement with the link inline. #10555 —
1095
+ // which one is `isHairlineConsent`'s, not the chrome's.
1096
+ {...(isHairlineConsent
1004
1097
  ? {
1005
1098
  message: (
1006
1099
  <>
@@ -1038,12 +1131,14 @@ export default async function LocaleLayout({
1038
1131
  // (`CONSENT_COOKIE_NAME`) stays the operative switch the
1039
1132
  // island itself still writes (C3).
1040
1133
  locale={locale}
1041
- // #7742 D5 — the minimal chrome's consent bar: the page ground,
1042
- // a hairline, no shadow.
1043
- {...(isMinimalChrome ? { consentAppearance: "hairline" as const } : {})}
1134
+ // #7742 D5 — the hairline consent bar: the page ground, a
1135
+ // hairline, no shadow.
1136
+ {...(isHairlineConsent ? { consentAppearance: "hairline" as const } : {})}
1044
1137
  // #7782 — on the minimal deck home the bar first shows once the
1045
1138
  // first slide has left view; every other page shows it on load.
1046
- {...(isDeckHome && isMinimalChrome
1139
+ // #10721 — the bar's alone: a minimal site that picks the card
1140
+ // (`consent.appearance: compact`) shows it on load there too.
1141
+ {...(isDeckHome && isMinimalChrome && isHairlineConsent
1047
1142
  ? { consentRevealAfter: DECK_FIRST_SLIDE_SELECTOR }
1048
1143
  : {})}
1049
1144
  />