create-ailk 0.30.0 → 0.31.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 (67) hide show
  1. package/README.md +71 -3
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/cli.js +99 -0
  5. package/dist/cutover.d.ts +109 -0
  6. package/dist/cutover.js +494 -0
  7. package/dist/design-presets.js +20 -4
  8. package/dist/export-content.d.ts +55 -0
  9. package/dist/export-content.js +303 -0
  10. package/dist/inventory.d.ts +216 -0
  11. package/dist/inventory.js +833 -0
  12. package/dist/language-import.d.ts +114 -0
  13. package/dist/language-import.js +435 -0
  14. package/dist/lib/env-catalog.js +1 -1
  15. package/dist/lib/import-mdx.d.ts +38 -0
  16. package/dist/lib/import-mdx.js +411 -64
  17. package/dist/lib/render-comparison.d.ts +200 -0
  18. package/dist/lib/render-comparison.js +374 -0
  19. package/dist/migrate-content.d.ts +6 -2
  20. package/dist/migrate-content.js +60 -14
  21. package/dist/seed-workspace.d.ts +3 -1
  22. package/dist/seed-workspace.js +45 -16
  23. package/dist/topology.d.ts +12 -0
  24. package/dist/topology.js +24 -10
  25. package/package.json +3 -3
  26. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  27. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  28. package/templates/.claude/settings.json +9 -0
  29. package/templates/.github/workflows/ci.yml +2 -0
  30. package/templates/CLAUDE.md +5 -0
  31. package/templates/apps/mcp/CLAUDE.md +8 -4
  32. package/templates/apps/web/.env.example +4 -2
  33. package/templates/apps/web/CLAUDE.md +16 -5
  34. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
  35. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  36. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  37. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  38. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  39. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  40. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  41. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  42. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  43. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  44. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  45. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  46. package/templates/apps/web/app/llms.txt/route.ts +16 -9
  47. package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
  48. package/templates/apps/web/jest.config.cjs +46 -1
  49. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  50. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  51. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
  52. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  53. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  54. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
  55. package/templates/apps/web/lib/bot-check.ts +113 -13
  56. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  57. package/templates/apps/web/lib/page-chrome.ts +65 -5
  58. package/templates/apps/web/lib/relay.ts +22 -3
  59. package/templates/apps/web/lib/site-identity.ts +75 -24
  60. package/templates/apps/web/messages/en.json +5 -1
  61. package/templates/apps/web/next.config.mjs +15 -1
  62. package/templates/apps/web/package.json +1 -1
  63. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  64. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  65. package/templates/package.json +2 -1
  66. package/templates/tools/check-content-source.ts +108 -0
  67. package/versions.json +48 -48
@@ -43,7 +43,7 @@ Raw HTML is allowed only when no `@working-theory/ui` component fits. Flag it in
43
43
  - Animations: only `transform` and `opacity`. Never `transition-all`.
44
44
  - Every interactive element needs hover, focus-visible, and active states.
45
45
 
46
- The four widths above are the responsive contract. Brand reference files (logos, photos, colour and type notes) live in `brand_assets/` at the repo root; the site's own brand colours and fonts are set in `content/_site.mdx` under `brand.colors` and `brand.fonts`. The signed-out header button is set there too, as `header.cta` (`label`, `href`), with `header.signIn: link | hidden` choosing whether Sign in stays beside it as a text link — never by editing `app/[locale]/layout.tsx`. Each page's chrome is set there too (#10049, resolved by `lib/page-chrome.ts`): `header.style` (`full | bare | none`), `footer.style` (`columns | centered | closing | none`), `layout` (`deck | default`, the home's) and one `{ header, footer }` key per route family (`blog`, `docs`, `services`, …), with a page's own `header:` / `footer:` / `layout:` frontmatter winning over all of them.
46
+ The four widths above are the responsive contract. Brand reference files (logos, photos, colour and type notes) live in `brand_assets/` at the repo root; the site's own brand colours and fonts are set in `content/_site.mdx` under `brand.colors` and `brand.fonts`. The signed-out header button is set there too, as `header.cta` (`label`, `href`), with `header.signIn: link | hidden` choosing whether Sign in stays beside it as a text link — never by editing `app/[locale]/layout.tsx`. Each page's chrome is set there too (#10049, resolved by `lib/page-chrome.ts`): `header.style` (`full | bare | none`), `footer.style` (`columns | centered | closing | none`), `layout` (`deck | default`, the home's) and one `{ header, footer }` key per route family (`blog`, `docs`, `services`, …), with a page's own `header:` / `footer:` / `layout:` frontmatter winning over all of them. The closing footer alone also reads three site-level keys (#10550): `footer.locality` (the line above the email, winning over the identity's address), `footer.actions` (one or two `{ label, href }` site paths — the primary, then the secondary — in place of the auth links) and `footer.legalBar` (off by default: `true` moves the legal line into a full-width bottom bar, on the deck's closing slide its bottom edge). The cookie notice's look is set there too (#10555, #10721): `consent.appearance` is `compact` (the floating card, bottom-left) or `hairline` (the full-width band on the page ground); absent, it follows the design's `chromeStyle` — the band under `minimal`, else the card. Only the band waits for the minimal deck home's first slide to leave view (#7782); the card shows on load on every page.
47
47
 
48
48
  ## Local dev
49
49
 
@@ -277,8 +277,8 @@ embedding for it is behind the hero CTA's lightbox gate
277
277
 
278
278
  The workspace's **Newsletter magnet** (#8847) — seeded live at slug
279
279
  `newsletter` in every workspace — is the only source of `/newsletter`,
280
- `/unsubscribe` and the footer signup; none of their copy is in page code,
281
- content or the message catalog. All three read it through
280
+ `/unsubscribe`, the footer signup and a bound `newsletter` section; none of
281
+ their copy is in page code, content or the message catalog. All four read it through
282
282
  `lib/in-page-magnet.ts`'s `readServedMagnet("newsletter")`, shared for 15
283
283
  seconds per instance like /contact's read.
284
284
 
@@ -306,6 +306,13 @@ seconds per instance like /contact's read.
306
306
  - **The footer** fills `Footer`'s `subscribe` slot with the compact form
307
307
  (stacked, `sr-only` label) only when the magnet is served and capture is
308
308
  on; the built-in config never fills it.
309
+ - **A bound `newsletter` section** (#10629) — a content `newsletter` section
310
+ with `magnet: "newsletter"` in place of `action` renders, under its own
311
+ headline, the full-size signup the locale layout builds from the footer's
312
+ same read and hands every page through `NewsletterSignupProvider` — with
313
+ `consentGate: "on-submit"`, so Subscribe stays enabled and an unticked
314
+ submit shows the consent error beside the box. It renders nothing whenever
315
+ the footer would show nothing. A section with `action` posts as before.
309
316
 
310
317
  ## Careers
311
318
 
@@ -581,7 +588,7 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
581
588
  - **No cross-site POST.** A `Sec-Fetch-Site` other than `same-origin`, or an `Origin` not on this site's own host, is refused 403 before the hub is called.
582
589
  - **Headers are an allow-list.** The visitor's `x-forwarded-for` (else `x-real-ip`) and `user-agent` pass through so per-visitor rate limits still count each visitor; no `authorization`, client-sent `x-ailk-*` or `host` is forwarded, and a `cookie` goes only on `POST /v1/commerce/invoices`, the one session route.
583
590
  - **The relay has its own per-visitor budget (#7707).** Each visitor gets 8 calls per 10 minutes across the five visitor routes, and the session route (`POST /v1/commerce/invoices`) gets its own, wider budget of 30 per 10 minutes; the call that exceeds either gets `429 rate_limited` before any hub call. The budget is held in this instance's own process (no shared store, no env variable) and holds only where the site's edge overwrites `x-forwarded-for` and `x-real-ip` — Vercel does; a bare self-hosted Next server does not.
584
- - **A bot check on the public writes (#9889, #9255).** On Vercel, `lib/bot-check.ts`'s `runBotCheck` asks Vercel BotID for a verdict before the hub is called — in the relay for `POST /v1/leads/{submit,subscribe,callback}` and `POST /v1/schedule/book` (`BOT_CHECKED_ROUTES`: `403 bot_detected`, or `503 bot_check_unavailable` when the check throws, answers no boolean or takes over 5 seconds), and in the contact, careers, newsletter, flow-step, flow-checkout, saved-recommendation and `/dev/purchase` Server Actions immediately before their hub call, which answer their own existing failure variant. The locale layout's `BotIdClient` protects every same-origin POST (`BOT_CLIENT_PROTECT`, `/*`), because a Server Action posts to the page it was submitted from; `next.config.mjs` wraps `withBotId` and `middleware.ts` keeps BotID's proxy prefix out of its matcher. It is on by default and inert off Vercel: `AILK_BOT_CHECK=off` turns every check off, `AILK_BOOKING_BOT_CHECK=off` the booking's alone, and MCP tools never pass through a site, so they are never challenged.
591
+ - **A bot check on the public writes (#9889, #9255).** On Vercel, `lib/bot-check.ts`'s `runBotCheck` asks Vercel BotID for a verdict before the hub is called — in the relay for `POST /v1/leads/{submit,subscribe,callback}` and `POST /v1/schedule/book` (`BOT_CHECKED_ROUTES`: `403 bot_detected`, or `503 bot_check_unavailable` when the check throws, answers no boolean or takes over 5 seconds), and in the contact, careers, newsletter, flow-step, flow-checkout, saved-recommendation and `/dev/purchase` Server Actions immediately before their hub call, which answer their own existing failure variant. The locale layout's `BotIdClient` protects every same-origin POST (`BOT_CLIENT_PROTECT`, `/*`), because a Server Action posts to the page it was submitted from; `next.config.mjs` wraps `withBotId` and `middleware.ts` keeps BotID's proxy prefix out of its matcher. It is on by default and inert off Vercel: `AILK_BOT_CHECK=off` turns every check off, `AILK_BOOKING_BOT_CHECK=off` the booking's alone, and MCP tools never pass through a site, so they are never challenged. A refusal logs `[<source>] bot refused` with BotID's `classificationReason`, `bypassed` and `isVerifiedBot`, the request's `host` and `forwardedHost`, and `proxied` (a `cf-ray` header) — no other header value, no form field (#10715). `AILK_BOT_CHECK=observe` runs the check and logs the same facts plus `isBot` as `observed`, but passes every write — for diagnosis only. **A BotID-protected site must be reached directly — Cloudflare DNS-only, no reverse proxy in front of Vercel (#10715).** Behind a proxy the write reaches Vercel from the proxy's egress rather than the browser that solved the challenge, so BotID refuses every real visitor; the first proxied request per process logs `[<source>] reverse proxy detected` (outcome `proxied`) naming that fix.
585
592
  - `/v1` is exempt from next-intl's locale rewrite in `middleware.ts`, as `/api` is.
586
593
 
587
594
  ## Where the docs live (`docsPath`)
@@ -637,7 +644,11 @@ A list page whose frontmatter sets `navOnly: true` (valid on a registry list typ
637
644
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
638
645
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
639
646
 
640
- Last updated: 2026-11-01 — § The owner's discovery switch: the six discovery files read the workspace's `discoverable` setting per request through `siteDiscoverable()`, off answering robots.txt's disallow-all and 404 for the rest, never cached (#10510) — verified against `app/{robots.txt,sitemap.xml,llms.txt,openapi.json}/route.ts`, `app/.well-known/{mcp.json,ai-plugin.json}/route.ts`, `lib/slices/docs-blog/llms-route.ts` and `packages/api-client/src/discovery.ts`.
647
+ Last updated: 2026-11-02 — § Theme & styling: `consent.appearance` named beside the other `_site.mdx` chrome keys, and the minimal deck home's hold-back is the band's alone, so a minimal site that picks the card sees it on load there too (#10721) — verified against `app/[locale]/layout.tsx`.
648
+
649
+ Prior: 2026-11-02 — § The same-origin relay: a bot-check refusal names its cause (`classificationReason`, `bypassed`, `isVerifiedBot`, both hosts, `proxied`), `AILK_BOT_CHECK=observe` logs and never refuses, and a BotID-protected site must be reached directly, Cloudflare DNS-only (#10715) — verified against `lib/bot-check.ts`.
650
+
651
+ Prior: 2026-11-01 — § The owner's discovery switch: the six discovery files read the workspace's `discoverable` setting per request through `siteDiscoverable()`, off answering robots.txt's disallow-all and 404 for the rest, never cached (#10510) — verified against `app/{robots.txt,sitemap.xml,llms.txt,openapi.json}/route.ts`, `app/.well-known/{mcp.json,ai-plugin.json}/route.ts`, `lib/slices/docs-blog/llms-route.ts` and `packages/api-client/src/discovery.ts`.
641
652
 
642
653
  Prior: 2026-10-30 — § The MCP authorization server: the site's OAuth 2.1 authorization server, its consent step, rotation and revocation (#10357) — verified against `auth.ts`, `lib/mcp-oauth.ts`, `app/[locale]/(auth)/consent/` and `app/.well-known/oauth-authorization-server/route.ts`.
643
654
 
@@ -50,6 +50,33 @@ jest.mock("../../../../../lib/paid-sections.generated", () => {
50
50
  };
51
51
  });
52
52
 
53
+ // #10552 — an MDX body's `## ` lines render through the page's own component
54
+ // map, so a heading's `id` is the real heading adapter's (AC-4).
55
+ jest.mock("next-mdx-remote/rsc", () => {
56
+ const { createElement, Fragment } = jest.requireActual<typeof import("react")>("react");
57
+ return {
58
+ MDXRemote: ({
59
+ source,
60
+ components,
61
+ }: {
62
+ source: string;
63
+ components: Record<string, React.ComponentType<{ children?: React.ReactNode }>>;
64
+ }) =>
65
+ createElement(
66
+ Fragment,
67
+ null,
68
+ ...source
69
+ .split("\n")
70
+ .filter((line) => line.trim() !== "")
71
+ .map((line, i) =>
72
+ line.startsWith("## ")
73
+ ? createElement(components["h2"]!, { key: i }, line.slice(3))
74
+ : createElement("p", { key: i }, line),
75
+ ),
76
+ ),
77
+ };
78
+ });
79
+
53
80
  jest.mock("@working-theory/ui", () => ({
54
81
  // #7789: renderPage's fallback page head renders inside the section Container.
55
82
  Container: ({ children }: { children?: React.ReactNode }) =>
@@ -60,10 +87,12 @@ jest.mock("@working-theory/ui", () => ({
60
87
  Heading: ({
61
88
  children,
62
89
  level,
90
+ id,
63
91
  }: {
64
92
  children: React.ReactNode;
65
93
  level: number;
66
- }): React.ReactElement => React.createElement(`h${level}`, null, children),
94
+ id?: string;
95
+ }): React.ReactElement => React.createElement(`h${level}`, id ? { id } : null, children),
67
96
  SectionRenderer: ({ sections }: { sections: unknown[] }) =>
68
97
  React.createElement("div", {
69
98
  "data-testid": "section-renderer",
@@ -73,7 +102,8 @@ jest.mock("@working-theory/ui", () => ({
73
102
 
74
103
  import { notFound } from "next/navigation";
75
104
 
76
- import DetailPage from "../page";
105
+ import { listingExtension } from "../../../../../lib/listings";
106
+ import DetailPage, { generateMetadata } from "../page";
77
107
 
78
108
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
79
109
 
@@ -120,7 +150,7 @@ describe("[slug]/[detail] — the Free /meet/<slug> placeholder route (#6897 D2)
120
150
  expect(mockNotFound).not.toHaveBeenCalled();
121
151
  });
122
152
 
123
- it("404s for another hub — the guard fires before getMdxSource is ever called", async () => {
153
+ it("404s for another hub — no BookCall source is ever read", async () => {
124
154
  const getMdxSource = jest.fn().mockResolvedValue(makeSource("book-a-call"));
125
155
 
126
156
  await expect(
@@ -130,7 +160,9 @@ describe("[slug]/[detail] — the Free /meet/<slug> placeholder route (#6897 D2)
130
160
  }),
131
161
  ).rejects.toThrow("NEXT_NOT_FOUND");
132
162
  expect(mockNotFound).toHaveBeenCalledTimes(1);
133
- expect(getMdxSource).not.toHaveBeenCalled();
163
+ // #10552 — the program-event branch reads the Event source first; the
164
+ // meet branch's BookCall read never runs for another hub.
165
+ expect(getMdxSource).not.toHaveBeenCalledWith("BookCall", expect.anything(), expect.anything());
134
166
  });
135
167
 
136
168
  it("404s for an unknown detail slug under the meet hub", async () => {
@@ -248,3 +280,120 @@ describe("[slug]/[detail] — a listicle entry at /<landingPage>/<slug> (#7521)"
248
280
  expect(global.fetch).not.toHaveBeenCalled();
249
281
  });
250
282
  });
283
+
284
+ // ── #10552 — an Event under a program at /<program>/<event> ─────────────────
285
+ describe("[slug]/[detail] — an Event under a program at /<program>/<event> (#10552)", () => {
286
+ const landing = (slug: string, title: string) => ({
287
+ frontmatter: { type: "LandingPage" as const, slug, title, description: `${title}.` },
288
+ body: "",
289
+ });
290
+ const golf = (frontmatter: Record<string, unknown> = { parent: "younglife" }) => ({
291
+ frontmatter: {
292
+ type: "Event" as const,
293
+ slug: "golf-tournament",
294
+ title: "Golf Tournament 2026",
295
+ description: "Our annual golf tournament.",
296
+ publishedAt: "2026-06-12",
297
+ ...frontmatter,
298
+ },
299
+ body: "Tee off with us.\n\n## Sponsorship\n\nBecome a hole sponsor.",
300
+ });
301
+ const loader = (
302
+ pages: Record<string, ReturnType<typeof landing> | ReturnType<typeof golf>>,
303
+ ) => jest.fn(async (type: string, slug: string) => pages[`${type}/${slug}`] ?? null);
304
+ const params = (slug: string, detail = "golf-tournament") =>
305
+ Promise.resolve({ locale: "en", slug, detail });
306
+
307
+ let entryAt: jest.SpyInstance;
308
+ beforeEach(() => {
309
+ mockNotFound.mockClear();
310
+ entryAt = jest.spyOn(listingExtension, "entryAt");
311
+ });
312
+ afterEach(() => entryAt.mockRestore());
313
+
314
+ it("renders the Event with its Event JSON-LD and the program breadcrumb (AC-3)", async () => {
315
+ const getMdxSource = loader({
316
+ "LandingPage/younglife": landing("younglife", "Young Life"),
317
+ "Event/golf-tournament": golf(),
318
+ });
319
+
320
+ const { container } = render(await DetailPage({ params: params("younglife"), getMdxSource }));
321
+
322
+ expect(mockNotFound).not.toHaveBeenCalled();
323
+ const ld = JSON.parse(
324
+ container.querySelector("script[type='application/ld+json']")?.textContent ?? "{}",
325
+ ) as Record<string, unknown>;
326
+ const nodes = (ld["@graph"] ?? [ld]) as Array<Record<string, unknown>>;
327
+ const event = nodes
328
+ .map((node) => node["mainEntity"] as Record<string, unknown> | undefined)
329
+ .find((entity) => entity?.["@type"] === "Event");
330
+ expect(event).toMatchObject({ name: "Golf Tournament 2026", startDate: "2026-06-12" });
331
+ const crumbs = nodes.find((node) => node["@type"] === "BreadcrumbList")?.[
332
+ "itemListElement"
333
+ ] as Array<Record<string, unknown>>;
334
+ expect(crumbs[1]).toMatchObject({ name: "Young Life" });
335
+ expect(crumbs[2]?.["item"]).toMatch(/\/younglife\/golf-tournament$/);
336
+ expect(entryAt).not.toHaveBeenCalled();
337
+
338
+ const metadata = await generateMetadata({ params: params("younglife"), getMdxSource });
339
+ expect(metadata.title).toBe("Golf Tournament 2026");
340
+ });
341
+
342
+ it("offers add-to-calendar for Google, Outlook and Apple (.ics) at the program URL", async () => {
343
+ const getMdxSource = loader({
344
+ "LandingPage/younglife": landing("younglife", "Young Life"),
345
+ "Event/golf-tournament": golf(),
346
+ });
347
+
348
+ const { container } = render(await DetailPage({ params: params("younglife"), getMdxSource }));
349
+
350
+ const href = (id: string) =>
351
+ container.querySelector(`[data-testid='add-to-calendar-${id}']`)?.getAttribute("href") ?? "";
352
+ expect(href("google")).toMatch(/^https:\/\/calendar\.google\.com\//);
353
+ expect(href("outlook")).toMatch(/^https:\/\/outlook\./);
354
+ expect(href("apple")).toMatch(/^data:text\/calendar/);
355
+ expect(decodeURIComponent(href("apple"))).toContain("/younglife/golf-tournament");
356
+ });
357
+
358
+ it("supports a #sponsorship anchor from the body heading (AC-4)", async () => {
359
+ const getMdxSource = loader({
360
+ "LandingPage/younglife": landing("younglife", "Young Life"),
361
+ "Event/golf-tournament": golf(),
362
+ });
363
+
364
+ const { container } = render(await DetailPage({ params: params("younglife"), getMdxSource }));
365
+
366
+ const anchors = container.querySelectorAll("#sponsorship");
367
+ expect(anchors).toHaveLength(1);
368
+ expect(anchors[0]?.tagName).toBe("H2");
369
+ expect(anchors[0]?.textContent).toBe("Sponsorship");
370
+ });
371
+
372
+ it("serves it only under the program it names (AC-5)", async () => {
373
+ const cases: Array<[string, ReturnType<typeof loader>]> = [
374
+ [
375
+ "other",
376
+ loader({
377
+ "LandingPage/other": landing("other", "Other"),
378
+ "LandingPage/younglife": landing("younglife", "Young Life"),
379
+ "Event/golf-tournament": golf(),
380
+ }),
381
+ ],
382
+ ["younglife", loader({ "Event/golf-tournament": golf() })],
383
+ [
384
+ "younglife",
385
+ loader({
386
+ "LandingPage/younglife": landing("younglife", "Young Life"),
387
+ "Event/golf-tournament": golf({}),
388
+ }),
389
+ ],
390
+ ];
391
+ for (const [slug, getMdxSource] of cases) {
392
+ mockNotFound.mockClear();
393
+ await expect(DetailPage({ params: params(slug), getMdxSource })).rejects.toThrow(
394
+ "NEXT_NOT_FOUND",
395
+ );
396
+ expect(mockNotFound).toHaveBeenCalledTimes(1);
397
+ }
398
+ });
399
+ });
@@ -1,5 +1,6 @@
1
1
  // Two-segment URLs: the Free /meet/<slug> placeholder detail pages (#6897
2
- // D2), and a listicle directory's entry pages (#7521).
2
+ // D2), an Event under a program (#10552), and a listicle directory's entry
3
+ // pages (#7521).
3
4
  //
4
5
  // The meet hub: BookCall content served at /meet/<detail> by Next.js
5
6
  // static-segment precedence: wherever apps/web/app/[locale]/meet/ exists (the
@@ -10,6 +11,13 @@
10
11
  // 404s rather than rendering an empty-state placeholder — the same contract
11
12
  // `[slug]/page.tsx` documents).
12
13
  //
14
+ // A program's event: `/<program>/<event>` serves the Event MDX `<event>` when
15
+ // its frontmatter `parent` is `<program>` and `/<program>` is a LandingPage —
16
+ // before the listing extension, so an event URL costs no listing read. Any
17
+ // other combination falls through. A `parent` that names a static top-level
18
+ // route directory (`events`, `services`, …) or `meet` is unreachable here:
19
+ // Next.js serves the static directory first (spec 10552 § 5.3).
20
+ //
13
21
  // Every other hub: `/<landingPage>/<entry>` for a listicle whose landing page
14
22
  // is a generic one-segment page (`/tools` → `[slug]/page.tsx`). The listing
15
23
  // extension (`lib/listings.tsx`) renders the accepted entry, or the route
@@ -49,6 +57,18 @@ function hubPage(
49
57
  (await getMdxSource("BlogList", slug, locale)) !== null;
50
58
  }
51
59
 
60
+ /** The Event `detail` when it names `slug` as its program and `/<slug>` is a LandingPage. */
61
+ async function programEvent(
62
+ slug: string,
63
+ detail: string,
64
+ locale: string,
65
+ getMdxSource: typeof defaultGetMdxSource = loadMdxSource,
66
+ ): Promise<Awaited<ReturnType<typeof defaultGetMdxSource>>> {
67
+ const source = await getMdxSource("Event", detail, locale);
68
+ if (source === null || source.frontmatter.parent !== slug) return null;
69
+ return (await getMdxSource("LandingPage", slug, locale)) === null ? null : source;
70
+ }
71
+
52
72
  interface Props {
53
73
  params: Promise<{ locale: string; slug: string; detail: string }>;
54
74
  /** Test-only DI override for getMdxSource. Production callers leave undefined. */
@@ -57,12 +77,21 @@ interface Props {
57
77
 
58
78
  export async function generateMetadata({
59
79
  params,
60
- }: Pick<Props, "params">): Promise<Metadata> {
80
+ getMdxSource,
81
+ }: Pick<Props, "params" | "getMdxSource">): Promise<Metadata> {
61
82
  const { locale, slug, detail } = await params;
62
83
  // Mirrors renderPageMetadata's own missing-frontmatter → {} contract: a
63
84
  // hub with no listing entry is the same "nothing to title" case, not a
64
85
  // thrown 404 — generateMetadata cannot itself change the response status.
65
86
  if (slug !== MEET_PLACEHOLDER_HUB) {
87
+ if ((await programEvent(slug, detail, locale, getMdxSource)) !== null) {
88
+ return renderPageMetadata({
89
+ type: "Event",
90
+ slug: detail,
91
+ locale,
92
+ ...(getMdxSource !== undefined && { getMdxSource }),
93
+ });
94
+ }
66
95
  return (
67
96
  (await listingExtension.entryMetadataAt(
68
97
  `/${slug}/${detail}`,
@@ -81,6 +110,20 @@ export default async function Page({
81
110
  }: Props): Promise<ReactElement> {
82
111
  const { locale, slug, detail } = await params;
83
112
  if (slug !== MEET_PLACEHOLDER_HUB) {
113
+ const event = await programEvent(slug, detail, locale, getMdxSource);
114
+ if (event !== null) {
115
+ // A composed Event renders its sections; a prose one its MDX body — the
116
+ // rule events/[slug]/page.tsx applies.
117
+ const sections = (event.frontmatter as { sections?: unknown[] }).sections;
118
+ const composed = Array.isArray(sections) && sections.length > 0;
119
+ return renderPage({
120
+ type: "Event",
121
+ slug: detail,
122
+ locale,
123
+ ...(composed ? {} : { bodyMode: "mdx-body" as const }),
124
+ ...(getMdxSource !== undefined && { getMdxSource }),
125
+ });
126
+ }
84
127
  const entry = await listingExtension.entryAt(
85
128
  `/${slug}/${detail}`,
86
129
  detail,
@@ -161,3 +161,52 @@ describe("readSiteContactLines (#7742 D4)", () => {
161
161
  expect(readSiteContactLines()).toEqual([]);
162
162
  });
163
163
  });
164
+
165
+ describe("readSiteContactLines(locality) — the site's own locality line (#10550 D2)", () => {
166
+ function writePerson(): void {
167
+ writeSite(
168
+ [
169
+ "siteName: Wendi Henry",
170
+ "contactEmail: a@b.test",
171
+ "siteIdentity:",
172
+ " kind: Person",
173
+ " name: Wendi Henry",
174
+ " url: https://wendi.test",
175
+ ...addressYaml(" ", "Denver", "CO"),
176
+ ].join("\n"),
177
+ );
178
+ }
179
+
180
+ it("a set locality wins over the address line, above the email", () => {
181
+ writePerson();
182
+ expect(readSiteContactLines("Colorado Springs, CO")).toEqual([
183
+ { label: "Colorado Springs, CO" },
184
+ { label: "a@b.test", href: "mailto:a@b.test" },
185
+ ]);
186
+ });
187
+
188
+ it.each([[undefined], [" "]])("locality %p keeps the address line", (locality) => {
189
+ writePerson();
190
+ expect(readSiteContactLines(locality)).toEqual([
191
+ { label: "Denver, CO" },
192
+ { label: "a@b.test", href: "mailto:a@b.test" },
193
+ ]);
194
+ });
195
+
196
+ it("an Organization takes the set locality, then the email", () => {
197
+ writeSite(
198
+ [
199
+ "siteName: Acme",
200
+ "contactEmail: a@b.test",
201
+ "siteIdentity:",
202
+ " kind: Organization",
203
+ " name: Acme",
204
+ " url: https://acme.test",
205
+ ].join("\n"),
206
+ );
207
+ expect(readSiteContactLines("Colorado Springs, CO")).toEqual([
208
+ { label: "Colorado Springs, CO" },
209
+ { label: "a@b.test", href: "mailto:a@b.test" },
210
+ ]);
211
+ });
212
+ });
@@ -8,7 +8,9 @@
8
8
  * read through the typed `SiteIdentitySchema` (#7824), so an invalid
9
9
  * identity or address gives no line rather than a half-trusted one;
10
10
  * 2. the published contact email (`readSiteContactEmail`, the address
11
- * `/contact` shows), as a `mailto:` link.
11
+ * `/contact` shows), as a `mailto:` link;
12
+ * 3. a site's own `footer.locality` (#10550 D2), passed in by the layout,
13
+ * which replaces line 1 when it is non-blank.
12
14
  *
13
15
  * Either line is left out when the site has none, so a site shows zero, one
14
16
  * or two lines. The same local `_site.mdx` frontmatter read as its sibling
@@ -54,11 +56,11 @@ function readLocalityLine(): string | undefined {
54
56
  return parts.length > 0 ? parts.join(", ") : undefined;
55
57
  }
56
58
 
57
- export function readSiteContactLines(): SiteContactLine[] {
59
+ export function readSiteContactLines(locality?: string): SiteContactLine[] {
58
60
  try {
59
61
  const lines: SiteContactLine[] = [];
60
- const locality = readLocalityLine();
61
- if (locality !== undefined) lines.push({ label: locality });
62
+ const localityLine = nonEmpty(locality) ?? readLocalityLine();
63
+ if (localityLine !== undefined) lines.push({ label: localityLine });
62
64
  const email = readSiteContactEmail();
63
65
  if (email !== undefined) lines.push({ label: email, href: `mailto:${email}` });
64
66
  return lines;
@@ -1,5 +1,7 @@
1
1
  // Event detail page. Reads the Event MDX for the given slug, renders via
2
2
  // renderPage, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
3
+ // An Event under a program (`parent`) lives at /<program>/<event> (#10552), so
4
+ // this URL answers a 308 there.
3
5
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
4
6
  import {
5
7
  getMdxSource as loadMdxSource,
@@ -7,6 +9,7 @@ import {
7
9
  renderPageMetadata,
8
10
  } from "@working-theory/page-renderer";
9
11
  import type { Metadata } from "next";
12
+ import { permanentRedirect } from "next/navigation";
10
13
  import type { ReactElement } from "react";
11
14
 
12
15
  interface Props {
@@ -29,7 +32,16 @@ export default async function Page({
29
32
  // A page that composes `sections[]` renders them (JSON-LD is built from
30
33
  // the same sections); a prose page renders its MDX body — the rule
31
34
  // /vs/[slug] applies (#10122).
32
- const source = await (getMdxSource ?? loadMdxSource)("Event", slug, locale);
35
+ const load = getMdxSource ?? loadMdxSource;
36
+ const source = await load("Event", slug, locale);
37
+ const parent = source?.frontmatter.parent;
38
+ // Only to a program the program route serves (a LandingPage at /<parent>),
39
+ // so a mistyped `parent` never turns this URL into a dead redirect.
40
+ if (parent !== undefined && (await load("LandingPage", parent, locale)) !== null) {
41
+ const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? "en";
42
+ const prefix = locale === defaultLocale ? "" : `/${locale}`;
43
+ permanentRedirect(`${prefix}/${parent}/${slug}`);
44
+ }
33
45
  const composed =
34
46
  source !== null &&
35
47
  Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
@@ -0,0 +1,120 @@
1
+ /**
2
+ * #10552 AC-6 — an Event under a program has one URL, `/<program>/<event>`:
3
+ * `/events/<event>` answers a 308 there, and the /events hub's card links it
4
+ * directly. An Event with no `parent` keeps `/events/<event>` on both.
5
+ *
6
+ * Its own suite: `hub-children.test.tsx` mocks `@working-theory/page-renderer`
7
+ * whole, so the hub's real cards are not rendered there.
8
+ */
9
+ import React from "react";
10
+ import { render } from "@testing-library/react";
11
+
12
+ jest.mock("next/navigation", () => ({
13
+ __esModule: true,
14
+ notFound: jest.fn(() => {
15
+ throw new Error("NEXT_NOT_FOUND");
16
+ }),
17
+ permanentRedirect: jest.fn((url: string) => {
18
+ throw new Error(`NEXT_REDIRECT ${url}`);
19
+ }),
20
+ }));
21
+
22
+ import { permanentRedirect } from "next/navigation";
23
+
24
+ import EventsPage from "../page";
25
+ import EventSlugPage from "../[slug]/page";
26
+
27
+ const mockRedirect = permanentRedirect as jest.MockedFunction<typeof permanentRedirect>;
28
+
29
+ const event = (slug: string, parent?: string) => ({
30
+ frontmatter: {
31
+ type: "Event" as const,
32
+ slug,
33
+ title: `Title for ${slug}`,
34
+ description: `Description for ${slug}.`,
35
+ ...(parent !== undefined && { parent }),
36
+ },
37
+ body: `# Title for ${slug}\n\nBody.`,
38
+ });
39
+
40
+ const eventsList = {
41
+ frontmatter: { type: "EventsList" as const, slug: "events", title: "Events", description: "Events." },
42
+ body: "# Events\n\nBody.",
43
+ };
44
+
45
+ const events: Record<string, ReturnType<typeof event>> = {
46
+ "golf-tournament": event("golf-tournament", "younglife"),
47
+ "annual-summit": event("annual-summit"),
48
+ "typo-gala": event("typo-gala", "no-such-program"),
49
+ };
50
+
51
+ const younglife = {
52
+ frontmatter: { type: "LandingPage" as const, slug: "younglife", title: "Young Life", description: "YL." },
53
+ body: "",
54
+ };
55
+
56
+ const getMdxSource = jest.fn(async (type: string, slug: string) =>
57
+ type === "Event"
58
+ ? (events[slug] ?? null)
59
+ : type === "EventsList"
60
+ ? eventsList
61
+ : type === "LandingPage" && slug === "younglife"
62
+ ? younglife
63
+ : null,
64
+ );
65
+
66
+ describe("/events/<event> for an Event under a program (#10552 D3)", () => {
67
+ const savedDefault = process.env.NEXT_PUBLIC_DEFAULT_LOCALE;
68
+ beforeEach(() => {
69
+ mockRedirect.mockClear();
70
+ delete process.env.NEXT_PUBLIC_DEFAULT_LOCALE;
71
+ });
72
+ afterAll(() => {
73
+ if (savedDefault === undefined) delete process.env.NEXT_PUBLIC_DEFAULT_LOCALE;
74
+ else process.env.NEXT_PUBLIC_DEFAULT_LOCALE = savedDefault;
75
+ });
76
+
77
+ it.each([
78
+ ["en", "/younglife/golf-tournament"],
79
+ ["es", "/es/younglife/golf-tournament"],
80
+ ])("answers a 308 to the program URL at %s", async (locale, target) => {
81
+ await expect(
82
+ EventSlugPage({ params: Promise.resolve({ locale, slug: "golf-tournament" }), getMdxSource }),
83
+ ).rejects.toThrow(`NEXT_REDIRECT ${target}`);
84
+ expect(mockRedirect).toHaveBeenCalledWith(target);
85
+ });
86
+
87
+ it("does not redirect when the parent names no program page", async () => {
88
+ render(
89
+ await EventSlugPage({ params: Promise.resolve({ locale: "en", slug: "typo-gala" }), getMdxSource }),
90
+ );
91
+ expect(mockRedirect).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it("renders an Event with no parent, with no redirect", async () => {
95
+ const { container } = render(
96
+ await EventSlugPage({
97
+ params: Promise.resolve({ locale: "en", slug: "annual-summit" }),
98
+ getMdxSource,
99
+ }),
100
+ );
101
+ expect(mockRedirect).not.toHaveBeenCalled();
102
+ expect(container.querySelector("script[type='application/ld+json']")).not.toBeNull();
103
+ });
104
+ });
105
+
106
+ describe("the /events hub's card for an Event under a program (#10552 D5)", () => {
107
+ it("links the program URL, and /events/<slug> for an Event with no parent", async () => {
108
+ const { container } = render(
109
+ await EventsPage({
110
+ params: Promise.resolve({ locale: "en" }),
111
+ getMdxSlugs: async () => ["golf-tournament", "annual-summit"],
112
+ getMdxSource,
113
+ }),
114
+ );
115
+ const hrefs = Array.from(container.querySelectorAll("a")).map((a) => a.getAttribute("href"));
116
+ expect(hrefs).toContain("/younglife/golf-tournament");
117
+ expect(hrefs).not.toContain("/events/golf-tournament");
118
+ expect(hrefs).toContain("/events/annual-summit");
119
+ });
120
+ });
@@ -12,6 +12,7 @@ import type { Metadata } from "next";
12
12
  import type { ReactElement } from "react";
13
13
 
14
14
  import { type HubConfig, hubPageInput } from "../../../lib/hub-children";
15
+ import { pagePathFor } from "../../../src/lib/routes";
15
16
 
16
17
  interface Props {
17
18
  params: Promise<{ locale: string }>;
@@ -27,6 +28,16 @@ const HUB: HubConfig = {
27
28
  linkLabel: "View the event",
28
29
  sectionId: "event-cards",
29
30
  body: "mdx-body",
31
+ // #10552 D5 — an Event under a program links its program URL; the rest is
32
+ // the default card, byte for byte.
33
+ card: ({ slug, frontmatter }) => {
34
+ const fm = frontmatter as { title?: string; description?: string; parent?: string };
35
+ return {
36
+ title: fm.title ?? slug,
37
+ description: fm.description ?? "",
38
+ link: { label: HUB.linkLabel, href: pagePathFor({ type: "Event", slug, parent: fm.parent }) },
39
+ };
40
+ },
30
41
  };
31
42
 
32
43
  export async function generateMetadata({