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.
- package/README.md +108 -4
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cli.js +99 -0
- package/dist/cutover.d.ts +114 -0
- package/dist/cutover.js +511 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +127 -0
- package/dist/language-import.js +451 -0
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +445 -65
- package/dist/lib/render-comparison.d.ts +251 -0
- package/dist/lib/render-comparison.js +573 -0
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +62 -14
- package/dist/programmatic.js +2 -8
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/site-manifest.js +6 -38
- package/dist/site-roster.d.ts +46 -3
- package/dist/site-roster.js +124 -4
- package/dist/surfaces.d.ts +8 -0
- package/dist/surfaces.js +16 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +24 -10
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/mcp/CLAUDE.md +8 -4
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +16 -5
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +156 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +16 -9
- package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +145 -11
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +5 -1
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/package.json +3 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-content-source.ts +108 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- 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
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
61
|
-
if (
|
|
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
|
|
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({
|