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.
- package/README.md +21 -1
- package/component-catalog.json +184 -6
- package/component-catalog.md +20 -9
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/programmatic.js +44 -20
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +11 -0
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +123 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +120 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +7 -3
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/CLAUDE.md +26 -1
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +35 -3
- package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +4 -2
- package/templates/apps/web/lib/deck-page.ts +186 -0
- package/templates/apps/web/messages/en.json +16 -5
- package/templates/database/CHANGELOG.md +9 -0
- package/templates/database/__tests__/seed.test.ts +39 -2
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +29 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- 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,
|
|
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
|
-
"
|
|
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(
|
|
111
|
-
expect(flowDefinitionUpsert).
|
|
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
|
+
});
|
|
@@ -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;
|