create-ailk 0.20.0 → 0.21.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 (83) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +184 -6
  3. package/component-catalog.md +20 -9
  4. package/dist/apply-design.d.ts +9 -0
  5. package/dist/apply-design.js +78 -31
  6. package/dist/design-home.d.ts +54 -11
  7. package/dist/design-home.js +160 -22
  8. package/dist/lib/design-checks.d.ts +6 -1
  9. package/dist/lib/design-checks.js +23 -2
  10. package/dist/programmatic.js +44 -20
  11. package/dist/site-roster.d.ts +4 -2
  12. package/dist/site-roster.js +7 -5
  13. package/dist/starter-grid.js +16 -0
  14. package/dist/surfaces.js +11 -0
  15. package/dist/sync-routes.d.ts +28 -0
  16. package/dist/sync-routes.js +123 -1
  17. package/dist/update.d.ts +6 -1
  18. package/dist/update.js +120 -18
  19. package/package.json +3 -3
  20. package/site-templates/_base/ailk-template.json +2 -2
  21. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  22. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  23. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  24. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  25. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  26. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  27. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  28. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  29. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  30. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  31. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  32. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  33. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  34. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  35. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  36. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  37. package/templates/README.md +26 -25
  38. package/templates/apps/api/CLAUDE.md +7 -3
  39. package/templates/apps/api/jest.config.cjs +1 -0
  40. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  41. package/templates/apps/api/src/services/project-listings.ts +6 -1
  42. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  43. package/templates/apps/mcp/jest.config.cjs +1 -0
  44. package/templates/apps/web/CLAUDE.md +26 -1
  45. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  46. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  47. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  48. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  49. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  50. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
  51. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  52. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  53. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
  54. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
  55. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
  56. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  57. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  58. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +35 -3
  59. package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
  60. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  61. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  62. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  63. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  64. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  65. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  66. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  68. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  69. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  70. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  71. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  72. package/templates/apps/web/jest.config.cjs +1 -0
  73. package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
  74. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +4 -2
  75. package/templates/apps/web/lib/deck-page.ts +186 -0
  76. package/templates/apps/web/messages/en.json +16 -5
  77. package/templates/database/CHANGELOG.md +9 -0
  78. package/templates/database/__tests__/seed.test.ts +39 -2
  79. package/templates/database/jest.config.cjs +1 -0
  80. package/templates/database/package.json +1 -1
  81. package/templates/database/seed.ts +29 -0
  82. package/templates/tools/jest/pin-timezone.cjs +34 -0
  83. package/versions.json +34 -34
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The deck-page resolver (#8687 D3, AC-2).
3
+ *
4
+ * `deckCandidates` and the `DECK_ROUTES` parity are pure functions over the
5
+ * real routes map. `resolveDeckPage` and `inputsFor` (captured off the
6
+ * `setDeckPages` registration call) are exercised with `getMdxSource` and
7
+ * `next/headers` mocked, in `renderPage-extension.test.tsx`'s idiom.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+
12
+ import { ROUTES } from "@working-theory/templates/routes";
13
+
14
+ const mockGetMdxSource = jest.fn();
15
+ const mockSetDeckPages = jest.fn();
16
+
17
+ jest.mock("@working-theory/page-renderer", () => {
18
+ const actual = jest.requireActual("@working-theory/page-renderer");
19
+ return {
20
+ ...actual,
21
+ getMdxSource: (...args: unknown[]) => mockGetMdxSource(...args),
22
+ setDeckPages: (...args: unknown[]) => mockSetDeckPages(...args),
23
+ };
24
+ });
25
+
26
+ const mockHeaders = jest.fn();
27
+ jest.mock("next/headers", () => ({ headers: () => mockHeaders() }));
28
+
29
+ const savedLocales = process.env["NEXT_PUBLIC_ENABLED_LOCALES"];
30
+
31
+ beforeAll(() => {
32
+ process.env["NEXT_PUBLIC_ENABLED_LOCALES"] = "en,es";
33
+ });
34
+ afterAll(() => {
35
+ if (savedLocales === undefined) delete process.env["NEXT_PUBLIC_ENABLED_LOCALES"];
36
+ else process.env["NEXT_PUBLIC_ENABLED_LOCALES"] = savedLocales;
37
+ });
38
+
39
+ import { DECK_ROUTES, deckCandidates, resolveDeckPage } from "../deck-page";
40
+ import { siteChrome } from "../site-chrome";
41
+
42
+ describe("deckCandidates (#8687 D3, AC-2)", () => {
43
+ const LOCALES = ["en", "es"];
44
+
45
+ it("/pricing resolves the route row, then the [slug] fallback", () => {
46
+ expect(deckCandidates("/pricing", LOCALES)).toEqual([
47
+ { type: "Pricing", slug: "pricing" },
48
+ { type: "LandingPage", slug: "pricing" },
49
+ { type: "BlogList", slug: "pricing" },
50
+ ]);
51
+ });
52
+
53
+ it("/about (core, always scaffolded) resolves the route row only", () => {
54
+ expect(deckCandidates("/about", LOCALES)).toEqual([
55
+ { type: "AboutPage", slug: "about" },
56
+ ]);
57
+ });
58
+
59
+ it("/es/offer/ (no route match) falls through to [slug] alone", () => {
60
+ expect(deckCandidates("/es/offer/", LOCALES)).toEqual([
61
+ { type: "LandingPage", slug: "offer" },
62
+ { type: "BlogList", slug: "offer" },
63
+ ]);
64
+ });
65
+
66
+ it("/projects/apollo resolves the detail row's directory type", () => {
67
+ expect(deckCandidates("/projects/apollo", LOCALES)).toEqual([
68
+ { type: "Project", slug: "apollo" },
69
+ ]);
70
+ });
71
+
72
+ it("/careers (gateable, no DECK_ROUTES entry) falls through to [slug]", () => {
73
+ expect(deckCandidates("/careers", LOCALES)).toEqual([
74
+ { type: "LandingPage", slug: "careers" },
75
+ { type: "BlogList", slug: "careers" },
76
+ ]);
77
+ });
78
+
79
+ it.each(["/", "/software", "/sign-in", "/blog/post", "/edit/LandingPage/x", "/a/b"])(
80
+ "%s resolves no candidates",
81
+ (pathname) => {
82
+ expect(deckCandidates(pathname, LOCALES)).toEqual([]);
83
+ },
84
+ );
85
+ });
86
+
87
+ describe("DECK_ROUTES parity (#8687 D3)", () => {
88
+ it("contains every routes-map row with seed content whose page.tsx renders sections, minus /", () => {
89
+ // DECK_ROUTES is a fixed superset, not a per-scaffold exact set: a shape
90
+ // that doesn't ship one of these routes (e.g. a Free scaffold without
91
+ // /alternatives) simply has no page.tsx there, so deckCandidates/
92
+ // resolveDeckPage never get a request for it (harmless). What would be a
93
+ // real bug is the OTHER direction — an actually-shipped sections route
94
+ // this build's own tree carries that DECK_ROUTES fails to name.
95
+ const appRoot = path.join(__dirname, "..", "..", "app", "[locale]");
96
+ const sectionsRoutes = new Set<string>();
97
+ for (const row of ROUTES) {
98
+ if (row.route === "/" || !row.contentPath?.startsWith("content/en/")) continue;
99
+ const dir = row.route
100
+ .split("/")
101
+ .filter(Boolean)
102
+ .map((segment) => (segment.startsWith(":") ? "[slug]" : segment))
103
+ .join("/");
104
+ let pageSource: string;
105
+ try {
106
+ pageSource = readFileSync(path.join(appRoot, dir, "page.tsx"), "utf8");
107
+ } catch {
108
+ continue;
109
+ }
110
+ if (!pageSource.includes('bodyMode: "mdx-body"')) {
111
+ sectionsRoutes.add(row.route);
112
+ }
113
+ }
114
+ const deckRoutesSet = new Set<string>(DECK_ROUTES);
115
+ const missing = [...sectionsRoutes].filter((route) => !deckRoutesSet.has(route));
116
+ expect(missing).toEqual([]);
117
+ });
118
+ });
119
+
120
+ describe("resolveDeckPage (#8687 D3, AC-2)", () => {
121
+ beforeEach(() => {
122
+ mockGetMdxSource.mockReset();
123
+ });
124
+
125
+ it("returns the first candidate with a source", async () => {
126
+ mockGetMdxSource.mockImplementation((type: string) =>
127
+ Promise.resolve(
128
+ type === "LandingPage"
129
+ ? null
130
+ : type === "BlogList"
131
+ ? { frontmatter: { type: "BlogList", slug: "offer" }, body: "" }
132
+ : null,
133
+ ),
134
+ );
135
+
136
+ const result = await resolveDeckPage("/es/offer/", "en");
137
+
138
+ expect(result).toEqual({
139
+ type: "BlogList",
140
+ slug: "offer",
141
+ frontmatter: { type: "BlogList", slug: "offer" },
142
+ });
143
+ });
144
+
145
+ it("returns null when a read throws", async () => {
146
+ mockGetMdxSource.mockRejectedValue(new Error("read failed"));
147
+
148
+ expect(await resolveDeckPage("/pricing", "en")).toBeNull();
149
+ });
150
+
151
+ it("returns null for a path with no candidates, with zero getMdxSource calls (NFR 2)", async () => {
152
+ expect(await resolveDeckPage("/software", "en")).toBeNull();
153
+ expect(mockGetMdxSource).not.toHaveBeenCalled();
154
+ });
155
+ });
156
+
157
+ describe("the registered inputsFor (#8687 D3, AC-2)", () => {
158
+ beforeEach(() => {
159
+ mockGetMdxSource.mockReset();
160
+ mockHeaders.mockReset();
161
+ });
162
+
163
+ function registeredInputsFor(): (ctx: {
164
+ type: string;
165
+ slug: string;
166
+ locale: string;
167
+ }) => Promise<unknown> {
168
+ const call = mockSetDeckPages.mock.calls[mockSetDeckPages.mock.calls.length - 1];
169
+ return (
170
+ call?.[0] as {
171
+ inputsFor: (ctx: { type: string; slug: string; locale: string }) => Promise<unknown>;
172
+ }
173
+ ).inputsFor;
174
+ }
175
+
176
+ it("resolves the design's inputs when the resolved page matches and opts in", async () => {
177
+ mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
178
+ mockGetMdxSource.mockImplementation((type: string) =>
179
+ Promise.resolve(
180
+ type === "Pricing"
181
+ ? null
182
+ : type === "LandingPage"
183
+ ? { frontmatter: { type: "LandingPage", slug: "pricing", layout: "deck" }, body: "" }
184
+ : null,
185
+ ),
186
+ );
187
+
188
+ const result = await registeredInputsFor()({
189
+ type: "LandingPage",
190
+ slug: "pricing",
191
+ locale: "en",
192
+ });
193
+
194
+ // The scaffold's generated site-chrome carries the DESIGN's values — minimalism
195
+ // presents the deck (deckPresentation true), standard does not — so the expected
196
+ // inputs are read from it rather than hard-coded to one design.
197
+ const chrome = siteChrome as typeof siteChrome & {
198
+ deckGrounds?: unknown;
199
+ deckPresentation?: true;
200
+ };
201
+ expect(result).toEqual({
202
+ ...(chrome.deckGrounds !== undefined ? { deckGrounds: chrome.deckGrounds } : {}),
203
+ deckPresentation: chrome.deckPresentation === true,
204
+ });
205
+ });
206
+
207
+ it("returns null when the resolved page does not match this render", async () => {
208
+ mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
209
+ mockGetMdxSource.mockResolvedValue(
210
+ { frontmatter: { type: "Pricing", slug: "pricing" }, body: "" },
211
+ );
212
+
213
+ const result = await registeredInputsFor()({
214
+ type: "Pricing",
215
+ slug: "pricing",
216
+ locale: "en",
217
+ });
218
+
219
+ expect(result).toBeNull();
220
+ });
221
+
222
+ it("returns null for a resolved page without the field", async () => {
223
+ mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
224
+ mockGetMdxSource.mockImplementation((type: string) =>
225
+ Promise.resolve(
226
+ type === "LandingPage"
227
+ ? { frontmatter: { type: "LandingPage", slug: "pricing" }, body: "" }
228
+ : null,
229
+ ),
230
+ );
231
+
232
+ expect(
233
+ await registeredInputsFor()({ type: "LandingPage", slug: "pricing", locale: "en" }),
234
+ ).toBeNull();
235
+ });
236
+
237
+ it("returns null with no x-pathname header", async () => {
238
+ mockHeaders.mockResolvedValue(new Map());
239
+
240
+ expect(
241
+ await registeredInputsFor()({ type: "LandingPage", slug: "pricing", locale: "en" }),
242
+ ).toBeNull();
243
+ });
244
+ });
@@ -240,10 +240,12 @@ describe("structured data — every content page builds valid JSON-LD", () => {
240
240
  "content/$locale/$typeDir — every page validates as its type",
241
241
  async ({ locale, typeDir }) => {
242
242
  if (!(SCHEMA_ORG_TYPES as readonly string[]).includes(typeDir)) {
243
- // Identity + sub-entity types (Organization, Person, BreadcrumbList,
243
+ // Identity + sub-entity types (Organization, BreadcrumbList,
244
244
  // ContactPoint) hold sub-objects other pages' builders embed — never
245
245
  // rendered as pages, and skipped by tools/content-compile the same
246
- // way. Anything else is a directory nothing will ever render.
246
+ // way. Anything else is a directory nothing will ever render. Person
247
+ // (#8650) is a registered page type (`/about/<slug>`), so it is
248
+ // covered by the SCHEMA_ORG_TYPES branch above, not this one.
247
249
  if (!knownSchemaOrgTypes.has(typeDir)) {
248
250
  throw new Error(
249
251
  `content/${locale}/${typeDir}: "${typeDir}" is not a registered page type — no route or builder will ever render it (see packages/content-adapters/src/types.ts SCHEMA_ORG_TYPES)`,
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The deck-page resolver (#8687 D3) — the layout's question ("does the page
3
+ * at this path opt in?") and `renderPage`'s consult both ask this ONE
4
+ * resolver, so they always agree. Registers with `@working-theory/page-renderer`
5
+ * at module load, in `lib/site-identity.ts`'s precedent: `app/[locale]/layout.tsx`
6
+ * imports this module, and Next.js evaluates a layout module before any page
7
+ * under it renders.
8
+ */
9
+ import {
10
+ getMdxSource as defaultGetMdxSource,
11
+ optsIntoDeck,
12
+ setDeckPages,
13
+ type DeckPageContext,
14
+ type DeckPageInputs,
15
+ type SchemaOrgType,
16
+ } from "@working-theory/page-renderer";
17
+ import { ROUTES, type RouteRow } from "@working-theory/templates/routes";
18
+ import { readLocales } from "@working-theory/web-i18n";
19
+ import { headers } from "next/headers";
20
+ import { cache } from "react";
21
+
22
+ import { normalizeRoutePath } from "./closing-footer-path";
23
+ import { siteChrome, type SiteChrome } from "./site-chrome";
24
+
25
+ /**
26
+ * The routes besides `[slug]` whose page can present as a deck: every
27
+ * routes-map row with seed content whose route renders sections, minus `/`.
28
+ *
29
+ * This is a fixed superset across every shape/level a scaffold can ship,
30
+ * not a per-site exact list: a shape that doesn't carry one of these routes
31
+ * (e.g. a Free scaffold without `/alternatives`) has no `page.tsx` there, so
32
+ * `deckCandidates`/`resolveDeckPage` never get a request for that path —
33
+ * harmless. `apps/web/lib/__tests__/deck-page.test.ts`'s parity case checks
34
+ * the direction that would be a real bug: that this list names every
35
+ * sections route THIS build's own tree actually carries.
36
+ */
37
+ export const DECK_ROUTES = [
38
+ "/about",
39
+ "/alternatives",
40
+ "/contact",
41
+ "/faq",
42
+ "/launch-service",
43
+ "/pricing",
44
+ "/privacy",
45
+ "/projects",
46
+ "/projects/:slug",
47
+ "/purchase-policy",
48
+ "/reports",
49
+ "/terms",
50
+ "/thanks/:slug",
51
+ "/working-agreement",
52
+ ] as const;
53
+
54
+ /** The widened type (#7719 D3, #7740 D8): present only when the design sets it. */
55
+ type Chrome = SiteChrome & {
56
+ deckGrounds?: DeckPageInputs["deckGrounds"];
57
+ deckPresentation?: true;
58
+ };
59
+ const chrome: Chrome = siteChrome;
60
+
61
+ function routeSegments(route: string): string[] {
62
+ return route.split("/").filter((segment) => segment.length > 0);
63
+ }
64
+
65
+ function matchesRoute(routeDef: string, pathSegments: readonly string[]): boolean {
66
+ const defSegments = routeSegments(routeDef);
67
+ if (defSegments.length !== pathSegments.length) return false;
68
+ return defSegments.every(
69
+ (segment, i) => segment.startsWith(":") || segment === pathSegments[i],
70
+ );
71
+ }
72
+
73
+ /** The type and slug a matched routes-map row resolves to for this request. */
74
+ function resolveTypeAndSlug(
75
+ row: RouteRow,
76
+ routeDef: string,
77
+ pathSegments: readonly string[],
78
+ ): { type: SchemaOrgType; slug: string } | null {
79
+ if (row.contentPath === undefined) return null;
80
+ const parts = row.contentPath.split("/");
81
+ const last = parts[parts.length - 1];
82
+ if (last === undefined) return null;
83
+ const isFile = last.endsWith(".mdx");
84
+ const type = isFile ? parts[parts.length - 2] : last;
85
+ if (type === undefined) return null;
86
+ const slug = isFile
87
+ ? last.replace(/\.mdx$/, "")
88
+ : pathSegments[routeSegments(routeDef).findIndex((segment) => segment.startsWith(":"))];
89
+ if (slug === undefined) return null;
90
+ return { type: type as SchemaOrgType, slug };
91
+ }
92
+
93
+ /** Whether a routes-map row at this exact route is always scaffolded (never absent from a site). */
94
+ function isAlwaysScaffolded(path: string): boolean {
95
+ const row = ROUTES.find((candidate) => candidate.route === path);
96
+ return row !== undefined && row.gateable !== true && row.module === "marketing";
97
+ }
98
+
99
+ /**
100
+ * The content pages that could serve `pathname`, in the order Next.js tries
101
+ * them (D3). `/` gives none — the home's deck is its own `layout.home` knob,
102
+ * never this resolver's.
103
+ */
104
+ export function deckCandidates(
105
+ pathname: string,
106
+ locales: readonly string[],
107
+ ): Array<{ type: SchemaOrgType; slug: string }> {
108
+ const path = normalizeRoutePath(pathname, locales);
109
+ if (path === "/") return [];
110
+ const segments = routeSegments(path);
111
+ const candidates: Array<{ type: SchemaOrgType; slug: string }> = [];
112
+
113
+ for (const routeDef of DECK_ROUTES) {
114
+ if (!matchesRoute(routeDef, segments)) continue;
115
+ const row = ROUTES.find((candidate) => candidate.route === routeDef);
116
+ const resolved = row && resolveTypeAndSlug(row, routeDef, segments);
117
+ if (resolved) candidates.push(resolved);
118
+ break;
119
+ }
120
+
121
+ // A one-segment path not always scaffolded falls through to `[slug]`,
122
+ // which serves `LandingPage` then `BlogList` (D3).
123
+ if (segments.length === 1 && !isAlwaysScaffolded(path)) {
124
+ const slug = segments[0];
125
+ if (slug !== undefined) {
126
+ candidates.push({ type: "LandingPage", slug });
127
+ candidates.push({ type: "BlogList", slug });
128
+ }
129
+ }
130
+
131
+ return candidates;
132
+ }
133
+
134
+ /**
135
+ * The first candidate with a source, and its frontmatter, or `null`. A read
136
+ * that throws ends the walk as `null` (NFR 3) — the page's own render
137
+ * surfaces that error, and the layout keeps its plain shell. Per-request
138
+ * memoised (React `cache`) — the layout and `renderPage`'s consult ask the
139
+ * same question for the same request and must share one answer.
140
+ */
141
+ export const resolveDeckPage = cache(async function resolveDeckPage(
142
+ pathname: string,
143
+ locale: string,
144
+ ): Promise<{ type: SchemaOrgType; slug: string; frontmatter: Record<string, unknown> } | null> {
145
+ for (const candidate of deckCandidates(pathname, readLocales())) {
146
+ let source;
147
+ try {
148
+ source = await defaultGetMdxSource(candidate.type, candidate.slug, locale);
149
+ } catch {
150
+ return null;
151
+ }
152
+ if (source) return { ...candidate, frontmatter: source.frontmatter };
153
+ }
154
+ return null;
155
+ });
156
+
157
+ /** The layout's question: does the page at this path opt in? */
158
+ export async function pageOptsIntoDeck(input: {
159
+ pathname: string;
160
+ locale: string;
161
+ }): Promise<boolean> {
162
+ const resolved = await resolveDeckPage(input.pathname, input.locale);
163
+ return resolved !== null && optsIntoDeck(resolved.frontmatter);
164
+ }
165
+
166
+ /** `inputsFor`: only when this render IS the page the request path resolves to and it opts in. */
167
+ async function inputsFor(ctx: DeckPageContext): Promise<DeckPageInputs | null> {
168
+ const headerStore = await headers();
169
+ const pathname = headerStore.get("x-pathname") ?? null;
170
+ if (pathname === null) return null;
171
+ const resolved = await resolveDeckPage(pathname, ctx.locale);
172
+ if (
173
+ resolved === null ||
174
+ resolved.type !== ctx.type ||
175
+ resolved.slug !== ctx.slug ||
176
+ !optsIntoDeck(resolved.frontmatter)
177
+ ) {
178
+ return null;
179
+ }
180
+ return {
181
+ ...(chrome.deckGrounds !== undefined ? { deckGrounds: chrome.deckGrounds } : {}),
182
+ deckPresentation: chrome.deckPresentation === true,
183
+ };
184
+ }
185
+
186
+ setDeckPages({ inputsFor });
@@ -73,12 +73,19 @@
73
73
  },
74
74
  "about": {
75
75
  "heading": "About",
76
- "emptyState": "About page content coming soon."
76
+ "emptyState": "About page content coming soon.",
77
+ "viewProfile": "View profile"
77
78
  },
78
79
  "blog": {
79
80
  "heading": "Blog",
80
81
  "emptyState": "No posts yet. Drop MDX files into",
81
- "emptyStateTrailing": "to populate this list."
82
+ "emptyStateTrailing": "to populate this list.",
83
+ "allTopics": "All",
84
+ "topicsNav": "Blog topics",
85
+ "latestPosts": "Latest posts",
86
+ "previousPosts": "Previous posts",
87
+ "readMore": "Read more",
88
+ "sidebar": "More from this site"
82
89
  },
83
90
  "docs": {
84
91
  "heading": "Docs",
@@ -118,7 +125,8 @@
118
125
  "sending": "Sending…",
119
126
  "sendMessage": "Send message",
120
127
  "successMessage": "Thank you for reaching out! We will be in touch shortly.",
121
- "consentStatement": "I accept the [Terms](terms).",
128
+ "consentStatement": "I accept the [Terms](terms), [Privacy Policy](privacy), and [Working Agreement](working_agreement).",
129
+ "consentRequired": "Please accept the terms to send your message.",
122
130
  "genericError": "Something went wrong, please try again.",
123
131
  "unavailableHeading": "Get in touch",
124
132
  "unavailableBody": "Email us at {email} and we'll reply.",
@@ -317,9 +325,8 @@
317
325
  "emptyState": "Resources listing coming soon."
318
326
  },
319
327
  "schedule": {
320
- "title": "Book a call",
328
+ "heading": "Book time with me",
321
329
  "description": "Pick a time that works for you.",
322
- "eyebrow": "Book a call",
323
330
  "headline": "Pick a time that works",
324
331
  "confirmed": {
325
332
  "title": "You're booked",
@@ -352,6 +359,10 @@
352
359
  "heading": "Terms of Service",
353
360
  "emptyState": "Terms of service content coming soon."
354
361
  },
362
+ "workingAgreement": {
363
+ "heading": "Working Agreement",
364
+ "emptyState": "Working agreement content coming soon."
365
+ },
355
366
  "testimonials": {
356
367
  "heading": "Testimonials",
357
368
  "emptyState": "Testimonials coming soon."
@@ -1,5 +1,14 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.42.0
4
+
5
+ ### Patch Changes
6
+
7
+ - [#8740](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8740) [`d1461bf`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d1461bff10a69aa4cf05627e393bd1902c358b00) Thanks [@samuelhenry](https://github.com/samuelhenry)! - The database seed writes the default workspace's Contact form magnet ([#8661](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8661)): one live contact-kind magnet at slug `contact`, insert-only, so `/contact` reads it on a freshly seeded site.
8
+
9
+ - Updated dependencies [[`cb2f3a5`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/cb2f3a50c38fe77b92aa52d1462d3d11a5981981), [`425e253`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/425e2538dfd22dfb6f0b6439d473fa2aafcbb883), [`da9253b`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/da9253bb6f8c792e7e75cfe1c1a7bba5ca569e26), [`df1b8e4`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/df1b8e485f9cdd1885925430b7aaa186ffdf6ac1), [`d4e0598`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d4e0598c7659d9349bd56ba184e4ab941e3fde62)]:
10
+ - @working-theory/validation@0.42.0
11
+
3
12
  ## 0.41.0
4
13
 
5
14
  ### Minor Changes
@@ -11,6 +11,8 @@
11
11
  */
12
12
 
13
13
  import {
14
+ CONTACT_FORM_FLOW_SLUG,
15
+ contactFormFlowConfig,
14
16
  REQUEST_QUOTE_FLOW_SLUG,
15
17
  REQUEST_QUOTE_FLOW_TITLE,
16
18
  requestQuoteFlowConfig,
@@ -107,8 +109,8 @@ describe("provisionDefaultWorkspace — the request-quote magnet (#8168 D8, C4,
107
109
 
108
110
  await provisionDefaultWorkspace(prisma);
109
111
 
110
- expect(flowDefinitionUpsert).toHaveBeenCalledTimes(1);
111
- expect(flowDefinitionUpsert).toHaveBeenCalledWith({
112
+ expect(flowDefinitionUpsert).toHaveBeenCalledTimes(2);
113
+ expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(1, {
112
114
  where: {
113
115
  siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: REQUEST_QUOTE_FLOW_SLUG },
114
116
  },
@@ -138,3 +140,38 @@ describe("provisionDefaultWorkspace — the request-quote magnet (#8168 D8, C4,
138
140
  expect(flowDefinitionUpsert).not.toHaveBeenCalled();
139
141
  });
140
142
  });
143
+
144
+ describe("provisionDefaultWorkspace — the Contact form magnet (#8661 D3, AC-1)", () => {
145
+ it("upserts the default workspace's live Contact form, insert-if-absent", async () => {
146
+ const { prisma, flowDefinitionUpsert } = makeStubPrisma(0);
147
+
148
+ await provisionDefaultWorkspace(prisma);
149
+
150
+ expect(flowDefinitionUpsert).toHaveBeenNthCalledWith(2, {
151
+ where: {
152
+ siteId_slug: { siteId: DEFAULT_WORKSPACE_ID, slug: CONTACT_FORM_FLOW_SLUG },
153
+ },
154
+ create: {
155
+ siteId: DEFAULT_WORKSPACE_ID,
156
+ workspaceId: DEFAULT_WORKSPACE_ID,
157
+ slug: "contact",
158
+ config: contactFormFlowConfig,
159
+ kind: "contact",
160
+ title: "Contact form",
161
+ status: "live",
162
+ customersEnabled: false,
163
+ landingPage: "/contact",
164
+ },
165
+ // Empty: a re-run never overwrites the owner's edits.
166
+ update: {},
167
+ });
168
+ });
169
+
170
+ it("makes no flowDefinition call when a workspace already exists", async () => {
171
+ const { prisma, flowDefinitionUpsert } = makeStubPrisma(1);
172
+
173
+ await provisionDefaultWorkspace(prisma);
174
+
175
+ expect(flowDefinitionUpsert).not.toHaveBeenCalled();
176
+ });
177
+ });
@@ -1,5 +1,6 @@
1
1
  module.exports = {
2
2
  preset: "ts-jest",
3
+ globalSetup: require.resolve("../tools/jest/pin-timezone.cjs"),
3
4
  testEnvironment: "node",
4
5
  roots: ["<rootDir>"],
5
6
  testMatch: ["**/__tests__/**/*.test.ts"],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@working-theory/database",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -26,6 +26,10 @@ import "dotenv/config";
26
26
  import { basename } from "node:path";
27
27
 
28
28
  import {
29
+ CONTACT_FORM_FLOW_SLUG,
30
+ CONTACT_FORM_FLOW_TITLE,
31
+ CONTACT_FORM_LANDING_PAGE,
32
+ contactFormFlowConfig,
29
33
  REQUEST_QUOTE_FLOW_SLUG,
30
34
  REQUEST_QUOTE_FLOW_TITLE,
31
35
  requestQuoteFlowConfig,
@@ -74,6 +78,10 @@ export type DefaultWorkspacePrismaClient = Pick<
74
78
  * backfill finds no workspace and seeds nothing; this is where the default
75
79
  * workspace's magnet comes from. Insert-if-absent: an `upsert` with an empty
76
80
  * `update`, so an existing `(site_id, slug)` row is never overwritten.
81
+ *
82
+ * #8661 D3 — and its live Contact form magnet, the same way. An existing
83
+ * deployment (a workspace already present) gets its row from the seed-presets
84
+ * CLI instead.
77
85
  */
78
86
  export async function provisionDefaultWorkspace(
79
87
  prisma: DefaultWorkspacePrismaClient,
@@ -129,6 +137,27 @@ export async function provisionDefaultWorkspace(
129
137
  update: {},
130
138
  });
131
139
 
140
+ await prisma.flowDefinition.upsert({
141
+ where: {
142
+ siteId_slug: {
143
+ siteId: DEFAULT_WORKSPACE_ID,
144
+ slug: CONTACT_FORM_FLOW_SLUG,
145
+ },
146
+ },
147
+ create: {
148
+ siteId: DEFAULT_WORKSPACE_ID,
149
+ workspaceId: DEFAULT_WORKSPACE_ID,
150
+ slug: CONTACT_FORM_FLOW_SLUG,
151
+ config: contactFormFlowConfig,
152
+ kind: "contact",
153
+ title: CONTACT_FORM_FLOW_TITLE,
154
+ status: "live",
155
+ customersEnabled: false,
156
+ landingPage: CONTACT_FORM_LANDING_PAGE,
157
+ },
158
+ update: {},
159
+ });
160
+
132
161
  return { created: true };
133
162
  }
134
163
 
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ /* global process */
3
+
4
+ /**
5
+ * Shared Jest `globalSetup` — pins every jest run in the workspace to one
6
+ * timezone (#8657).
7
+ *
8
+ * Jest otherwise inherits the developer's `TZ`, so a test that renders a date
9
+ * in local time (`formatDate`, `toLocaleDateString`, `getDate()`) passes in CI,
10
+ * which runs in UTC, and fails on a laptop that does not: the #8578 shape,
11
+ * where UTC-midnight consent fixtures rendered the previous day on an MDT
12
+ * machine and failed 11 release-check rows.
13
+ *
14
+ * WHY `globalSetup` AND NOT `setupFiles`: a setup file runs inside the test
15
+ * sandbox, whose `process.env` is jest's copy of the real one, so assigning
16
+ * `TZ` there never reaches the process clock. `globalSetup` runs in jest's
17
+ * parent process before any worker is spawned: the workers inherit the pinned
18
+ * `TZ`, and Node re-reads an assigned `process.env.TZ` at once, so an in-band
19
+ * run (`--runInBand`, a single test file) is pinned in place too.
20
+ *
21
+ * Every committed `jest.config.cjs` loads this file; the guard is
22
+ * `tests/architecture/jest-timezone-pin.test.ts`, and the value is documented
23
+ * in CONVENTIONS.md § Testing. A test that needs another zone formats with an
24
+ * explicit `timeZone` option rather than changing `TZ`. The create-ailk
25
+ * scaffold ships this file at the same path (`SNAPSHOT_FILES` in
26
+ * packages/create-ailk/scripts/build-templates.mjs), so its copied configs
27
+ * load it unchanged.
28
+ */
29
+ const PINNED_TZ = "UTC";
30
+
31
+ module.exports = async function pinTimezone() {
32
+ process.env.TZ = PINNED_TZ;
33
+ };
34
+ module.exports.PINNED_TZ = PINNED_TZ;