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.
- package/README.md +71 -3
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/add-route.js +1 -0
- package/dist/cli.js +114 -1
- 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 +2 -2
- 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/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/surfaces.js +3 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +28 -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/.env.example +7 -0
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +46 -8
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +39 -5
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
- package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
- 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]/about/__tests__/about-routes.test.tsx +2 -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/api/auth/hub/continuation.ts +8 -3
- package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
- package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
- package/templates/apps/web/app/llms.txt/route.ts +26 -9
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
- package/templates/apps/web/app/openapi.json/route.ts +4 -0
- package/templates/apps/web/app/robots.txt/route.ts +18 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -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/auth-routes.ts +5 -3
- 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/mcp-oauth.ts +702 -0
- 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 +47 -14
- 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/database/seed.ts +6 -0
- package/templates/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- 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
|
-
|
|
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,
|
|
@@ -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
|
+
});
|