create-ailk 0.29.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +71 -3
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/add-route.js +1 -0
  5. package/dist/cli.js +114 -1
  6. package/dist/cutover.d.ts +109 -0
  7. package/dist/cutover.js +494 -0
  8. package/dist/design-presets.js +20 -4
  9. package/dist/export-content.d.ts +55 -0
  10. package/dist/export-content.js +303 -0
  11. package/dist/inventory.d.ts +216 -0
  12. package/dist/inventory.js +833 -0
  13. package/dist/language-import.d.ts +114 -0
  14. package/dist/language-import.js +435 -0
  15. package/dist/lib/env-catalog.js +2 -2
  16. package/dist/lib/import-mdx.d.ts +38 -0
  17. package/dist/lib/import-mdx.js +411 -64
  18. package/dist/lib/render-comparison.d.ts +200 -0
  19. package/dist/lib/render-comparison.js +374 -0
  20. package/dist/lib/scaffold-render-sweep.d.ts +1 -1
  21. package/dist/lib/scaffold-render-sweep.js +1 -1
  22. package/dist/migrate-content.d.ts +6 -2
  23. package/dist/migrate-content.js +60 -14
  24. package/dist/module-architecture.js +6 -1
  25. package/dist/provision-env.d.ts +28 -0
  26. package/dist/provision-env.js +210 -0
  27. package/dist/seed-workspace.d.ts +3 -1
  28. package/dist/seed-workspace.js +45 -16
  29. package/dist/surfaces.js +3 -0
  30. package/dist/topology.d.ts +12 -0
  31. package/dist/topology.js +28 -10
  32. package/package.json +3 -3
  33. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  34. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  35. package/templates/.claude/settings.json +9 -0
  36. package/templates/.env.example +7 -0
  37. package/templates/.github/workflows/ci.yml +2 -0
  38. package/templates/CLAUDE.md +5 -0
  39. package/templates/apps/api/CLAUDE.md +6 -2
  40. package/templates/apps/mcp/.env.example +20 -0
  41. package/templates/apps/mcp/CLAUDE.md +46 -8
  42. package/templates/apps/web/.env.example +4 -2
  43. package/templates/apps/web/CLAUDE.md +39 -5
  44. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
  45. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
  46. package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
  47. package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
  48. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
  49. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
  50. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
  51. package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
  52. package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
  53. package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
  54. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
  55. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  56. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  57. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  58. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  59. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  60. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  61. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  62. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  63. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  64. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  65. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  66. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  67. package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
  68. package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
  69. package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
  70. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
  71. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
  72. package/templates/apps/web/app/llms.txt/route.ts +26 -9
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
  74. package/templates/apps/web/app/openapi.json/route.ts +4 -0
  75. package/templates/apps/web/app/robots.txt/route.ts +18 -1
  76. package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
  77. package/templates/apps/web/auth.ts +184 -7
  78. package/templates/apps/web/jest.config.cjs +46 -1
  79. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
  80. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  81. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  82. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
  83. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  84. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  85. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
  86. package/templates/apps/web/lib/auth-routes.ts +5 -3
  87. package/templates/apps/web/lib/bot-check.ts +113 -13
  88. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  89. package/templates/apps/web/lib/mcp-oauth.ts +702 -0
  90. package/templates/apps/web/lib/page-chrome.ts +65 -5
  91. package/templates/apps/web/lib/relay.ts +22 -3
  92. package/templates/apps/web/lib/site-identity.ts +75 -24
  93. package/templates/apps/web/messages/en.json +47 -14
  94. package/templates/apps/web/next.config.mjs +15 -1
  95. package/templates/apps/web/package.json +1 -1
  96. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  97. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  98. package/templates/database/seed.ts +6 -0
  99. package/templates/package.json +2 -1
  100. package/templates/tools/check-content-source.ts +108 -0
  101. package/versions.json +48 -48
@@ -0,0 +1,132 @@
1
+ "use client"; // the workspace choice decides which scopes show and whether Allow is enabled
2
+
3
+ /**
4
+ * The consent page's client island (#10357 D6): the workspace radio list, the
5
+ * chosen workspace's offered scopes as checkboxes — none ticked — and Allow,
6
+ * disabled until a workspace is chosen. Deliberate selection is the defence
7
+ * against a phished authorization (SEC-10357-11, -13). Strings arrive
8
+ * translated from the server page.
9
+ */
10
+ import {
11
+ Button,
12
+ Checkbox,
13
+ Input,
14
+ Label,
15
+ RadioGroup,
16
+ RadioGroupItem,
17
+ Text,
18
+ } from "@working-theory/ui/primitives";
19
+ import type { ReactElement } from "react";
20
+ import { useState } from "react";
21
+
22
+ import { decideConsent } from "./consent.actions";
23
+
24
+ export interface ConsentWorkspace {
25
+ id: string;
26
+ name: string;
27
+ organizationName: string;
28
+ /** Each offered scope with its label. */
29
+ offered: Array<{ scope: string; permission: string; label: string }>;
30
+ }
31
+
32
+ export interface ConsentFormText {
33
+ workspaceLegend: string;
34
+ scopesLegend: string;
35
+ memberReads: string;
36
+ noScopes: string;
37
+ allow: string;
38
+ deny: string;
39
+ }
40
+
41
+ export interface ConsentFormProps {
42
+ consentCode: string;
43
+ locale: string;
44
+ workspaces: ConsentWorkspace[];
45
+ text: ConsentFormText;
46
+ }
47
+
48
+ export function ConsentForm({ consentCode, locale, workspaces, text }: ConsentFormProps): ReactElement {
49
+ const [workspaceId, setWorkspaceId] = useState<string>(
50
+ workspaces.length === 1 ? (workspaces[0]?.id ?? "") : "",
51
+ );
52
+ const chosen = workspaces.find((w) => w.id === workspaceId);
53
+
54
+ return (
55
+ <form action={decideConsent} className="flex flex-col gap-6" data-testid="mcp-consent-form">
56
+ <Input type="hidden" name="consent_code" value={consentCode} />
57
+ <Input type="hidden" name="locale" value={locale} />
58
+ {workspaces.length > 0 ? (
59
+ <div className="flex flex-col gap-3">
60
+ <Text as="p" variant="body-sm" className="font-semibold">
61
+ {text.workspaceLegend}
62
+ </Text>
63
+ <RadioGroup
64
+ name="workspace_id"
65
+ value={workspaceId}
66
+ onValueChange={setWorkspaceId}
67
+ aria-label={text.workspaceLegend}
68
+ >
69
+ {workspaces.map((w) => (
70
+ <div key={w.id} className="flex min-h-11 items-center gap-3">
71
+ <RadioGroupItem
72
+ id={`mcp-consent-ws-${w.id}`}
73
+ value={w.id}
74
+ data-testid={`mcp-consent-workspace-${w.id}`}
75
+ />
76
+ <Label htmlFor={`mcp-consent-ws-${w.id}`}>
77
+ {/* i18n-allow: a separator between two data values */}
78
+ {w.name} · {w.organizationName}
79
+ </Label>
80
+ </div>
81
+ ))}
82
+ </RadioGroup>
83
+ </div>
84
+ ) : null}
85
+ {chosen ? (
86
+ <div className="flex flex-col gap-3">
87
+ <Text as="p" variant="body-sm" className="font-semibold">
88
+ {text.scopesLegend}
89
+ </Text>
90
+ {chosen.offered.length === 0 ? (
91
+ <Text as="p" variant="body-sm" tone="muted">
92
+ {text.noScopes}
93
+ </Text>
94
+ ) : (
95
+ chosen.offered.map((o) => (
96
+ <div key={o.scope} className="flex min-h-11 items-center gap-3">
97
+ <Checkbox
98
+ id={`mcp-consent-scope-${o.permission}`}
99
+ name="scopes"
100
+ value={o.scope}
101
+ defaultChecked={false}
102
+ data-testid={`mcp-consent-scope-${o.permission}`}
103
+ />
104
+ <Label htmlFor={`mcp-consent-scope-${o.permission}`}>{o.label}</Label>
105
+ </div>
106
+ ))
107
+ )}
108
+ <Text as="p" variant="body-sm" tone="muted" data-testid="mcp-consent-member-reads">
109
+ {text.memberReads}
110
+ </Text>
111
+ </div>
112
+ ) : null}
113
+ <div className="flex flex-wrap gap-3">
114
+ {workspaces.length > 0 ? (
115
+ <Button
116
+ type="submit"
117
+ name="decision"
118
+ value="allow"
119
+ size="big"
120
+ disabled={!chosen}
121
+ data-testid="mcp-consent-allow"
122
+ >
123
+ {text.allow}
124
+ </Button>
125
+ ) : null}
126
+ <Button type="submit" name="decision" value="deny" variant="secondary" size="big" data-testid="mcp-consent-deny">
127
+ {text.deny}
128
+ </Button>
129
+ </div>
130
+ </form>
131
+ );
132
+ }
@@ -0,0 +1,51 @@
1
+ "use server";
2
+
3
+ /**
4
+ * `decideConsent` — the consent page's Allow and Deny (#10357 D6).
5
+ *
6
+ * Hands the person's choice to AILK's `POST /mcp/consent` in-process
7
+ * (`auth.api.mcpConsent`), which checks the session, the signed consent
8
+ * cookie and the role bound on every scope, and answers the client's own
9
+ * callback URL. Next's Server Action origin check plus that cookie match make
10
+ * a cross-site submit fail. A refusal returns to the page, which then renders
11
+ * what is still true.
12
+ */
13
+ import { localeHref } from "@working-theory/ui";
14
+ import { headers } from "next/headers";
15
+ import { redirect } from "next/navigation";
16
+
17
+ import { auth } from "../../../../auth";
18
+
19
+ const LOCALE_PATTERN = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
20
+
21
+ export async function decideConsent(formData: FormData): Promise<void> {
22
+ const field = (name: string): string => {
23
+ const value = formData.get(name);
24
+ return typeof value === "string" ? value : "";
25
+ };
26
+ const consentCode = field("consent_code");
27
+ // The fallback redirect is built from it, so only a locale-shaped value
28
+ // counts: `/evil.example` would make `//evil.example/…`.
29
+ const locale = LOCALE_PATTERN.test(field("locale")) ? field("locale") : "en";
30
+ const accept = formData.get("decision") === "allow";
31
+ const workspaceId = formData.get("workspace_id");
32
+ const scopes = formData.getAll("scopes").filter((s): s is string => typeof s === "string");
33
+
34
+ let redirectURI: string | null;
35
+ try {
36
+ const answer = await auth.api.mcpConsent({
37
+ headers: await headers(),
38
+ body: {
39
+ consent_code: consentCode,
40
+ accept,
41
+ ...(accept && typeof workspaceId === "string" ? { workspace_id: workspaceId, scopes } : {}),
42
+ },
43
+ });
44
+ redirectURI = answer.redirectURI;
45
+ } catch {
46
+ redirectURI = null;
47
+ }
48
+ redirect(
49
+ redirectURI ?? localeHref(locale, `/consent?consent_code=${encodeURIComponent(consentCode)}`),
50
+ );
51
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `/[locale]/consent` — the MCP consent step (#10357 D6).
3
+ *
4
+ * The `mcp` plugin sends every authorization here (AILK's before-hook forces
5
+ * `prompt=consent`, D2) with a `consent_code` and a signed cookie. The page
6
+ * reads ONLY `consent_code` from its query — never `client_id` or `scope`,
7
+ * which the client controls — and asks AILK's `GET /mcp/consent-details`
8
+ * in-process. That endpoint checks the session, the cookie and that the code
9
+ * was issued to this person, and answers the client's self-claimed name,
10
+ * where the code will go, and each workspace with the scopes the person's
11
+ * role there may approve. Any refusal, or no session, renders the
12
+ * `unavailable` line and no form.
13
+ *
14
+ * It names who is signed in (with a sign-out back to this page), names the
15
+ * client as the client names itself — an unchecked claim — and its redirect
16
+ * origin, then hands the workspaces to the `ConsentForm` island. `noindex`,
17
+ * `referrer: same-origin` (the `account/connect` precedent). Framed by
18
+ * `AuthShell` like sign-in: `"consent"` is an `AUTH_SEGMENTS` entry.
19
+ */
20
+ import { localeHref } from "@working-theory/ui";
21
+ import { Button, Heading, Text } from "@working-theory/ui/primitives";
22
+ import { SCOPE_GRANT_MAP, type McpScope } from "@working-theory/validation";
23
+ import type { Metadata } from "next";
24
+ import { headers } from "next/headers";
25
+ import { getTranslations } from "next-intl/server";
26
+ import type { ReactElement } from "react";
27
+
28
+ import { auth } from "../../../../auth";
29
+ import { SignOutTrigger } from "../../../../components/sign-out-trigger";
30
+
31
+ import { ConsentForm, type ConsentWorkspace } from "./consent-form";
32
+
33
+ interface Props {
34
+ params: Promise<{ locale: string }>;
35
+ searchParams: Promise<{ consent_code?: string | string[] }>;
36
+ }
37
+
38
+ interface ConsentDetails {
39
+ client: { name: string; redirectOrigin: string };
40
+ workspaces: Array<{ id: string; name: string; organizationName: string; offered: McpScope[] }>;
41
+ }
42
+
43
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
44
+ const { locale } = await params;
45
+ const t = await getTranslations({ locale, namespace: "mcpConsent" });
46
+ return {
47
+ title: t("metaTitle"),
48
+ robots: { index: false, follow: false },
49
+ referrer: "same-origin",
50
+ };
51
+ }
52
+
53
+ /** The details and the signed-in email, or `null` on any refusal. */
54
+ async function readDetails(consentCode: string): Promise<{ details: ConsentDetails; email: string } | null> {
55
+ try {
56
+ const requestHeaders = await headers();
57
+ const session = await auth.api.getSession({ headers: requestHeaders });
58
+ if (!session) return null;
59
+ const details = (await auth.api.mcpConsentDetails({
60
+ headers: requestHeaders,
61
+ query: { consent_code: consentCode },
62
+ })) as ConsentDetails;
63
+ return { details, email: session.user.email };
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export default async function ConsentPage({ params, searchParams }: Props): Promise<ReactElement> {
70
+ const { locale } = await params;
71
+ const { consent_code: raw } = await searchParams;
72
+ const t = await getTranslations({ locale, namespace: "mcpConsent" });
73
+ const consentCode = typeof raw === "string" ? raw : "";
74
+ const read = consentCode ? await readDetails(consentCode) : null;
75
+
76
+ if (!read) {
77
+ return (
78
+ <div className="flex flex-col gap-4" data-testid="mcp-consent-unavailable">
79
+ <Heading level={1} visual={3}>
80
+ {t("heading")}
81
+ </Heading>
82
+ <Text as="p" variant="body-sm" tone="muted">
83
+ {t("unavailable")}
84
+ </Text>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ const { details, email } = read;
90
+ const here = localeHref(locale, `/consent?consent_code=${encodeURIComponent(consentCode)}`);
91
+ const workspaces: ConsentWorkspace[] = details.workspaces.map((w) => ({
92
+ id: w.id,
93
+ name: w.name,
94
+ organizationName: w.organizationName,
95
+ offered: w.offered.map((scope) => {
96
+ const permission = SCOPE_GRANT_MAP[scope];
97
+ return { scope, permission, label: t(`scopes.${permission}`) };
98
+ }),
99
+ }));
100
+
101
+ return (
102
+ <div className="flex flex-col gap-6" data-testid="mcp-consent">
103
+ <div className="flex flex-col gap-2">
104
+ <Heading level={1} visual={3}>
105
+ {t("heading")}
106
+ </Heading>
107
+ <Text as="p" variant="body-sm" data-testid="mcp-consent-signed-in">
108
+ {t("signedInAs", { email })}
109
+ </Text>
110
+ <SignOutTrigger homeHref={here}>
111
+ <Button type="button" variant="link" data-testid="mcp-consent-sign-out">
112
+ {t("signOut")}
113
+ </Button>
114
+ </SignOutTrigger>
115
+ </div>
116
+ <div className="flex flex-col gap-2">
117
+ <Text as="p" variant="body-sm" data-testid="mcp-consent-client">
118
+ {t("clientClaim", { name: details.client.name })}
119
+ </Text>
120
+ <Text as="p" variant="body-sm" tone="muted" data-testid="mcp-consent-redirect-origin">
121
+ {t("returnsTo", { origin: details.client.redirectOrigin })}
122
+ </Text>
123
+ </div>
124
+ {workspaces.length === 0 ? (
125
+ <Text as="p" variant="body-sm" tone="muted" data-testid="mcp-consent-no-workspace">
126
+ {t("noWorkspace")}
127
+ </Text>
128
+ ) : null}
129
+ <ConsentForm
130
+ consentCode={consentCode}
131
+ locale={locale}
132
+ workspaces={workspaces}
133
+ text={{
134
+ workspaceLegend: t("workspaceLegend"),
135
+ scopesLegend: t("scopesLegend"),
136
+ memberReads: t("memberReads"),
137
+ noScopes: t("noScopes"),
138
+ allow: t("allow"),
139
+ deny: t("deny"),
140
+ }}
141
+ />
142
+ </div>
143
+ );
144
+ }
@@ -50,6 +50,33 @@ jest.mock("../../../../../lib/paid-sections.generated", () => {
50
50
  };
51
51
  });
52
52
 
53
+ // #10552 — an MDX body's `## ` lines render through the page's own component
54
+ // map, so a heading's `id` is the real heading adapter's (AC-4).
55
+ jest.mock("next-mdx-remote/rsc", () => {
56
+ const { createElement, Fragment } = jest.requireActual<typeof import("react")>("react");
57
+ return {
58
+ MDXRemote: ({
59
+ source,
60
+ components,
61
+ }: {
62
+ source: string;
63
+ components: Record<string, React.ComponentType<{ children?: React.ReactNode }>>;
64
+ }) =>
65
+ createElement(
66
+ Fragment,
67
+ null,
68
+ ...source
69
+ .split("\n")
70
+ .filter((line) => line.trim() !== "")
71
+ .map((line, i) =>
72
+ line.startsWith("## ")
73
+ ? createElement(components["h2"]!, { key: i }, line.slice(3))
74
+ : createElement("p", { key: i }, line),
75
+ ),
76
+ ),
77
+ };
78
+ });
79
+
53
80
  jest.mock("@working-theory/ui", () => ({
54
81
  // #7789: renderPage's fallback page head renders inside the section Container.
55
82
  Container: ({ children }: { children?: React.ReactNode }) =>
@@ -60,10 +87,12 @@ jest.mock("@working-theory/ui", () => ({
60
87
  Heading: ({
61
88
  children,
62
89
  level,
90
+ id,
63
91
  }: {
64
92
  children: React.ReactNode;
65
93
  level: number;
66
- }): React.ReactElement => React.createElement(`h${level}`, null, children),
94
+ id?: string;
95
+ }): React.ReactElement => React.createElement(`h${level}`, id ? { id } : null, children),
67
96
  SectionRenderer: ({ sections }: { sections: unknown[] }) =>
68
97
  React.createElement("div", {
69
98
  "data-testid": "section-renderer",
@@ -73,7 +102,8 @@ jest.mock("@working-theory/ui", () => ({
73
102
 
74
103
  import { notFound } from "next/navigation";
75
104
 
76
- import DetailPage from "../page";
105
+ import { listingExtension } from "../../../../../lib/listings";
106
+ import DetailPage, { generateMetadata } from "../page";
77
107
 
78
108
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
79
109
 
@@ -120,7 +150,7 @@ describe("[slug]/[detail] — the Free /meet/<slug> placeholder route (#6897 D2)
120
150
  expect(mockNotFound).not.toHaveBeenCalled();
121
151
  });
122
152
 
123
- it("404s for another hub — the guard fires before getMdxSource is ever called", async () => {
153
+ it("404s for another hub — no BookCall source is ever read", async () => {
124
154
  const getMdxSource = jest.fn().mockResolvedValue(makeSource("book-a-call"));
125
155
 
126
156
  await expect(
@@ -130,7 +160,9 @@ describe("[slug]/[detail] — the Free /meet/<slug> placeholder route (#6897 D2)
130
160
  }),
131
161
  ).rejects.toThrow("NEXT_NOT_FOUND");
132
162
  expect(mockNotFound).toHaveBeenCalledTimes(1);
133
- expect(getMdxSource).not.toHaveBeenCalled();
163
+ // #10552 — the program-event branch reads the Event source first; the
164
+ // meet branch's BookCall read never runs for another hub.
165
+ expect(getMdxSource).not.toHaveBeenCalledWith("BookCall", expect.anything(), expect.anything());
134
166
  });
135
167
 
136
168
  it("404s for an unknown detail slug under the meet hub", async () => {
@@ -248,3 +280,120 @@ describe("[slug]/[detail] — a listicle entry at /<landingPage>/<slug> (#7521)"
248
280
  expect(global.fetch).not.toHaveBeenCalled();
249
281
  });
250
282
  });
283
+
284
+ // ── #10552 — an Event under a program at /<program>/<event> ─────────────────
285
+ describe("[slug]/[detail] — an Event under a program at /<program>/<event> (#10552)", () => {
286
+ const landing = (slug: string, title: string) => ({
287
+ frontmatter: { type: "LandingPage" as const, slug, title, description: `${title}.` },
288
+ body: "",
289
+ });
290
+ const golf = (frontmatter: Record<string, unknown> = { parent: "younglife" }) => ({
291
+ frontmatter: {
292
+ type: "Event" as const,
293
+ slug: "golf-tournament",
294
+ title: "Golf Tournament 2026",
295
+ description: "Our annual golf tournament.",
296
+ publishedAt: "2026-06-12",
297
+ ...frontmatter,
298
+ },
299
+ body: "Tee off with us.\n\n## Sponsorship\n\nBecome a hole sponsor.",
300
+ });
301
+ const loader = (
302
+ pages: Record<string, ReturnType<typeof landing> | ReturnType<typeof golf>>,
303
+ ) => jest.fn(async (type: string, slug: string) => pages[`${type}/${slug}`] ?? null);
304
+ const params = (slug: string, detail = "golf-tournament") =>
305
+ Promise.resolve({ locale: "en", slug, detail });
306
+
307
+ let entryAt: jest.SpyInstance;
308
+ beforeEach(() => {
309
+ mockNotFound.mockClear();
310
+ entryAt = jest.spyOn(listingExtension, "entryAt");
311
+ });
312
+ afterEach(() => entryAt.mockRestore());
313
+
314
+ it("renders the Event with its Event JSON-LD and the program breadcrumb (AC-3)", async () => {
315
+ const getMdxSource = loader({
316
+ "LandingPage/younglife": landing("younglife", "Young Life"),
317
+ "Event/golf-tournament": golf(),
318
+ });
319
+
320
+ const { container } = render(await DetailPage({ params: params("younglife"), getMdxSource }));
321
+
322
+ expect(mockNotFound).not.toHaveBeenCalled();
323
+ const ld = JSON.parse(
324
+ container.querySelector("script[type='application/ld+json']")?.textContent ?? "{}",
325
+ ) as Record<string, unknown>;
326
+ const nodes = (ld["@graph"] ?? [ld]) as Array<Record<string, unknown>>;
327
+ const event = nodes
328
+ .map((node) => node["mainEntity"] as Record<string, unknown> | undefined)
329
+ .find((entity) => entity?.["@type"] === "Event");
330
+ expect(event).toMatchObject({ name: "Golf Tournament 2026", startDate: "2026-06-12" });
331
+ const crumbs = nodes.find((node) => node["@type"] === "BreadcrumbList")?.[
332
+ "itemListElement"
333
+ ] as Array<Record<string, unknown>>;
334
+ expect(crumbs[1]).toMatchObject({ name: "Young Life" });
335
+ expect(crumbs[2]?.["item"]).toMatch(/\/younglife\/golf-tournament$/);
336
+ expect(entryAt).not.toHaveBeenCalled();
337
+
338
+ const metadata = await generateMetadata({ params: params("younglife"), getMdxSource });
339
+ expect(metadata.title).toBe("Golf Tournament 2026");
340
+ });
341
+
342
+ it("offers add-to-calendar for Google, Outlook and Apple (.ics) at the program URL", async () => {
343
+ const getMdxSource = loader({
344
+ "LandingPage/younglife": landing("younglife", "Young Life"),
345
+ "Event/golf-tournament": golf(),
346
+ });
347
+
348
+ const { container } = render(await DetailPage({ params: params("younglife"), getMdxSource }));
349
+
350
+ const href = (id: string) =>
351
+ container.querySelector(`[data-testid='add-to-calendar-${id}']`)?.getAttribute("href") ?? "";
352
+ expect(href("google")).toMatch(/^https:\/\/calendar\.google\.com\//);
353
+ expect(href("outlook")).toMatch(/^https:\/\/outlook\./);
354
+ expect(href("apple")).toMatch(/^data:text\/calendar/);
355
+ expect(decodeURIComponent(href("apple"))).toContain("/younglife/golf-tournament");
356
+ });
357
+
358
+ it("supports a #sponsorship anchor from the body heading (AC-4)", async () => {
359
+ const getMdxSource = loader({
360
+ "LandingPage/younglife": landing("younglife", "Young Life"),
361
+ "Event/golf-tournament": golf(),
362
+ });
363
+
364
+ const { container } = render(await DetailPage({ params: params("younglife"), getMdxSource }));
365
+
366
+ const anchors = container.querySelectorAll("#sponsorship");
367
+ expect(anchors).toHaveLength(1);
368
+ expect(anchors[0]?.tagName).toBe("H2");
369
+ expect(anchors[0]?.textContent).toBe("Sponsorship");
370
+ });
371
+
372
+ it("serves it only under the program it names (AC-5)", async () => {
373
+ const cases: Array<[string, ReturnType<typeof loader>]> = [
374
+ [
375
+ "other",
376
+ loader({
377
+ "LandingPage/other": landing("other", "Other"),
378
+ "LandingPage/younglife": landing("younglife", "Young Life"),
379
+ "Event/golf-tournament": golf(),
380
+ }),
381
+ ],
382
+ ["younglife", loader({ "Event/golf-tournament": golf() })],
383
+ [
384
+ "younglife",
385
+ loader({
386
+ "LandingPage/younglife": landing("younglife", "Young Life"),
387
+ "Event/golf-tournament": golf({}),
388
+ }),
389
+ ],
390
+ ];
391
+ for (const [slug, getMdxSource] of cases) {
392
+ mockNotFound.mockClear();
393
+ await expect(DetailPage({ params: params(slug), getMdxSource })).rejects.toThrow(
394
+ "NEXT_NOT_FOUND",
395
+ );
396
+ expect(mockNotFound).toHaveBeenCalledTimes(1);
397
+ }
398
+ });
399
+ });
@@ -1,5 +1,6 @@
1
1
  // Two-segment URLs: the Free /meet/<slug> placeholder detail pages (#6897
2
- // D2), and a listicle directory's entry pages (#7521).
2
+ // D2), an Event under a program (#10552), and a listicle directory's entry
3
+ // pages (#7521).
3
4
  //
4
5
  // The meet hub: BookCall content served at /meet/<detail> by Next.js
5
6
  // static-segment precedence: wherever apps/web/app/[locale]/meet/ exists (the
@@ -10,6 +11,13 @@
10
11
  // 404s rather than rendering an empty-state placeholder — the same contract
11
12
  // `[slug]/page.tsx` documents).
12
13
  //
14
+ // A program's event: `/<program>/<event>` serves the Event MDX `<event>` when
15
+ // its frontmatter `parent` is `<program>` and `/<program>` is a LandingPage —
16
+ // before the listing extension, so an event URL costs no listing read. Any
17
+ // other combination falls through. A `parent` that names a static top-level
18
+ // route directory (`events`, `services`, …) or `meet` is unreachable here:
19
+ // Next.js serves the static directory first (spec 10552 § 5.3).
20
+ //
13
21
  // Every other hub: `/<landingPage>/<entry>` for a listicle whose landing page
14
22
  // is a generic one-segment page (`/tools` → `[slug]/page.tsx`). The listing
15
23
  // extension (`lib/listings.tsx`) renders the accepted entry, or the route
@@ -49,6 +57,18 @@ function hubPage(
49
57
  (await getMdxSource("BlogList", slug, locale)) !== null;
50
58
  }
51
59
 
60
+ /** The Event `detail` when it names `slug` as its program and `/<slug>` is a LandingPage. */
61
+ async function programEvent(
62
+ slug: string,
63
+ detail: string,
64
+ locale: string,
65
+ getMdxSource: typeof defaultGetMdxSource = loadMdxSource,
66
+ ): Promise<Awaited<ReturnType<typeof defaultGetMdxSource>>> {
67
+ const source = await getMdxSource("Event", detail, locale);
68
+ if (source === null || source.frontmatter.parent !== slug) return null;
69
+ return (await getMdxSource("LandingPage", slug, locale)) === null ? null : source;
70
+ }
71
+
52
72
  interface Props {
53
73
  params: Promise<{ locale: string; slug: string; detail: string }>;
54
74
  /** Test-only DI override for getMdxSource. Production callers leave undefined. */
@@ -57,12 +77,21 @@ interface Props {
57
77
 
58
78
  export async function generateMetadata({
59
79
  params,
60
- }: Pick<Props, "params">): Promise<Metadata> {
80
+ getMdxSource,
81
+ }: Pick<Props, "params" | "getMdxSource">): Promise<Metadata> {
61
82
  const { locale, slug, detail } = await params;
62
83
  // Mirrors renderPageMetadata's own missing-frontmatter → {} contract: a
63
84
  // hub with no listing entry is the same "nothing to title" case, not a
64
85
  // thrown 404 — generateMetadata cannot itself change the response status.
65
86
  if (slug !== MEET_PLACEHOLDER_HUB) {
87
+ if ((await programEvent(slug, detail, locale, getMdxSource)) !== null) {
88
+ return renderPageMetadata({
89
+ type: "Event",
90
+ slug: detail,
91
+ locale,
92
+ ...(getMdxSource !== undefined && { getMdxSource }),
93
+ });
94
+ }
66
95
  return (
67
96
  (await listingExtension.entryMetadataAt(
68
97
  `/${slug}/${detail}`,
@@ -81,6 +110,20 @@ export default async function Page({
81
110
  }: Props): Promise<ReactElement> {
82
111
  const { locale, slug, detail } = await params;
83
112
  if (slug !== MEET_PLACEHOLDER_HUB) {
113
+ const event = await programEvent(slug, detail, locale, getMdxSource);
114
+ if (event !== null) {
115
+ // A composed Event renders its sections; a prose one its MDX body — the
116
+ // rule events/[slug]/page.tsx applies.
117
+ const sections = (event.frontmatter as { sections?: unknown[] }).sections;
118
+ const composed = Array.isArray(sections) && sections.length > 0;
119
+ return renderPage({
120
+ type: "Event",
121
+ slug: detail,
122
+ locale,
123
+ ...(composed ? {} : { bodyMode: "mdx-body" as const }),
124
+ ...(getMdxSource !== undefined && { getMdxSource }),
125
+ });
126
+ }
84
127
  const entry = await listingExtension.entryAt(
85
128
  `/${slug}/${detail}`,
86
129
  detail,
@@ -329,7 +329,7 @@ describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug>
329
329
 
330
330
  it("sitemap.xml: one <loc> ending in /about/sam-henry, none ending in a bare /sam-henry", async () => {
331
331
  const stubAdapter = makeStubAdapter();
332
- const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [] });
332
+ const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [], discoverable: async () => true });
333
333
  const response = await GET();
334
334
  const text = await response.text();
335
335
  const locs = Array.from(text.matchAll(/<loc>([^<]*)<\/loc>/g)).map(
@@ -341,7 +341,7 @@ describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug>
341
341
 
342
342
  it("llms.txt: names a URL ending in /about/sam-henry", async () => {
343
343
  const stubAdapter = makeStubAdapter();
344
- const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [] });
344
+ const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [], discoverable: async () => true });
345
345
  const response = await GET();
346
346
  const text = await response.text();
347
347
  expect(text).toContain("URL: https://site.example/about/sam-henry");
@@ -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
+ });