create-ailk 0.14.0 → 0.15.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 (95) hide show
  1. package/component-catalog.json +3 -3
  2. package/dist/add-module.d.ts +7 -0
  3. package/dist/add-module.js +34 -2
  4. package/dist/add-route.js +18 -2
  5. package/dist/apply-design.d.ts +12 -1
  6. package/dist/apply-design.js +36 -4
  7. package/dist/cli.js +23 -3
  8. package/dist/derive-architecture.d.ts +15 -2
  9. package/dist/derive-architecture.js +34 -6
  10. package/dist/design-package.js +3 -2
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/lib/chrome-readers.d.ts +21 -0
  14. package/dist/lib/chrome-readers.js +100 -0
  15. package/dist/lib/extract-module-bundle.d.ts +11 -0
  16. package/dist/lib/extract-module-bundle.js +36 -6
  17. package/dist/lib/license-key-files.d.ts +6 -1
  18. package/dist/lib/license-key-files.js +9 -4
  19. package/dist/module-architecture.d.ts +9 -0
  20. package/dist/module-architecture.js +6 -0
  21. package/dist/parse-args.d.ts +22 -0
  22. package/dist/parse-args.js +73 -0
  23. package/dist/programmatic.d.ts +20 -0
  24. package/dist/programmatic.js +110 -9
  25. package/dist/site-chrome.d.ts +4 -0
  26. package/dist/site-chrome.js +34 -3
  27. package/dist/site-manifest.d.ts +12 -5
  28. package/dist/site-manifest.js +25 -11
  29. package/dist/sync-routes.js +11 -2
  30. package/dist/topology.d.ts +131 -0
  31. package/dist/topology.js +533 -0
  32. package/package.json +3 -3
  33. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  34. package/templates/apps/api/CLAUDE.md +4 -2
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  38. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  39. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  40. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  41. package/templates/apps/api/src/lib/site.ts +37 -0
  42. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  43. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  44. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  45. package/templates/apps/api/src/middleware/auth.ts +3 -0
  46. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  47. package/templates/apps/api/src/openapi/spec.ts +38 -2
  48. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  49. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  50. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  51. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  52. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  53. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  54. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  55. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  56. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  57. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  58. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  59. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  60. package/templates/apps/mcp/src/index.ts +6 -4
  61. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  62. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  63. package/templates/apps/web/CLAUDE.md +1 -0
  64. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  67. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  68. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  69. package/templates/apps/web/app/[locale]/layout.tsx +71 -19
  70. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  71. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  72. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  74. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  75. package/templates/apps/web/instrumentation.ts +7 -4
  76. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  77. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  78. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  79. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  80. package/templates/apps/web/lib/relay.ts +206 -2
  81. package/templates/apps/web/lib/site-brand.tsx +22 -1
  82. package/templates/apps/web/lib/site-chrome.ts +3 -0
  83. package/templates/apps/web/lib/topology.generated.ts +23 -0
  84. package/templates/apps/web/package.json +1 -1
  85. package/templates/database/CHANGELOG.md +25 -0
  86. package/templates/database/__tests__/agents.db.test.ts +65 -0
  87. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  88. package/templates/database/auth/schema.prisma +23 -0
  89. package/templates/database/inbox/schema.prisma +13 -0
  90. package/templates/database/jest.config.cjs +7 -11
  91. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  92. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  93. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  94. package/templates/database/package.json +1 -1
  95. package/versions.json +34 -33
@@ -19,6 +19,9 @@ import { render, screen } from "@testing-library/react";
19
19
 
20
20
  // ── @working-theory/ui mock ─────────────────────────────────────────────────
21
21
  jest.mock("@working-theory/ui", () => ({
22
+ // #7789: renderPage's fallback page head renders inside the section Container.
23
+ Container: ({ children }: { children?: React.ReactNode }) =>
24
+ React.createElement("div", null, children),
22
25
  // #4446/#4447: renderPage's own fallback/empty-state title composes Heading.
23
26
  Heading: ({
24
27
  children,
@@ -63,14 +66,22 @@ jest.mock("@working-theory/ui", () => ({
63
66
  "data-fallback-email": props.fallbackEmail ?? "",
64
67
  "data-terms-href": props.termsHref,
65
68
  },
66
- React.createElement("button", { "data-testid": "contact-submit" }, props.submitLabel ?? "Send"),
69
+ React.createElement(
70
+ "button",
71
+ { "data-testid": "contact-submit" },
72
+ props.submitLabel ?? "Send",
73
+ ),
67
74
  ),
68
75
  ContactFallback: (props: { email?: string; text?: { noEmail?: string } }) =>
69
76
  React.createElement(
70
77
  "div",
71
78
  { "data-testid": "contact-fallback" },
72
79
  props.email
73
- ? React.createElement("a", { href: `mailto:${props.email}` }, props.email)
80
+ ? React.createElement(
81
+ "a",
82
+ { href: `mailto:${props.email}` },
83
+ props.email,
84
+ )
74
85
  : props.text?.noEmail,
75
86
  ),
76
87
  }));
@@ -111,8 +122,16 @@ import { readSiteContactEmail } from "../site-contact-email";
111
122
  const mockCaptureStatus = leads.captureStatus as jest.Mock;
112
123
  const mockContactEmail = readSiteContactEmail as jest.Mock;
113
124
 
114
- const ACCEPTING = { ok: true, status: 200, body: { ok: true, data: { contact: true, newsletter: false } } };
115
- const REFUSING = { ok: true, status: 200, body: { ok: true, data: { contact: false, newsletter: false } } };
125
+ const ACCEPTING = {
126
+ ok: true,
127
+ status: 200,
128
+ body: { ok: true, data: { contact: true, newsletter: false } },
129
+ };
130
+ const REFUSING = {
131
+ ok: true,
132
+ status: 200,
133
+ body: { ok: true, data: { contact: false, newsletter: false } },
134
+ };
116
135
 
117
136
  const source = {
118
137
  frontmatter: {
@@ -124,7 +143,11 @@ const source = {
124
143
  body: "",
125
144
  };
126
145
 
127
- const splitHero = { id: "hero", type: "hero", data: { variant: "split", headline: "Hi" } };
146
+ const splitHero = {
147
+ id: "hero",
148
+ type: "hero",
149
+ data: { variant: "split", headline: "Hi" },
150
+ };
128
151
  const contactFormSection = {
129
152
  id: "contact-form",
130
153
  type: "contact-form",
@@ -140,7 +163,9 @@ const contactFormSection = {
140
163
  async function renderWith(sections: unknown[] | null) {
141
164
  const element = await ContactPage({
142
165
  params: Promise.resolve({ locale: "en" }),
143
- getMdxSource: jest.fn().mockResolvedValue(sections === null ? null : source),
166
+ getMdxSource: jest
167
+ .fn()
168
+ .mockResolvedValue(sections === null ? null : source),
144
169
  getPageSections: jest.fn().mockResolvedValue(sections ?? []),
145
170
  });
146
171
  return render(element);
@@ -163,7 +188,7 @@ describe("ContactPage — snapshot regression", () => {
163
188
  it("populated state renders deterministic HTML (renderPage sections + the form after it)", async () => {
164
189
  const { container } = await renderWith([]);
165
190
  expect(container.innerHTML).toMatchInlineSnapshot(
166
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"ContactPage","inLanguage":"en","name":"Contact Us","description":"Get in touch.","mainEntity":{"@type":"ContactPoint","contactType":"customer support"}}</script><main id="main"><h1>Contact Us</h1><p data-testid="contact-description">Get in touch.</p><div data-testid="section-renderer" data-section-count="0"></div></main><div data-testid="lead-capture-form" data-fields="" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send</button></div>"`,
191
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"ContactPage","inLanguage":"en","name":"Contact Us","description":"Get in touch.","mainEntity":{"@type":"ContactPoint","contactType":"customer support"}}</script><main id="main"><div><h1>Contact Us</h1><p data-testid="contact-description" class="mt-block-tight text-lead">Get in touch.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main><div data-testid="lead-capture-form" data-fields="" data-fallback-email="" data-terms-href="/en/terms"><button data-testid="contact-submit">Send</button></div>"`,
167
192
  );
168
193
  });
169
194
  });
@@ -184,7 +209,9 @@ describe("ContactPage — placement (#6730 D8)", () => {
184
209
  const { container } = await renderWith([splitHero]);
185
210
 
186
211
  expect(
187
- container.querySelector("[data-section-id='hero'] [data-testid='lead-capture-form']"),
212
+ container.querySelector(
213
+ "[data-section-id='hero'] [data-testid='lead-capture-form']",
214
+ ),
188
215
  ).not.toBeNull();
189
216
  expect(screen.getAllByTestId("lead-capture-form")).toHaveLength(1);
190
217
  });
@@ -194,7 +221,9 @@ describe("ContactPage — placement (#6730 D8)", () => {
194
221
  { id: "faq", type: "faq", data: { items: [] } },
195
222
  ]);
196
223
 
197
- expect(container.querySelector("main [data-testid='lead-capture-form']")).toBeNull();
224
+ expect(
225
+ container.querySelector("main [data-testid='lead-capture-form']"),
226
+ ).toBeNull();
198
227
  expect(screen.getAllByTestId("lead-capture-form")).toHaveLength(1);
199
228
  });
200
229
 
@@ -216,7 +245,9 @@ describe("ContactPage — no lead destination (#6730 D8)", () => {
216
245
  await renderWith([]);
217
246
 
218
247
  const fallback = screen.getByTestId("contact-fallback");
219
- expect(fallback.querySelector("a[href='mailto:hi@example.com']")).not.toBeNull();
248
+ expect(
249
+ fallback.querySelector("a[href='mailto:hi@example.com']"),
250
+ ).not.toBeNull();
220
251
  expect(screen.queryByTestId("contact-submit")).toBeNull();
221
252
  expect(screen.queryByTestId("lead-capture-form")).toBeNull();
222
253
  });
@@ -239,14 +270,35 @@ describe("ContactPage — no lead destination (#6730 D8)", () => {
239
270
  const { container } = await renderWith([contactFormSection]);
240
271
 
241
272
  expect(
242
- container.querySelector("[data-section-id='contact-form'] [data-testid='contact-fallback']"),
273
+ container.querySelector(
274
+ "[data-section-id='contact-form'] [data-testid='contact-fallback']",
275
+ ),
243
276
  ).not.toBeNull();
244
277
  });
245
278
 
246
279
  it.each([
247
- ["rejects (unreachable API)", () => mockCaptureStatus.mockRejectedValue(new Error("ECONNREFUSED"))],
248
- ["404 (older API)", () => mockCaptureStatus.mockResolvedValue({ ok: false, status: 404, body: {} })],
249
- ["5xx", () => mockCaptureStatus.mockResolvedValue({ ok: false, status: 503, body: {} })],
280
+ [
281
+ "rejects (unreachable API)",
282
+ () => mockCaptureStatus.mockRejectedValue(new Error("ECONNREFUSED")),
283
+ ],
284
+ [
285
+ "404 (older API)",
286
+ () =>
287
+ mockCaptureStatus.mockResolvedValue({
288
+ ok: false,
289
+ status: 404,
290
+ body: {},
291
+ }),
292
+ ],
293
+ [
294
+ "5xx",
295
+ () =>
296
+ mockCaptureStatus.mockResolvedValue({
297
+ ok: false,
298
+ status: 503,
299
+ body: {},
300
+ }),
301
+ ],
250
302
  ])("status %s → the form renders", async (_label, arrange) => {
251
303
  arrange();
252
304
 
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * `readSiteContactLines` (#7742 D4 / AC-1) — the closing slide's contact
3
- * lines, read from a fixture `_site.mdx` through `AILK_CONTENT_ROOT`.
3
+ * lines, read from a fixture `_site.mdx` through `AILK_CONTENT_ROOT`. The
4
+ * locality line is read through the typed `SiteIdentitySchema` (#7824), so
5
+ * the fixtures declare complete, valid identities.
4
6
  */
5
7
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
8
  import { tmpdir } from "node:os";
@@ -26,6 +28,18 @@ function writeSite(frontmatter: string): void {
26
28
  writeFileSync(join(root, "_site.mdx"), `---\n${frontmatter}\n---\n`);
27
29
  }
28
30
 
31
+ /** A complete PostalAddress, with the locality and region under test. */
32
+ function addressYaml(indent: string, locality: string, region: string): string[] {
33
+ return [
34
+ `${indent}address:`,
35
+ `${indent} streetAddress: 1 Main St`,
36
+ `${indent} addressLocality: ${locality}`,
37
+ `${indent} addressRegion: ${region}`,
38
+ `${indent} postalCode: '94105'`,
39
+ `${indent} addressCountry: US`,
40
+ ];
41
+ }
42
+
29
43
  describe("readSiteContactLines (#7742 D4)", () => {
30
44
  it("a LocalBusiness with an email gives its locality, then the mailto line", () => {
31
45
  writeSite(
@@ -34,13 +48,10 @@ describe("readSiteContactLines (#7742 D4)", () => {
34
48
  "siteIdentity:",
35
49
  " kind: LocalBusiness",
36
50
  " name: Acme",
51
+ " url: https://acme.test",
52
+ " telephone: '+14155550100'",
37
53
  " email: hello@acme.test",
38
- " address:",
39
- " streetAddress: 1 Main St",
40
- " addressLocality: San Francisco",
41
- " addressRegion: CA",
42
- " postalCode: '94105'",
43
- " addressCountry: US",
54
+ ...addressYaml(" ", "San Francisco", "CA"),
44
55
  ].join("\n"),
45
56
  );
46
57
  expect(readSiteContactLines()).toEqual([
@@ -49,17 +60,100 @@ describe("readSiteContactLines (#7742 D4)", () => {
49
60
  ]);
50
61
  });
51
62
 
63
+ it("a Person that declares an address gives its locality line (#7791, #7824)", () => {
64
+ writeSite(
65
+ [
66
+ "siteName: Wendi Henry",
67
+ "contactEmail: wendi@acme.test",
68
+ "siteIdentity:",
69
+ " kind: Person",
70
+ " name: Wendi Henry",
71
+ " url: https://wendi.test",
72
+ ...addressYaml(" ", "Denver", "CO"),
73
+ ].join("\n"),
74
+ );
75
+ expect(readSiteContactLines()).toEqual([
76
+ { label: "Denver, CO" },
77
+ { label: "wendi@acme.test", href: "mailto:wendi@acme.test" },
78
+ ]);
79
+ });
80
+
81
+ it("a Person's homeLocation.address is not read — the schema accepts only `address` (#7824)", () => {
82
+ writeSite(
83
+ [
84
+ "siteName: Wendi Henry",
85
+ "siteIdentity:",
86
+ " kind: Person",
87
+ " name: Wendi Henry",
88
+ " url: https://wendi.test",
89
+ " homeLocation:",
90
+ ...addressYaml(" ", "Denver", "CO"),
91
+ ].join("\n"),
92
+ );
93
+ expect(readSiteContactLines()).toEqual([]);
94
+ });
95
+
96
+ it("a Person address that fails the PostalAddress schema gives no locality line (#7824)", () => {
97
+ writeSite(
98
+ [
99
+ "siteName: Wendi Henry",
100
+ "siteIdentity:",
101
+ " kind: Person",
102
+ " name: Wendi Henry",
103
+ " url: https://wendi.test",
104
+ " address:",
105
+ " addressLocality: Denver",
106
+ " addressRegion: CO",
107
+ ].join("\n"),
108
+ );
109
+ expect(readSiteContactLines()).toEqual([]);
110
+ });
111
+
112
+ it("a Person with no address gives no locality line (#7791)", () => {
113
+ writeSite(
114
+ [
115
+ "siteName: Wendi Henry",
116
+ "siteIdentity:",
117
+ " kind: Person",
118
+ " name: Wendi Henry",
119
+ " url: https://wendi.test",
120
+ ].join("\n"),
121
+ );
122
+ expect(readSiteContactLines()).toEqual([]);
123
+ });
124
+
125
+ it("an Organization never gives a locality line, even with an address (#7791 sibling kinds)", () => {
126
+ writeSite(
127
+ [
128
+ "siteName: Acme",
129
+ "siteIdentity:",
130
+ " kind: Organization",
131
+ " name: Acme",
132
+ " url: https://acme.test",
133
+ ...addressYaml(" ", "Denver", "CO"),
134
+ ].join("\n"),
135
+ );
136
+ expect(readSiteContactLines()).toEqual([]);
137
+ });
138
+
52
139
  it("an Organization with contactEmail gives one line", () => {
53
140
  writeSite(
54
- ["siteName: Acme", "contactEmail: team@acme.test", "siteIdentity:", " kind: Organization", " name: Acme"].join(
55
- "\n",
56
- ),
141
+ [
142
+ "siteName: Acme",
143
+ "contactEmail: team@acme.test",
144
+ "siteIdentity:",
145
+ " kind: Organization",
146
+ " name: Acme",
147
+ " url: https://acme.test",
148
+ ].join("\n"),
57
149
  );
58
150
  expect(readSiteContactLines()).toEqual([{ label: "team@acme.test", href: "mailto:team@acme.test" }]);
59
151
  });
60
152
 
61
153
  it("neither gives no lines", () => {
62
- writeSite(["siteName: Acme", "siteIdentity:", " kind: Organization", " name: Acme"].join("\n"));
154
+ writeSite(
155
+ ["siteName: Acme", "siteIdentity:", " kind: Organization", " name: Acme", " url: https://acme.test"].join("\n"),
156
+ );
63
157
  expect(readSiteContactLines()).toEqual([]);
64
158
  });
65
159
 
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * The deck's closing-slide contact lines (#7742 D4,
3
3
  * docs/specs/7742-minimalism-b4-chrome-and-system-spec.md), from data the site
4
- * already publishes — no schema change:
4
+ * already publishes:
5
5
  *
6
- * 1. a `LocalBusiness` identity's `address.addressLocality` and
7
- * `address.addressRegion`, joined by ", " ("San Francisco, CA");
6
+ * 1. a `LocalBusiness` or `Person` identity's `address.addressLocality`
7
+ * and `address.addressRegion`, joined by ", " ("San Francisco, CA") —
8
+ * read through the typed `SiteIdentitySchema` (#7824), so an invalid
9
+ * identity or address gives no line rather than a half-trusted one;
8
10
  * 2. the published contact email (`readSiteContactEmail`, the address
9
11
  * `/contact` shows), as a `mailto:` link.
10
12
  *
@@ -17,6 +19,7 @@
17
19
  import { readFileSync } from "node:fs";
18
20
  import { join } from "node:path";
19
21
 
22
+ import { SiteIdentitySchema } from "@working-theory/site-config/schema";
20
23
  import { parse as parseYaml } from "yaml";
21
24
 
22
25
  import { resolveContentRoot } from "../../../lib/site-brand";
@@ -34,17 +37,18 @@ function nonEmpty(value: unknown): string | undefined {
34
37
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
35
38
  }
36
39
 
37
- /** The LocalBusiness locality line, or `undefined` for any other identity. */
40
+ /** The locality line of a LocalBusiness or Person identity, or `undefined`. */
38
41
  function readLocalityLine(): string | undefined {
39
42
  const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
40
43
  const match = FRONTMATTER_RE.exec(raw);
41
44
  if (!match) return undefined;
42
45
  const fm: unknown = parseYaml(match[1] ?? "");
43
46
  if (!fm || typeof fm !== "object") return undefined;
44
- const identity = (fm as { siteIdentity?: { kind?: unknown; address?: unknown } }).siteIdentity;
45
- if (identity?.kind !== "LocalBusiness") return undefined;
46
- const address = identity.address as { addressLocality?: unknown; addressRegion?: unknown } | undefined;
47
- const parts = [nonEmpty(address?.addressLocality), nonEmpty(address?.addressRegion)].filter(
47
+ const parsed = SiteIdentitySchema.safeParse((fm as { siteIdentity?: unknown }).siteIdentity);
48
+ if (!parsed.success) return undefined;
49
+ const identity = parsed.data;
50
+ if (identity.kind !== "LocalBusiness" && identity.kind !== "Person") return undefined;
51
+ const parts = [nonEmpty(identity.address?.addressLocality), nonEmpty(identity.address?.addressRegion)].filter(
48
52
  (part): part is string => part !== undefined,
49
53
  );
50
54
  return parts.length > 0 ? parts.join(", ") : undefined;
@@ -47,18 +47,18 @@ import { NextIntlClientProvider } from "next-intl";
47
47
  import { getMessages, getTranslations } from "next-intl/server";
48
48
  import type { ReactElement, ReactNode } from "react";
49
49
 
50
- import { auth } from "../../auth";
51
50
  import { GoogleAnalytics } from "../../components/google-analytics";
52
51
  import {
53
52
  SignedInNav,
54
53
  type SignedInNavProps,
55
54
  } from "../../components/signed-in-nav";
56
55
  import { isAccountPath, isAuthPath } from "../../lib/auth-routes";
56
+ import { isClosingFooterPath } from "../../lib/closing-footer-path";
57
57
  import { assertEnabledLocale } from "../../lib/locale-guard";
58
58
  import { footerColumns, navItems } from "../../lib/nav-config";
59
59
  import { getOgImageUrl } from "../../lib/og";
60
60
  import { backLinkHref } from "../../lib/profile-back-link";
61
- import { SITE_NAME } from "../../lib/site-brand";
61
+ import { COPYRIGHT_HOLDER, SITE_NAME } from "../../lib/site-brand";
62
62
  import { siteChrome, type SiteChrome } from "../../lib/site-chrome";
63
63
  import { siteLogo } from "../../lib/site-logo";
64
64
  // Registers the `_site.mdx` identity with page-renderer at module load, so
@@ -69,6 +69,7 @@ import "../../lib/site-identity";
69
69
  // (#7521). Inert without AILK_LISTING_PUBLIC_KEY or the paid package.
70
70
  import "../../lib/listings";
71
71
  import { siteThemeHead } from "../../lib/site-theme-head";
72
+ import { SIGN_IN_ENABLED, readSession } from "../../lib/topology.generated";
72
73
  import { resolveBaseUrl } from "../../src/lib/routes";
73
74
 
74
75
  import { readSiteContactLines } from "./contact/site-contact-lines";
@@ -83,6 +84,7 @@ const chrome: SiteChrome & {
83
84
  deckPresentation?: true;
84
85
  colorMode?: "light";
85
86
  chromeStyle?: "minimal";
87
+ closingFooterPaths?: readonly string[];
86
88
  } = siteChrome;
87
89
 
88
90
  // ── Constants ────────────────────────────────────────────────────────────────
@@ -142,6 +144,15 @@ async function resolveThemeMode(): Promise<ThemeMode> {
142
144
  }
143
145
  }
144
146
 
147
+ /**
148
+ * The deck home's first slide (#7782): the element the consent bar waits on.
149
+ * The same section-slide selector `DeckSlideAdvance` walks (its `SLIDES`, minus
150
+ * the closing footer slide), so the first match is slide 1. The story that pins
151
+ * the geometry (`Shells/MarketingShell` → `Deck Consent Bar`) carries a copy.
152
+ */
153
+ const DECK_FIRST_SLIDE_SELECTOR =
154
+ '.ailk-snap-deck .ailk-section-renderer > :not(.ailk-section-separator):not([data-section="separator"])';
155
+
145
156
  /**
146
157
  * Whether this visitor has already accepted or declined cookies (#5557).
147
158
  *
@@ -250,8 +261,10 @@ export default async function LocaleLayout({
250
261
  const isMinimalChrome = chrome.chromeStyle === "minimal";
251
262
  const consentDecided = await hasConsentChoice();
252
263
  // Server-side session read (#4984) — the same `auth.api.getSession` idiom
253
- // the `(authed)` segment guard uses. Null when signed out or auth is unset.
254
- const session = await auth.api.getSession({ headers: await headers() });
264
+ // the `(authed)` segment guard uses, through the generated topology seam
265
+ // (#7426 D5): a spoke has no sign-in, so its render always answers null.
266
+ // Null when signed out or auth is unset.
267
+ const session = await readSession(await headers());
255
268
 
256
269
  // Resolve nav labels server-side so Nav + Footer components stay
257
270
  // i18n-hook-free and are testable without a NextIntlClientProvider wrapper.
@@ -348,11 +361,16 @@ export default async function LocaleLayout({
348
361
  // above).
349
362
  // The signed-out auth pair, shared by the header (every page but the deck
350
363
  // home) and the deck home's closing footer slide (#7718 D4).
351
- const authActions = [
352
- // NO_HAND_IMPLEMENT_OK: operator-directed live Free-template pass.
353
- { labelKey: "signIn", path: "/sign-in", variant: "outline-strong" },
354
- { labelKey: "signUp", path: "/sign-up", variant: "primary" },
355
- ] as const satisfies readonly NavAction[];
364
+ // A spoke (#7426 D11) has no sign-in, so it offers no auth pair anywhere:
365
+ // not in the header, the deck's closing footer or a bare page's footer.
366
+ const authActions: readonly Extract<NavAction, { path: string }>[] =
367
+ SIGN_IN_ENABLED
368
+ ? [
369
+ // NO_HAND_IMPLEMENT_OK: operator-directed live Free-template pass.
370
+ { labelKey: "signIn", path: "/sign-in", variant: "outline-strong" },
371
+ { labelKey: "signUp", path: "/sign-up", variant: "primary" },
372
+ ]
373
+ : [];
356
374
  const navActions: NavAction[] = session
357
375
  ? [
358
376
  { labelKey: "account", kind: "flow", flow: NAV_CLIENT_ACTION_FLOW },
@@ -398,10 +416,12 @@ export default async function LocaleLayout({
398
416
  ? []
399
417
  : session
400
418
  ? [{ label: tNav("account"), href: localeHref(locale, "/account") }]
401
- : [
402
- { label: tNav("signIn"), href: localeHref(locale, "/sign-in") },
403
- { label: tNav("signUp"), href: localeHref(locale, "/sign-up") },
404
- ];
419
+ : SIGN_IN_ENABLED
420
+ ? [
421
+ { label: tNav("signIn"), href: localeHref(locale, "/sign-in") },
422
+ { label: tNav("signUp"), href: localeHref(locale, "/sign-up") },
423
+ ]
424
+ : [];
405
425
 
406
426
  // A flow action needs `renderFlowTrigger`, a function — and this layout is a
407
427
  // Server Component. Nav forwards that function into NavMobileMenu ("use
@@ -434,6 +454,18 @@ export default async function LocaleLayout({
434
454
  // middleware and hreflang alternates read (NEXT_PUBLIC_ENABLED_LOCALES).
435
455
  const enabledLocales = readLocales();
436
456
 
457
+ // #7797 — an inner page the site lists in `closingFooterPaths` ends on the
458
+ // closing footer: the wordmark, the contact lines and the legal line. The
459
+ // home is never an inner page, and an absent or empty list is false for
460
+ // every page, so a site that lists none renders exactly as before.
461
+ const isClosingInner =
462
+ pathname !== homePath &&
463
+ isClosingFooterPath({
464
+ pathname,
465
+ paths: chrome.closingFooterPaths,
466
+ locales: enabledLocales,
467
+ });
468
+
437
469
  // The (auth) route group's chrome branch (#5963).
438
470
  //
439
471
  // A route group's layout nests INSIDE its parent segment's layout, so
@@ -519,7 +551,8 @@ export default async function LocaleLayout({
519
551
 
520
552
  // The site's own name (#6942) — `SITE_NAME` resolves from `_site.mdx`'s
521
553
  // `siteName:`, so a customer's footer never carries the kit vendor's name.
522
- const copyrightText = `© ${new Date().getFullYear()} ${SITE_NAME}. All rights reserved.`;
554
+ // The holder is `siteIdentity.legalName` when declared (#7796), else the name.
555
+ const copyrightText = `© ${new Date().getFullYear()} ${COPYRIGHT_HOLDER}. All rights reserved.`;
523
556
 
524
557
  // Footer legal links (#5612 D3). The centered variant folds in FAQ — the
525
558
  // roster's third always-scaffolded core route (`/faq`, alongside `/privacy`
@@ -740,18 +773,32 @@ export default async function LocaleLayout({
740
773
  // Sign up are on no part of the home.
741
774
  // #7721 D4 — a bare inner page's footer is the one bar, its
742
775
  // link group carrying the account links the header drops.
776
+ // #7797 — a listed inner page takes the same closing footer.
777
+ // Under `innerPages: bare` the one-bar footer is where the
778
+ // account links live (the header drops them), so the closing
779
+ // footer carries the deck's auth actions in their place; on
780
+ // any other inner page the header still holds them.
743
781
  {...(isDeckHome && isMinimalChrome
744
782
  ? { layout: "closing" as const, contactLines: readSiteContactLines() }
745
- : isBareInner
746
- ? { layout: "line" as const, accountLinks }
747
- : siteChrome.footer === "centered"
748
- ? { layout: "centered" as const }
749
- : {})}
783
+ : isClosingInner
784
+ ? {
785
+ layout: "closing" as const,
786
+ contactLines: readSiteContactLines(),
787
+ ...(isBareInner ? { actions: deckFooterActions } : {}),
788
+ }
789
+ : isBareInner
790
+ ? { layout: "line" as const, accountLinks }
791
+ : siteChrome.footer === "centered"
792
+ ? { layout: "centered" as const }
793
+ : {})}
750
794
  // #7718 D4 — the deck home's closing slide carries the
751
795
  // centred brand and the auth links its bare header drops.
752
796
  {...(isDeckHome && !isMinimalChrome
753
797
  ? { centeredBrand: true, actions: deckFooterActions }
754
798
  : {})}
799
+ // #7823 — the minimal closing slide's actions are a button,
800
+ // then a text link; Footer reads the style from this.
801
+ {...(isMinimalChrome ? { chromeStyle: "minimal" as const } : {})}
755
802
  // #7742 D2 — no theme switch under `colorMode: light`.
756
803
  showThemeToggle={!pinLight}
757
804
  bottomBarAlign="content"
@@ -816,6 +863,11 @@ export default async function LocaleLayout({
816
863
  // #7742 D5 — the minimal chrome's consent bar: the page ground,
817
864
  // a hairline, no shadow.
818
865
  {...(isMinimalChrome ? { consentAppearance: "hairline" as const } : {})}
866
+ // #7782 — on the minimal deck home the bar first shows once the
867
+ // first slide has left view; every other page shows it on load.
868
+ {...(isDeckHome && isMinimalChrome
869
+ ? { consentRevealAfter: DECK_FIRST_SLIDE_SELECTOR }
870
+ : {})}
819
871
  />
820
872
  )}
821
873
  <Telemetry />
@@ -11,6 +11,9 @@ import React from "react";
11
11
  import { render, screen } from "@testing-library/react";
12
12
 
13
13
  jest.mock("@working-theory/ui", () => ({
14
+ // #7789: renderPage's fallback page head renders inside the section Container.
15
+ Container: ({ children }: { children?: React.ReactNode }) =>
16
+ React.createElement("div", null, children),
14
17
  Heading: ({ children, level }: { children: React.ReactNode; level: number }) =>
15
18
  React.createElement(`h${level}`, null, children),
16
19
  SectionRenderer: ({
@@ -20,6 +20,9 @@ import { render } from "@testing-library/react";
20
20
 
21
21
  // ── @working-theory/ui mock ─────────────────────────────────────────────────────────────
22
22
  jest.mock("@working-theory/ui", () => ({
23
+ // #7789: renderPage's fallback page head renders inside the section Container.
24
+ Container: ({ children }: { children?: React.ReactNode }) =>
25
+ React.createElement("div", null, children),
23
26
  // #4446/#4447: renderPage's own fallback/empty-state title now composes
24
27
  // Heading (mobile type-budget classes, #4417) instead of a bare <h1>.
25
28
  Heading: ({
@@ -106,7 +109,7 @@ describe("ProjectsPage — snapshot regression", () => {
106
109
  });
107
110
  const { container } = render(element);
108
111
  expect(container.innerHTML).toMatchInlineSnapshot(
109
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Projects","description":"Open-source projects built with AI Launch Kit.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"CreativeWork"}]}}</script><main id="main"><h1>Projects</h1><p data-testid="projects-description">Open-source projects built with AI Launch Kit.</p><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
112
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Projects","description":"Open-source projects built with AI Launch Kit.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"CreativeWork"}]}}</script><main id="main"><div><h1>Projects</h1><p data-testid="projects-description" class="mt-block-tight text-lead">Open-source projects built with AI Launch Kit.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
110
113
  );
111
114
  });
112
115
  });
@@ -130,7 +133,7 @@ describe("ProjectSlugPage — snapshot regression", () => {
130
133
  });
131
134
  const { container } = render(element);
132
135
  expect(container.innerHTML).toMatchInlineSnapshot(
133
- `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AI Launch Kit","description":"The AX-first OSS template.","mainEntity":{"@context":"https://schema.org","@type":"CreativeWork","name":"AI Launch Kit","description":"The AX-first OSS template."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Projects","item":"http://localhost:3000/en/projects"},{"@type":"ListItem","position":3,"name":"AI Launch Kit","item":"http://localhost:3000/en/projects/ailk"}]}]}</script><main id="main"><h1>AI Launch Kit</h1><p data-testid="ailk-description">The AX-first OSS template.</p><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
136
+ `"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AI Launch Kit","description":"The AX-first OSS template.","mainEntity":{"@context":"https://schema.org","@type":"CreativeWork","name":"AI Launch Kit","description":"The AX-first OSS template."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Projects","item":"http://localhost:3000/en/projects"},{"@type":"ListItem","position":3,"name":"AI Launch Kit","item":"http://localhost:3000/en/projects/ailk"}]}]}</script><main id="main"><div><h1>AI Launch Kit</h1><p data-testid="ailk-description" class="mt-block-tight text-lead">The AX-first OSS template.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
134
137
  );
135
138
  });
136
139
 
@@ -17,6 +17,9 @@ jest.mock("next/navigation", () => ({
17
17
  }));
18
18
 
19
19
  jest.mock("@working-theory/ui", () => ({
20
+ // #7789: renderPage's fallback page head renders inside the section Container.
21
+ Container: ({ children }: { children?: React.ReactNode }) =>
22
+ React.createElement("div", null, children),
20
23
  // #4446/#4447: renderPage's own fallback/empty-state title now composes
21
24
  // Heading (mobile type-budget classes, #4417) instead of a bare <h1>.
22
25
  Heading: ({