create-ailk 0.30.0 → 0.32.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 (95) hide show
  1. package/README.md +108 -4
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/body-heading.d.ts +13 -0
  5. package/dist/body-heading.js +19 -5
  6. package/dist/cli.js +99 -0
  7. package/dist/cutover.d.ts +114 -0
  8. package/dist/cutover.js +511 -0
  9. package/dist/design-presets.js +20 -4
  10. package/dist/export-content.d.ts +55 -0
  11. package/dist/export-content.js +303 -0
  12. package/dist/inventory.d.ts +216 -0
  13. package/dist/inventory.js +833 -0
  14. package/dist/language-import.d.ts +127 -0
  15. package/dist/language-import.js +451 -0
  16. package/dist/lib/env-catalog.js +1 -1
  17. package/dist/lib/import-mdx.d.ts +38 -0
  18. package/dist/lib/import-mdx.js +445 -65
  19. package/dist/lib/render-comparison.d.ts +251 -0
  20. package/dist/lib/render-comparison.js +573 -0
  21. package/dist/mdx-braces.d.ts +73 -0
  22. package/dist/mdx-braces.js +285 -0
  23. package/dist/migrate-content.d.ts +6 -2
  24. package/dist/migrate-content.js +62 -14
  25. package/dist/programmatic.js +2 -8
  26. package/dist/seed-workspace.d.ts +3 -1
  27. package/dist/seed-workspace.js +45 -16
  28. package/dist/site-manifest.js +6 -38
  29. package/dist/site-roster.d.ts +46 -3
  30. package/dist/site-roster.js +124 -4
  31. package/dist/surfaces.d.ts +8 -0
  32. package/dist/surfaces.js +16 -0
  33. package/dist/topology.d.ts +12 -0
  34. package/dist/topology.js +24 -10
  35. package/dist/update.d.ts +21 -1
  36. package/dist/update.js +142 -11
  37. package/package.json +3 -3
  38. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  39. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  40. package/templates/.claude/settings.json +9 -0
  41. package/templates/.github/workflows/ci.yml +4 -0
  42. package/templates/CLAUDE.md +5 -0
  43. package/templates/apps/mcp/.env.example +7 -1
  44. package/templates/apps/mcp/CLAUDE.md +8 -4
  45. package/templates/apps/web/.env.example +4 -2
  46. package/templates/apps/web/CLAUDE.md +16 -5
  47. package/templates/apps/web/__tests__/setup.ts +9 -0
  48. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +156 -4
  49. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  50. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  51. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
  52. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
  53. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  54. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  55. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  56. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  57. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  58. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  59. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  60. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  61. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
  62. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  63. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
  64. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
  67. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
  68. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  69. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  70. package/templates/apps/web/app/llms.txt/route.ts +16 -9
  71. package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
  72. package/templates/apps/web/jest.config.cjs +46 -1
  73. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  74. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  75. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +93 -0
  76. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  77. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  78. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +145 -11
  79. package/templates/apps/web/lib/bot-check.ts +113 -13
  80. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  81. package/templates/apps/web/lib/page-chrome.ts +65 -5
  82. package/templates/apps/web/lib/relay.ts +22 -3
  83. package/templates/apps/web/lib/site-identity.ts +75 -24
  84. package/templates/apps/web/messages/en.json +5 -1
  85. package/templates/apps/web/next.config.mjs +15 -1
  86. package/templates/apps/web/package.json +1 -1
  87. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  88. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  89. package/templates/apps/web/src/lib/routes.ts +5 -3
  90. package/templates/package.json +3 -1
  91. package/templates/tools/body-heading.ts +440 -0
  92. package/templates/tools/check-content-source.ts +108 -0
  93. package/templates/tools/check-mdx-body.ts +123 -0
  94. package/templates/tools/mdx-braces.ts +303 -0
  95. package/versions.json +48 -48
@@ -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,11 @@ 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";
107
+
108
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
109
+ process.env.CONTENT_ADAPTER = "mdx";
77
110
 
78
111
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
79
112
 
@@ -120,7 +153,7 @@ describe("[slug]/[detail] — the Free /meet/<slug> placeholder route (#6897 D2)
120
153
  expect(mockNotFound).not.toHaveBeenCalled();
121
154
  });
122
155
 
123
- it("404s for another hub — the guard fires before getMdxSource is ever called", async () => {
156
+ it("404s for another hub — no BookCall source is ever read", async () => {
124
157
  const getMdxSource = jest.fn().mockResolvedValue(makeSource("book-a-call"));
125
158
 
126
159
  await expect(
@@ -130,7 +163,9 @@ describe("[slug]/[detail] — the Free /meet/<slug> placeholder route (#6897 D2)
130
163
  }),
131
164
  ).rejects.toThrow("NEXT_NOT_FOUND");
132
165
  expect(mockNotFound).toHaveBeenCalledTimes(1);
133
- expect(getMdxSource).not.toHaveBeenCalled();
166
+ // #10552 — the program-event branch reads the Event source first; the
167
+ // meet branch's BookCall read never runs for another hub.
168
+ expect(getMdxSource).not.toHaveBeenCalledWith("BookCall", expect.anything(), expect.anything());
134
169
  });
135
170
 
136
171
  it("404s for an unknown detail slug under the meet hub", async () => {
@@ -248,3 +283,120 @@ describe("[slug]/[detail] — a listicle entry at /<landingPage>/<slug> (#7521)"
248
283
  expect(global.fetch).not.toHaveBeenCalled();
249
284
  });
250
285
  });
286
+
287
+ // ── #10552 — an Event under a program at /<program>/<event> ─────────────────
288
+ describe("[slug]/[detail] — an Event under a program at /<program>/<event> (#10552)", () => {
289
+ const landing = (slug: string, title: string) => ({
290
+ frontmatter: { type: "LandingPage" as const, slug, title, description: `${title}.` },
291
+ body: "",
292
+ });
293
+ const golf = (frontmatter: Record<string, unknown> = { parent: "younglife" }) => ({
294
+ frontmatter: {
295
+ type: "Event" as const,
296
+ slug: "golf-tournament",
297
+ title: "Golf Tournament 2026",
298
+ description: "Our annual golf tournament.",
299
+ publishedAt: "2026-06-12",
300
+ ...frontmatter,
301
+ },
302
+ body: "Tee off with us.\n\n## Sponsorship\n\nBecome a hole sponsor.",
303
+ });
304
+ const loader = (
305
+ pages: Record<string, ReturnType<typeof landing> | ReturnType<typeof golf>>,
306
+ ) => jest.fn(async (type: string, slug: string) => pages[`${type}/${slug}`] ?? null);
307
+ const params = (slug: string, detail = "golf-tournament") =>
308
+ Promise.resolve({ locale: "en", slug, detail });
309
+
310
+ let entryAt: jest.SpyInstance;
311
+ beforeEach(() => {
312
+ mockNotFound.mockClear();
313
+ entryAt = jest.spyOn(listingExtension, "entryAt");
314
+ });
315
+ afterEach(() => entryAt.mockRestore());
316
+
317
+ it("renders the Event with its Event JSON-LD and the program breadcrumb (AC-3)", async () => {
318
+ const getMdxSource = loader({
319
+ "LandingPage/younglife": landing("younglife", "Young Life"),
320
+ "Event/golf-tournament": golf(),
321
+ });
322
+
323
+ const { container } = render(await DetailPage({ params: params("younglife"), getMdxSource }));
324
+
325
+ expect(mockNotFound).not.toHaveBeenCalled();
326
+ const ld = JSON.parse(
327
+ container.querySelector("script[type='application/ld+json']")?.textContent ?? "{}",
328
+ ) as Record<string, unknown>;
329
+ const nodes = (ld["@graph"] ?? [ld]) as Array<Record<string, unknown>>;
330
+ const event = nodes
331
+ .map((node) => node["mainEntity"] as Record<string, unknown> | undefined)
332
+ .find((entity) => entity?.["@type"] === "Event");
333
+ expect(event).toMatchObject({ name: "Golf Tournament 2026", startDate: "2026-06-12" });
334
+ const crumbs = nodes.find((node) => node["@type"] === "BreadcrumbList")?.[
335
+ "itemListElement"
336
+ ] as Array<Record<string, unknown>>;
337
+ expect(crumbs[1]).toMatchObject({ name: "Young Life" });
338
+ expect(crumbs[2]?.["item"]).toMatch(/\/younglife\/golf-tournament$/);
339
+ expect(entryAt).not.toHaveBeenCalled();
340
+
341
+ const metadata = await generateMetadata({ params: params("younglife"), getMdxSource });
342
+ expect(metadata.title).toBe("Golf Tournament 2026");
343
+ });
344
+
345
+ it("offers add-to-calendar for Google, Outlook and Apple (.ics) at the program URL", async () => {
346
+ const getMdxSource = loader({
347
+ "LandingPage/younglife": landing("younglife", "Young Life"),
348
+ "Event/golf-tournament": golf(),
349
+ });
350
+
351
+ const { container } = render(await DetailPage({ params: params("younglife"), getMdxSource }));
352
+
353
+ const href = (id: string) =>
354
+ container.querySelector(`[data-testid='add-to-calendar-${id}']`)?.getAttribute("href") ?? "";
355
+ expect(href("google")).toMatch(/^https:\/\/calendar\.google\.com\//);
356
+ expect(href("outlook")).toMatch(/^https:\/\/outlook\./);
357
+ expect(href("apple")).toMatch(/^data:text\/calendar/);
358
+ expect(decodeURIComponent(href("apple"))).toContain("/younglife/golf-tournament");
359
+ });
360
+
361
+ it("supports a #sponsorship anchor from the body heading (AC-4)", async () => {
362
+ const getMdxSource = loader({
363
+ "LandingPage/younglife": landing("younglife", "Young Life"),
364
+ "Event/golf-tournament": golf(),
365
+ });
366
+
367
+ const { container } = render(await DetailPage({ params: params("younglife"), getMdxSource }));
368
+
369
+ const anchors = container.querySelectorAll("#sponsorship");
370
+ expect(anchors).toHaveLength(1);
371
+ expect(anchors[0]?.tagName).toBe("H2");
372
+ expect(anchors[0]?.textContent).toBe("Sponsorship");
373
+ });
374
+
375
+ it("serves it only under the program it names (AC-5)", async () => {
376
+ const cases: Array<[string, ReturnType<typeof loader>]> = [
377
+ [
378
+ "other",
379
+ loader({
380
+ "LandingPage/other": landing("other", "Other"),
381
+ "LandingPage/younglife": landing("younglife", "Young Life"),
382
+ "Event/golf-tournament": golf(),
383
+ }),
384
+ ],
385
+ ["younglife", loader({ "Event/golf-tournament": golf() })],
386
+ [
387
+ "younglife",
388
+ loader({
389
+ "LandingPage/younglife": landing("younglife", "Young Life"),
390
+ "Event/golf-tournament": golf({}),
391
+ }),
392
+ ],
393
+ ];
394
+ for (const [slug, getMdxSource] of cases) {
395
+ mockNotFound.mockClear();
396
+ await expect(DetailPage({ params: params(slug), getMdxSource })).rejects.toThrow(
397
+ "NEXT_NOT_FOUND",
398
+ );
399
+ expect(mockNotFound).toHaveBeenCalledTimes(1);
400
+ }
401
+ });
402
+ });
@@ -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,
@@ -45,6 +45,9 @@ jest.mock("@working-theory/ui", () => ({
45
45
  import { notFound } from "next/navigation";
46
46
  import SlugPage from "../page";
47
47
 
48
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
49
+ process.env.CONTENT_ADAPTER = "mdx";
50
+
48
51
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
49
52
 
50
53
  function makeSource(slug: string, schema?: Record<string, unknown>) {
@@ -45,6 +45,9 @@ import { createSitemapHandler } from "../../../sitemap.xml/route";
45
45
  import { createLlmsHandler } from "../../../llms.txt/route";
46
46
  import type { ContentAdapter } from "@working-theory/content-adapters";
47
47
 
48
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
49
+ process.env.CONTENT_ADAPTER = "mdx";
50
+
48
51
  type JsonLd = Record<string, unknown>;
49
52
 
50
53
  function jsonLdScripts(container: HTMLElement): JsonLd[] {
@@ -58,6 +58,9 @@ import CareersPage from "../page";
58
58
  import CareersSlugPage from "../[slug]/page";
59
59
  import * as pageRenderer from "@working-theory/page-renderer";
60
60
 
61
+ // #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
62
+ process.env.CONTENT_ADAPTER = "mdx";
63
+
61
64
  // ── Shared stubs ───────────────────────────────────────────────────────────────
62
65
  const getMdxSourceEmpty = jest.fn().mockResolvedValue(null);
63
66
 
@@ -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({