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.
- package/README.md +71 -3
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/cli.js +99 -0
- package/dist/cutover.d.ts +109 -0
- package/dist/cutover.js +494 -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 +114 -0
- package/dist/language-import.js +435 -0
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +411 -64
- package/dist/lib/render-comparison.d.ts +200 -0
- package/dist/lib/render-comparison.js +374 -0
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +24 -10
- 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 +2 -0
- package/templates/CLAUDE.md +5 -0
- 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/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- 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]/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 +90 -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 +11 -1
- 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/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- 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
|
|
281
|
-
content or the message catalog. All
|
|
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-
|
|
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
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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
|
|
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,
|
|
@@ -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({
|