create-ailk 0.14.0 → 0.16.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 (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -0,0 +1,90 @@
1
+ // signed-out-gate.generated.tsx — what the (authed) group shows a signed-out visitor.
2
+ //
3
+ // GENERATED by create-ailk for this site's topology and sign-in mode (#7894).
4
+ // Do not edit: every scaffold rewrites it. A hub renders its own sign-in —
5
+ // the operator gate, or the account area's member gate; a spoke that signs in
6
+ // through its hub (`--hub-sign-in`) renders the hub-session gate.
7
+ import { localeHref } from "@working-theory/ui";
8
+ import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
9
+ import type { AuthPanelProvider } from "@working-theory/ui/sections";
10
+ import { readLocales } from "@working-theory/web-i18n";
11
+ import { getTranslations } from "next-intl/server";
12
+ import type { ReactElement } from "react";
13
+
14
+ import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
15
+ import { getActiveProviderIds } from "../../../lib/auth-env";
16
+ import { isAccountPath } from "../../../lib/auth-routes";
17
+
18
+ import { SignInGate } from "./sign-in-gate";
19
+
20
+ const SUPPORTED_PROVIDERS: readonly AuthPanelProvider[] = ["google", "github"];
21
+
22
+ export interface SignedOutGateProps {
23
+ locale: string;
24
+ /** The incoming request's headers (`x-pathname` names the requested page). */
25
+ requestHeaders: Headers;
26
+ }
27
+
28
+ export async function SignedOutGate({
29
+ locale,
30
+ requestHeaders,
31
+ }: SignedOutGateProps): Promise<ReactElement> {
32
+ // No `x-pathname`, no locale read: a render outside the middleware keeps
33
+ // the operator gate without touching the locale config.
34
+ const pathname = requestHeaders.get("x-pathname");
35
+ const isAccount =
36
+ pathname !== null && isAccountPath(pathname, readLocales());
37
+ const t = await getTranslations({
38
+ locale,
39
+ namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
40
+ });
41
+ const active = getActiveProviderIds();
42
+ const emailLink = active.includes("magic-link")
43
+ ? { text: await resolveSignInCardText(locale) }
44
+ : undefined;
45
+ const providers = active.filter((id): id is AuthPanelProvider =>
46
+ (SUPPORTED_PROVIDERS as readonly string[]).includes(id),
47
+ );
48
+ const signInPath = localeHref(locale, "/sign-in");
49
+
50
+ if (isAccount && providers.length === 0 && !emailLink) {
51
+ return (
52
+ <div
53
+ className="flex min-h-screen flex-col items-center justify-center gap-4 py-section text-center"
54
+ data-testid="account-sign-in-gate"
55
+ >
56
+ <Heading level={1} visual={4}>
57
+ {t("emptyHeadline")}
58
+ </Heading>
59
+ <Text variant="body-sm" tone="muted" className="max-w-xs">
60
+ {t("emptyDescription")}
61
+ </Text>
62
+ {/* With no sign-in method on, /sign-in has nothing for a member
63
+ either, so the one action goes back to the site. Outline
64
+ action, 44px on a phone and 36px from md up. */}
65
+ <Button asChild variant="secondary" className="max-md:min-h-11">
66
+ <Link
67
+ href={localeHref(locale, "/")}
68
+ variant="bare"
69
+ data-testid="account-sign-in-gate-home"
70
+ >
71
+ {t("homeLink")}
72
+ </Link>
73
+ </Button>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ return (
79
+ <SignInGate
80
+ labels={{
81
+ emptyHeadline: t("emptyHeadline"),
82
+ emptyDescription: t("emptyDescription"),
83
+ signInLinkLabel: t("signInLink"),
84
+ }}
85
+ signInPath={signInPath}
86
+ providers={providers}
87
+ {...(emailLink ? { emailLink } : {})}
88
+ />
89
+ );
90
+ }
@@ -51,6 +51,9 @@ jest.mock("../../../../../lib/paid-sections.generated", () => {
51
51
  });
52
52
 
53
53
  jest.mock("@working-theory/ui", () => ({
54
+ // #7789: renderPage's fallback page head renders inside the section Container.
55
+ Container: ({ children }: { children?: React.ReactNode }) =>
56
+ React.createElement("div", null, children),
54
57
  // #7521 — the rest of the real barrel, so the listing extension finds
55
58
  // `localeHref`; the two below still override.
56
59
  ...jest.requireActual("@working-theory/ui"),
@@ -24,6 +24,9 @@ jest.mock("next/navigation", () => ({
24
24
  }));
25
25
 
26
26
  jest.mock("@working-theory/ui", () => ({
27
+ // #7789: renderPage's fallback page head renders inside the section Container.
28
+ Container: ({ children }: { children?: React.ReactNode }) =>
29
+ React.createElement("div", null, children),
27
30
  Heading: ({
28
31
  children,
29
32
  level,
@@ -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;