create-ailk 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.json +3 -3
- package/dist/add-module.d.ts +7 -0
- package/dist/add-module.js +34 -2
- package/dist/add-route.js +18 -2
- package/dist/apply-design.d.ts +12 -1
- package/dist/apply-design.js +36 -4
- package/dist/cli.js +23 -3
- package/dist/derive-architecture.d.ts +15 -2
- package/dist/derive-architecture.js +34 -6
- package/dist/design-package.js +3 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/chrome-readers.d.ts +21 -0
- package/dist/lib/chrome-readers.js +100 -0
- package/dist/lib/extract-module-bundle.d.ts +11 -0
- package/dist/lib/extract-module-bundle.js +36 -6
- package/dist/lib/license-key-files.d.ts +6 -1
- package/dist/lib/license-key-files.js +9 -4
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +6 -0
- package/dist/parse-args.d.ts +22 -0
- package/dist/parse-args.js +73 -0
- package/dist/programmatic.d.ts +20 -0
- package/dist/programmatic.js +110 -9
- package/dist/site-chrome.d.ts +4 -0
- package/dist/site-chrome.js +34 -3
- package/dist/site-manifest.d.ts +12 -5
- package/dist/site-manifest.js +25 -11
- package/dist/sync-routes.js +11 -2
- package/dist/topology.d.ts +131 -0
- package/dist/topology.js +533 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +3 -3
- package/templates/apps/api/CLAUDE.md +4 -2
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
- package/templates/apps/api/src/lib/agent-store.ts +95 -3
- package/templates/apps/api/src/lib/site.ts +37 -0
- package/templates/apps/api/src/lib/spoke-store.ts +18 -2
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
- package/templates/apps/api/src/middleware/auth.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
- package/templates/apps/api/src/openapi/spec.ts +38 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
- package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
- package/templates/apps/api/src/routes/leads/index.ts +69 -6
- package/templates/apps/api/src/routes/webhooks/README.md +39 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
- package/templates/apps/api/src/services/default-tenant.ts +41 -7
- package/templates/apps/api/src/services/lead-routing.ts +148 -29
- package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
- package/templates/apps/mcp/__tests__/index.test.ts +5 -5
- package/templates/apps/mcp/src/index.ts +6 -4
- package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
- package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
- package/templates/apps/web/CLAUDE.md +1 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
- package/templates/apps/web/app/[locale]/layout.tsx +71 -19
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
- package/templates/apps/web/app/openapi.json/route.ts +69 -1
- package/templates/apps/web/instrumentation.ts +7 -4
- package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
- package/templates/apps/web/lib/closing-footer-path.ts +50 -0
- package/templates/apps/web/lib/relay.ts +206 -2
- package/templates/apps/web/lib/site-brand.tsx +22 -1
- package/templates/apps/web/lib/site-chrome.ts +3 -0
- package/templates/apps/web/lib/topology.generated.ts +23 -0
- package/templates/apps/web/package.json +1 -1
- package/templates/database/CHANGELOG.md +25 -0
- package/templates/database/__tests__/agents.db.test.ts +65 -0
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
- package/templates/database/auth/schema.prisma +23 -0
- package/templates/database/inbox/schema.prisma +13 -0
- package/templates/database/jest.config.cjs +7 -11
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
- package/templates/database/package.json +1 -1
- package/versions.json +34 -33
|
@@ -19,6 +19,9 @@ import { render, screen } from "@testing-library/react";
|
|
|
19
19
|
|
|
20
20
|
// ── @working-theory/ui mock ─────────────────────────────────────────────────
|
|
21
21
|
jest.mock("@working-theory/ui", () => ({
|
|
22
|
+
// #7789: renderPage's fallback page head renders inside the section Container.
|
|
23
|
+
Container: ({ children }: { children?: React.ReactNode }) =>
|
|
24
|
+
React.createElement("div", null, children),
|
|
22
25
|
// #4446/#4447: renderPage's own fallback/empty-state title composes Heading.
|
|
23
26
|
Heading: ({
|
|
24
27
|
children,
|
|
@@ -63,14 +66,22 @@ jest.mock("@working-theory/ui", () => ({
|
|
|
63
66
|
"data-fallback-email": props.fallbackEmail ?? "",
|
|
64
67
|
"data-terms-href": props.termsHref,
|
|
65
68
|
},
|
|
66
|
-
React.createElement(
|
|
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(
|
|
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 = {
|
|
115
|
-
|
|
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 = {
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
[
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
"
|
|
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
|
-
[
|
|
55
|
-
"
|
|
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(
|
|
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
|
|
4
|
+
* already publishes:
|
|
5
5
|
*
|
|
6
|
-
* 1. a `LocalBusiness` identity's `address.addressLocality`
|
|
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
|
|
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
|
|
45
|
-
if (
|
|
46
|
-
const
|
|
47
|
-
|
|
47
|
+
const parsed = SiteIdentitySchema.safeParse((fm as { siteIdentity?: unknown }).siteIdentity);
|
|
48
|
+
if (!parsed.success) return undefined;
|
|
49
|
+
const identity = parsed.data;
|
|
50
|
+
if (identity.kind !== "LocalBusiness" && identity.kind !== "Person") return undefined;
|
|
51
|
+
const parts = [nonEmpty(identity.address?.addressLocality), nonEmpty(identity.address?.addressRegion)].filter(
|
|
48
52
|
(part): part is string => part !== undefined,
|
|
49
53
|
);
|
|
50
54
|
return parts.length > 0 ? parts.join(", ") : undefined;
|
|
@@ -47,18 +47,18 @@ import { NextIntlClientProvider } from "next-intl";
|
|
|
47
47
|
import { getMessages, getTranslations } from "next-intl/server";
|
|
48
48
|
import type { ReactElement, ReactNode } from "react";
|
|
49
49
|
|
|
50
|
-
import { auth } from "../../auth";
|
|
51
50
|
import { GoogleAnalytics } from "../../components/google-analytics";
|
|
52
51
|
import {
|
|
53
52
|
SignedInNav,
|
|
54
53
|
type SignedInNavProps,
|
|
55
54
|
} from "../../components/signed-in-nav";
|
|
56
55
|
import { isAccountPath, isAuthPath } from "../../lib/auth-routes";
|
|
56
|
+
import { isClosingFooterPath } from "../../lib/closing-footer-path";
|
|
57
57
|
import { assertEnabledLocale } from "../../lib/locale-guard";
|
|
58
58
|
import { footerColumns, navItems } from "../../lib/nav-config";
|
|
59
59
|
import { getOgImageUrl } from "../../lib/og";
|
|
60
60
|
import { backLinkHref } from "../../lib/profile-back-link";
|
|
61
|
-
import { SITE_NAME } from "../../lib/site-brand";
|
|
61
|
+
import { COPYRIGHT_HOLDER, SITE_NAME } from "../../lib/site-brand";
|
|
62
62
|
import { siteChrome, type SiteChrome } from "../../lib/site-chrome";
|
|
63
63
|
import { siteLogo } from "../../lib/site-logo";
|
|
64
64
|
// Registers the `_site.mdx` identity with page-renderer at module load, so
|
|
@@ -69,6 +69,7 @@ import "../../lib/site-identity";
|
|
|
69
69
|
// (#7521). Inert without AILK_LISTING_PUBLIC_KEY or the paid package.
|
|
70
70
|
import "../../lib/listings";
|
|
71
71
|
import { siteThemeHead } from "../../lib/site-theme-head";
|
|
72
|
+
import { SIGN_IN_ENABLED, readSession } from "../../lib/topology.generated";
|
|
72
73
|
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
73
74
|
|
|
74
75
|
import { readSiteContactLines } from "./contact/site-contact-lines";
|
|
@@ -83,6 +84,7 @@ const chrome: SiteChrome & {
|
|
|
83
84
|
deckPresentation?: true;
|
|
84
85
|
colorMode?: "light";
|
|
85
86
|
chromeStyle?: "minimal";
|
|
87
|
+
closingFooterPaths?: readonly string[];
|
|
86
88
|
} = siteChrome;
|
|
87
89
|
|
|
88
90
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
@@ -142,6 +144,15 @@ async function resolveThemeMode(): Promise<ThemeMode> {
|
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The deck home's first slide (#7782): the element the consent bar waits on.
|
|
149
|
+
* The same section-slide selector `DeckSlideAdvance` walks (its `SLIDES`, minus
|
|
150
|
+
* the closing footer slide), so the first match is slide 1. The story that pins
|
|
151
|
+
* the geometry (`Shells/MarketingShell` → `Deck Consent Bar`) carries a copy.
|
|
152
|
+
*/
|
|
153
|
+
const DECK_FIRST_SLIDE_SELECTOR =
|
|
154
|
+
'.ailk-snap-deck .ailk-section-renderer > :not(.ailk-section-separator):not([data-section="separator"])';
|
|
155
|
+
|
|
145
156
|
/**
|
|
146
157
|
* Whether this visitor has already accepted or declined cookies (#5557).
|
|
147
158
|
*
|
|
@@ -250,8 +261,10 @@ export default async function LocaleLayout({
|
|
|
250
261
|
const isMinimalChrome = chrome.chromeStyle === "minimal";
|
|
251
262
|
const consentDecided = await hasConsentChoice();
|
|
252
263
|
// Server-side session read (#4984) — the same `auth.api.getSession` idiom
|
|
253
|
-
// the `(authed)` segment guard uses
|
|
254
|
-
|
|
264
|
+
// the `(authed)` segment guard uses, through the generated topology seam
|
|
265
|
+
// (#7426 D5): a spoke has no sign-in, so its render always answers null.
|
|
266
|
+
// Null when signed out or auth is unset.
|
|
267
|
+
const session = await readSession(await headers());
|
|
255
268
|
|
|
256
269
|
// Resolve nav labels server-side so Nav + Footer components stay
|
|
257
270
|
// i18n-hook-free and are testable without a NextIntlClientProvider wrapper.
|
|
@@ -348,11 +361,16 @@ export default async function LocaleLayout({
|
|
|
348
361
|
// above).
|
|
349
362
|
// The signed-out auth pair, shared by the header (every page but the deck
|
|
350
363
|
// home) and the deck home's closing footer slide (#7718 D4).
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
364
|
+
// A spoke (#7426 D11) has no sign-in, so it offers no auth pair anywhere:
|
|
365
|
+
// not in the header, the deck's closing footer or a bare page's footer.
|
|
366
|
+
const authActions: readonly Extract<NavAction, { path: string }>[] =
|
|
367
|
+
SIGN_IN_ENABLED
|
|
368
|
+
? [
|
|
369
|
+
// NO_HAND_IMPLEMENT_OK: operator-directed live Free-template pass.
|
|
370
|
+
{ labelKey: "signIn", path: "/sign-in", variant: "outline-strong" },
|
|
371
|
+
{ labelKey: "signUp", path: "/sign-up", variant: "primary" },
|
|
372
|
+
]
|
|
373
|
+
: [];
|
|
356
374
|
const navActions: NavAction[] = session
|
|
357
375
|
? [
|
|
358
376
|
{ labelKey: "account", kind: "flow", flow: NAV_CLIENT_ACTION_FLOW },
|
|
@@ -398,10 +416,12 @@ export default async function LocaleLayout({
|
|
|
398
416
|
? []
|
|
399
417
|
: session
|
|
400
418
|
? [{ label: tNav("account"), href: localeHref(locale, "/account") }]
|
|
401
|
-
:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
419
|
+
: SIGN_IN_ENABLED
|
|
420
|
+
? [
|
|
421
|
+
{ label: tNav("signIn"), href: localeHref(locale, "/sign-in") },
|
|
422
|
+
{ label: tNav("signUp"), href: localeHref(locale, "/sign-up") },
|
|
423
|
+
]
|
|
424
|
+
: [];
|
|
405
425
|
|
|
406
426
|
// A flow action needs `renderFlowTrigger`, a function — and this layout is a
|
|
407
427
|
// Server Component. Nav forwards that function into NavMobileMenu ("use
|
|
@@ -434,6 +454,18 @@ export default async function LocaleLayout({
|
|
|
434
454
|
// middleware and hreflang alternates read (NEXT_PUBLIC_ENABLED_LOCALES).
|
|
435
455
|
const enabledLocales = readLocales();
|
|
436
456
|
|
|
457
|
+
// #7797 — an inner page the site lists in `closingFooterPaths` ends on the
|
|
458
|
+
// closing footer: the wordmark, the contact lines and the legal line. The
|
|
459
|
+
// home is never an inner page, and an absent or empty list is false for
|
|
460
|
+
// every page, so a site that lists none renders exactly as before.
|
|
461
|
+
const isClosingInner =
|
|
462
|
+
pathname !== homePath &&
|
|
463
|
+
isClosingFooterPath({
|
|
464
|
+
pathname,
|
|
465
|
+
paths: chrome.closingFooterPaths,
|
|
466
|
+
locales: enabledLocales,
|
|
467
|
+
});
|
|
468
|
+
|
|
437
469
|
// The (auth) route group's chrome branch (#5963).
|
|
438
470
|
//
|
|
439
471
|
// A route group's layout nests INSIDE its parent segment's layout, so
|
|
@@ -519,7 +551,8 @@ export default async function LocaleLayout({
|
|
|
519
551
|
|
|
520
552
|
// The site's own name (#6942) — `SITE_NAME` resolves from `_site.mdx`'s
|
|
521
553
|
// `siteName:`, so a customer's footer never carries the kit vendor's name.
|
|
522
|
-
|
|
554
|
+
// The holder is `siteIdentity.legalName` when declared (#7796), else the name.
|
|
555
|
+
const copyrightText = `© ${new Date().getFullYear()} ${COPYRIGHT_HOLDER}. All rights reserved.`;
|
|
523
556
|
|
|
524
557
|
// Footer legal links (#5612 D3). The centered variant folds in FAQ — the
|
|
525
558
|
// roster's third always-scaffolded core route (`/faq`, alongside `/privacy`
|
|
@@ -740,18 +773,32 @@ export default async function LocaleLayout({
|
|
|
740
773
|
// Sign up are on no part of the home.
|
|
741
774
|
// #7721 D4 — a bare inner page's footer is the one bar, its
|
|
742
775
|
// link group carrying the account links the header drops.
|
|
776
|
+
// #7797 — a listed inner page takes the same closing footer.
|
|
777
|
+
// Under `innerPages: bare` the one-bar footer is where the
|
|
778
|
+
// account links live (the header drops them), so the closing
|
|
779
|
+
// footer carries the deck's auth actions in their place; on
|
|
780
|
+
// any other inner page the header still holds them.
|
|
743
781
|
{...(isDeckHome && isMinimalChrome
|
|
744
782
|
? { layout: "closing" as const, contactLines: readSiteContactLines() }
|
|
745
|
-
:
|
|
746
|
-
? {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
783
|
+
: isClosingInner
|
|
784
|
+
? {
|
|
785
|
+
layout: "closing" as const,
|
|
786
|
+
contactLines: readSiteContactLines(),
|
|
787
|
+
...(isBareInner ? { actions: deckFooterActions } : {}),
|
|
788
|
+
}
|
|
789
|
+
: isBareInner
|
|
790
|
+
? { layout: "line" as const, accountLinks }
|
|
791
|
+
: siteChrome.footer === "centered"
|
|
792
|
+
? { layout: "centered" as const }
|
|
793
|
+
: {})}
|
|
750
794
|
// #7718 D4 — the deck home's closing slide carries the
|
|
751
795
|
// centred brand and the auth links its bare header drops.
|
|
752
796
|
{...(isDeckHome && !isMinimalChrome
|
|
753
797
|
? { centeredBrand: true, actions: deckFooterActions }
|
|
754
798
|
: {})}
|
|
799
|
+
// #7823 — the minimal closing slide's actions are a button,
|
|
800
|
+
// then a text link; Footer reads the style from this.
|
|
801
|
+
{...(isMinimalChrome ? { chromeStyle: "minimal" as const } : {})}
|
|
755
802
|
// #7742 D2 — no theme switch under `colorMode: light`.
|
|
756
803
|
showThemeToggle={!pinLight}
|
|
757
804
|
bottomBarAlign="content"
|
|
@@ -816,6 +863,11 @@ export default async function LocaleLayout({
|
|
|
816
863
|
// #7742 D5 — the minimal chrome's consent bar: the page ground,
|
|
817
864
|
// a hairline, no shadow.
|
|
818
865
|
{...(isMinimalChrome ? { consentAppearance: "hairline" as const } : {})}
|
|
866
|
+
// #7782 — on the minimal deck home the bar first shows once the
|
|
867
|
+
// first slide has left view; every other page shows it on load.
|
|
868
|
+
{...(isDeckHome && isMinimalChrome
|
|
869
|
+
? { consentRevealAfter: DECK_FIRST_SLIDE_SELECTOR }
|
|
870
|
+
: {})}
|
|
819
871
|
/>
|
|
820
872
|
)}
|
|
821
873
|
<Telemetry />
|
|
@@ -11,6 +11,9 @@ import React from "react";
|
|
|
11
11
|
import { render, screen } from "@testing-library/react";
|
|
12
12
|
|
|
13
13
|
jest.mock("@working-theory/ui", () => ({
|
|
14
|
+
// #7789: renderPage's fallback page head renders inside the section Container.
|
|
15
|
+
Container: ({ children }: { children?: React.ReactNode }) =>
|
|
16
|
+
React.createElement("div", null, children),
|
|
14
17
|
Heading: ({ children, level }: { children: React.ReactNode; level: number }) =>
|
|
15
18
|
React.createElement(`h${level}`, null, children),
|
|
16
19
|
SectionRenderer: ({
|
|
@@ -20,6 +20,9 @@ import { render } from "@testing-library/react";
|
|
|
20
20
|
|
|
21
21
|
// ── @working-theory/ui mock ─────────────────────────────────────────────────────────────
|
|
22
22
|
jest.mock("@working-theory/ui", () => ({
|
|
23
|
+
// #7789: renderPage's fallback page head renders inside the section Container.
|
|
24
|
+
Container: ({ children }: { children?: React.ReactNode }) =>
|
|
25
|
+
React.createElement("div", null, children),
|
|
23
26
|
// #4446/#4447: renderPage's own fallback/empty-state title now composes
|
|
24
27
|
// Heading (mobile type-budget classes, #4417) instead of a bare <h1>.
|
|
25
28
|
Heading: ({
|
|
@@ -106,7 +109,7 @@ describe("ProjectsPage — snapshot regression", () => {
|
|
|
106
109
|
});
|
|
107
110
|
const { container } = render(element);
|
|
108
111
|
expect(container.innerHTML).toMatchInlineSnapshot(
|
|
109
|
-
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Projects","description":"Open-source projects built with AI Launch Kit.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"CreativeWork"}]}}</script><main id="main"><h1>Projects</h1><p data-testid="projects-description">Open-source projects built with AI Launch Kit.</p><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
|
|
112
|
+
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Projects","description":"Open-source projects built with AI Launch Kit.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"CreativeWork"}]}}</script><main id="main"><div><h1>Projects</h1><p data-testid="projects-description" class="mt-block-tight text-lead">Open-source projects built with AI Launch Kit.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
|
|
110
113
|
);
|
|
111
114
|
});
|
|
112
115
|
});
|
|
@@ -130,7 +133,7 @@ describe("ProjectSlugPage — snapshot regression", () => {
|
|
|
130
133
|
});
|
|
131
134
|
const { container } = render(element);
|
|
132
135
|
expect(container.innerHTML).toMatchInlineSnapshot(
|
|
133
|
-
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AI Launch Kit","description":"The AX-first OSS template.","mainEntity":{"@context":"https://schema.org","@type":"CreativeWork","name":"AI Launch Kit","description":"The AX-first OSS template."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Projects","item":"http://localhost:3000/en/projects"},{"@type":"ListItem","position":3,"name":"AI Launch Kit","item":"http://localhost:3000/en/projects/ailk"}]}]}</script><main id="main"><h1>AI Launch Kit</h1><p data-testid="ailk-description">The AX-first OSS template.</p><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
|
|
136
|
+
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AI Launch Kit","description":"The AX-first OSS template.","mainEntity":{"@context":"https://schema.org","@type":"CreativeWork","name":"AI Launch Kit","description":"The AX-first OSS template."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Projects","item":"http://localhost:3000/en/projects"},{"@type":"ListItem","position":3,"name":"AI Launch Kit","item":"http://localhost:3000/en/projects/ailk"}]}]}</script><main id="main"><div><h1>AI Launch Kit</h1><p data-testid="ailk-description" class="mt-block-tight text-lead">The AX-first OSS template.</p></div><div data-testid="section-renderer" data-section-count="0"></div></main>"`,
|
|
134
137
|
);
|
|
135
138
|
});
|
|
136
139
|
|
|
@@ -17,6 +17,9 @@ jest.mock("next/navigation", () => ({
|
|
|
17
17
|
}));
|
|
18
18
|
|
|
19
19
|
jest.mock("@working-theory/ui", () => ({
|
|
20
|
+
// #7789: renderPage's fallback page head renders inside the section Container.
|
|
21
|
+
Container: ({ children }: { children?: React.ReactNode }) =>
|
|
22
|
+
React.createElement("div", null, children),
|
|
20
23
|
// #4446/#4447: renderPage's own fallback/empty-state title now composes
|
|
21
24
|
// Heading (mobile type-budget classes, #4417) instead of a bare <h1>.
|
|
22
25
|
Heading: ({
|