create-ailk 0.19.1 → 0.21.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 +84 -7
- package/component-catalog.json +184 -6
- package/component-catalog.md +20 -9
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/module-architecture.js +55 -2
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +76 -22
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +78 -2
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +123 -1
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/update.d.ts +53 -0
- package/dist/update.js +606 -0
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/README.md +26 -25
- package/templates/_gitignore +162 -0
- package/templates/apps/api/CLAUDE.md +18 -10
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
- package/templates/apps/api/src/middleware/auth.ts +8 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
- package/templates/apps/api/src/server.ts +14 -2
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +139 -44
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/CLAUDE.md +26 -1
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +48 -4
- package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +46 -4
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/jest.config.cjs +4 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +100 -9
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/deck-page.ts +186 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/messages/en.json +83 -9
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +48 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/__tests__/seed.test.ts +39 -2
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +38 -12
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +29 -0
- package/templates/project.yaml +6 -3
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /[locale]/account/privacy — User settings › Privacy (#8438 D14, NEW).
|
|
3
|
+
*
|
|
4
|
+
* RSC, outside the account layout's SignedInShell (#8430), in the member's
|
|
5
|
+
* settings frame (D7) — standalone, the Admin's shape. In DOM order:
|
|
6
|
+
*
|
|
7
|
+
* 1. account-privacy-summary — one line with a link to the site's
|
|
8
|
+
* privacy policy.
|
|
9
|
+
* 2. account-privacy-cookies — the current cookie-banner choice, read
|
|
10
|
+
* from the request's OWN `ailk-consent` cookie (never the ledger —
|
|
11
|
+
* the cookie stays the operative switch, C3), and a Change link
|
|
12
|
+
* (moved here from Notifications, #8438 D15) to
|
|
13
|
+
* `/privacy#cookie-settings`.
|
|
14
|
+
* 3. account-privacy-consents — one `ConsentSwitchList` row per
|
|
15
|
+
* `area: "data"` purpose, or the "no other data-use consents" line.
|
|
16
|
+
* 4. account-privacy-agreements — a read-only `AgreementList` of every
|
|
17
|
+
* agreement (`service_terms`, `account_terms`, …), or "None yet".
|
|
18
|
+
* 5. account-deletion — moved unchanged from General (#8438 D16).
|
|
19
|
+
*
|
|
20
|
+
* Sections 3 and 4 read `GET /v1/member/consents`; a failed read shows one
|
|
21
|
+
* line in their place and sections 1, 2 and 5 still render (D14).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { member } from "@working-theory/api-client";
|
|
25
|
+
import {
|
|
26
|
+
AccountSettingsFrame,
|
|
27
|
+
AccountSettingsPane,
|
|
28
|
+
AgreementList,
|
|
29
|
+
ConsentSwitchList,
|
|
30
|
+
} from "@working-theory/templates/account";
|
|
31
|
+
import { localeHref } from "@working-theory/ui";
|
|
32
|
+
import { Heading, Link, Text } from "@working-theory/ui/primitives";
|
|
33
|
+
import type { Metadata } from "next";
|
|
34
|
+
import { cookies } from "next/headers";
|
|
35
|
+
import { getTranslations } from "next-intl/server";
|
|
36
|
+
import type { ReactElement } from "react";
|
|
37
|
+
|
|
38
|
+
import { accountSession } from "../account-data";
|
|
39
|
+
import { DeletionRequest } from "../general/deletion-request";
|
|
40
|
+
import { loadProfileSource } from "../general/profile-source";
|
|
41
|
+
import { memberSettingsFrame, type Translate } from "../settings-frame";
|
|
42
|
+
|
|
43
|
+
import { setConsentAction } from "./actions";
|
|
44
|
+
|
|
45
|
+
/** The cookie banner's own choice cookie (`layout.tsx`'s `CONSENT_COOKIE_NAME`, #5557). */
|
|
46
|
+
const CONSENT_COOKIE_NAME = "ailk-consent";
|
|
47
|
+
|
|
48
|
+
interface Props {
|
|
49
|
+
params: Promise<{ locale: string }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
53
|
+
const { locale } = await params;
|
|
54
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
55
|
+
return { title: t("settings.privacy.title"), description: t("settings.privacy.lead") };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default async function AccountPrivacyPage({ params }: Props): Promise<ReactElement> {
|
|
59
|
+
const { locale } = await params;
|
|
60
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
61
|
+
const session = await accountSession(locale);
|
|
62
|
+
const frame = memberSettingsFrame(locale, t as unknown as Translate, "privacy");
|
|
63
|
+
|
|
64
|
+
// Unreachable behind the (authed) gate; a session that expired between
|
|
65
|
+
// the layout and the page renders the pane's title alone.
|
|
66
|
+
if (!session) {
|
|
67
|
+
return (
|
|
68
|
+
<AccountSettingsFrame {...frame} name="">
|
|
69
|
+
<AccountSettingsPane
|
|
70
|
+
id="account-settings-privacy"
|
|
71
|
+
title={t("settings.privacy.title")}
|
|
72
|
+
lead={t("settings.privacy.lead")}
|
|
73
|
+
>
|
|
74
|
+
{null}
|
|
75
|
+
</AccountSettingsPane>
|
|
76
|
+
</AccountSettingsFrame>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { ctx } = session;
|
|
81
|
+
const name = ctx.user.name || ctx.user.email;
|
|
82
|
+
const source = await loadProfileSource(ctx);
|
|
83
|
+
|
|
84
|
+
let cookieChoice: "accepted" | "declined" | null;
|
|
85
|
+
try {
|
|
86
|
+
const value = (await cookies()).get(CONSENT_COOKIE_NAME)?.value;
|
|
87
|
+
cookieChoice = value === "accepted" || value === "declined" ? value : null;
|
|
88
|
+
} catch {
|
|
89
|
+
cookieChoice = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let dataRows: { id: string; label: string; description?: string; granted: boolean; dateLine?: string }[] = [];
|
|
93
|
+
let agreementRows: { id: string; statement: string; version: number; acceptedLine?: string }[] = [];
|
|
94
|
+
let consentsAvailable = true;
|
|
95
|
+
try {
|
|
96
|
+
const res = await member.consents(ctx.init, { locale });
|
|
97
|
+
if (res.ok) {
|
|
98
|
+
dataRows = res.body.purposes
|
|
99
|
+
.filter((purpose) => purpose.area === "data")
|
|
100
|
+
.map((purpose) => ({
|
|
101
|
+
id: purpose.id,
|
|
102
|
+
label: purpose.statement,
|
|
103
|
+
granted: purpose.effective,
|
|
104
|
+
...(purpose.description ? { description: purpose.description } : {}),
|
|
105
|
+
...(purpose.state
|
|
106
|
+
? {
|
|
107
|
+
dateLine: purpose.state.granted
|
|
108
|
+
? t("settings.privacy.given", { date: formatDate(purpose.state.at, locale) })
|
|
109
|
+
: t("settings.privacy.withdrawn", { date: formatDate(purpose.state.at, locale) }),
|
|
110
|
+
}
|
|
111
|
+
: {}),
|
|
112
|
+
}));
|
|
113
|
+
agreementRows = res.body.purposes
|
|
114
|
+
.filter((purpose) => purpose.kind === "agreement")
|
|
115
|
+
.map((purpose) => ({
|
|
116
|
+
id: purpose.id,
|
|
117
|
+
statement: purpose.statement,
|
|
118
|
+
version: purpose.policyVersion,
|
|
119
|
+
...(purpose.state?.granted
|
|
120
|
+
? { acceptedLine: t("settings.privacy.accepted", { date: formatDate(purpose.state.at, locale) }) }
|
|
121
|
+
: {}),
|
|
122
|
+
}));
|
|
123
|
+
} else {
|
|
124
|
+
consentsAvailable = false;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
consentsAvailable = false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<AccountSettingsFrame {...frame} name={name}>
|
|
132
|
+
<AccountSettingsPane
|
|
133
|
+
id="account-settings-privacy"
|
|
134
|
+
title={t("settings.privacy.title")}
|
|
135
|
+
lead={t("settings.privacy.lead")}
|
|
136
|
+
>
|
|
137
|
+
<div data-testid="account-privacy-summary" className="flex flex-wrap items-baseline gap-1">
|
|
138
|
+
<Text as="span" variant="body-sm">
|
|
139
|
+
{t("settings.privacy.summary")}
|
|
140
|
+
</Text>
|
|
141
|
+
<Link href={localeHref(locale, "/privacy")} data-testid="account-privacy-policy">
|
|
142
|
+
{t("settings.privacy.policyLink")}
|
|
143
|
+
</Link>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div data-testid="account-privacy-cookies" className="flex flex-col gap-1">
|
|
147
|
+
<Heading level={2} visual={4} className="!text-body-sm !font-medium">
|
|
148
|
+
{t("settings.privacy.cookiesTitle")}
|
|
149
|
+
</Heading>
|
|
150
|
+
<div className="flex flex-wrap items-baseline gap-3">
|
|
151
|
+
<Text as="span" variant="body-sm" tone="muted">
|
|
152
|
+
{cookieChoice === "accepted"
|
|
153
|
+
? t("settings.privacy.cookiesAccepted")
|
|
154
|
+
: cookieChoice === "declined"
|
|
155
|
+
? t("settings.privacy.cookiesDeclined")
|
|
156
|
+
: t("settings.privacy.cookiesUnset")}
|
|
157
|
+
</Text>
|
|
158
|
+
<Link
|
|
159
|
+
href={localeHref(locale, "/privacy#cookie-settings")}
|
|
160
|
+
className="inline-flex min-h-6 items-center"
|
|
161
|
+
data-testid="account-cookie-settings"
|
|
162
|
+
>
|
|
163
|
+
{t("settings.privacy.cookiesChange")}
|
|
164
|
+
</Link>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{consentsAvailable ? (
|
|
169
|
+
<>
|
|
170
|
+
<div data-testid="account-privacy-consents" className="flex flex-col gap-2">
|
|
171
|
+
<Heading level={2} visual={4} className="!text-body-sm !font-medium">
|
|
172
|
+
{t("settings.privacy.consentsTitle")}
|
|
173
|
+
</Heading>
|
|
174
|
+
<ConsentSwitchList
|
|
175
|
+
rows={dataRows}
|
|
176
|
+
onChange={setConsentAction}
|
|
177
|
+
emptyLine={t("settings.privacy.consentsEmpty")}
|
|
178
|
+
testIdPrefix="account-privacy-data-consent"
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div data-testid="account-privacy-agreements" className="flex flex-col gap-2">
|
|
183
|
+
<Heading level={2} visual={4} className="!text-body-sm !font-medium">
|
|
184
|
+
{t("settings.privacy.agreementsTitle")}
|
|
185
|
+
</Heading>
|
|
186
|
+
<AgreementList
|
|
187
|
+
rows={agreementRows}
|
|
188
|
+
notAcceptedLine={t("settings.privacy.agreementsEmpty")}
|
|
189
|
+
emptyLine={t("settings.privacy.agreementsEmpty")}
|
|
190
|
+
testIdPrefix="account-privacy-agreement"
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
</>
|
|
194
|
+
) : (
|
|
195
|
+
<span data-testid="account-privacy-unavailable">
|
|
196
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
197
|
+
{t("settings.privacy.unavailable")}
|
|
198
|
+
</Text>
|
|
199
|
+
</span>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
<div data-testid="account-deletion">
|
|
203
|
+
<DeletionRequest
|
|
204
|
+
via={source.via}
|
|
205
|
+
supportHref={localeHref(locale, "/account/support")}
|
|
206
|
+
labels={{
|
|
207
|
+
lead: t("settings.privacy.deletionLead"),
|
|
208
|
+
action: t("settings.privacy.deletion"),
|
|
209
|
+
sentSite: t("settings.privacy.deletionSentSite"),
|
|
210
|
+
sentHub: t("settings.privacy.deletionSentHub"),
|
|
211
|
+
unverified: t("settings.privacy.deletionUnverified"),
|
|
212
|
+
unavailable: t("settings.privacy.deletionUnavailable"),
|
|
213
|
+
error: t("settings.privacy.deletionError"),
|
|
214
|
+
viaSupport: t("settings.privacy.deletionViaSupport"),
|
|
215
|
+
supportLink: t("settings.privacy.deletionSupportLink"),
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
</AccountSettingsPane>
|
|
220
|
+
</AccountSettingsFrame>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function formatDate(iso: string, locale: string): string {
|
|
225
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(iso));
|
|
226
|
+
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import type { AccountSettingsFrameProps, AccountSettingsTab } from "@working-theory/templates/account";
|
|
26
26
|
import { localeHref } from "@working-theory/ui";
|
|
27
27
|
import { Heading, Icon, Text } from "@working-theory/ui/primitives";
|
|
28
|
-
import { Bell, CreditCard, ShieldCheck, UserRound } from "lucide-react";
|
|
28
|
+
import { Bell, CreditCard, Lock, ShieldCheck, UserRound } from "lucide-react";
|
|
29
29
|
import type { ReactElement, ReactNode } from "react";
|
|
30
30
|
|
|
31
31
|
/** The `next-intl` translator General/Security/Notifications/Billing each
|
|
@@ -36,12 +36,14 @@ import type { ReactElement, ReactNode } from "react";
|
|
|
36
36
|
* own `t` with `as unknown as Translate`. */
|
|
37
37
|
export type Translate = (key: string, values?: Record<string, unknown>) => string;
|
|
38
38
|
|
|
39
|
-
/** User settings' tabs, in the reference's order (#8339 D7
|
|
40
|
-
* route that renders outside the
|
|
39
|
+
/** User settings' tabs, in the reference's order (#8339 D7; #8438 D14 adds
|
|
40
|
+
* Privacy before Billing) — each one a route that renders outside the
|
|
41
|
+
* account shell (#8430, #8441). */
|
|
41
42
|
export const SETTINGS_ROUTES = [
|
|
42
43
|
{ key: "general", path: "/account/general", titleKey: "settings.general.title", icon: UserRound },
|
|
43
44
|
{ key: "security", path: "/account/security", titleKey: "settings.security.title", icon: ShieldCheck },
|
|
44
45
|
{ key: "notifications", path: "/account/notifications", titleKey: "settings.notifications.title", icon: Bell },
|
|
46
|
+
{ key: "privacy", path: "/account/privacy", titleKey: "settings.privacy.title", icon: Lock },
|
|
45
47
|
{ key: "billing", path: "/account/billing", titleKey: "settings.billing.title", icon: CreditCard },
|
|
46
48
|
] as const;
|
|
47
49
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A person's own page (#8650 D3). Reads the Person MDX for the given slug and
|
|
2
|
+
// emits a JSON-LD <script>. Unknown slugs 404 through renderPage. Renders
|
|
3
|
+
// `sections[]` when the frontmatter carries them (the /for composed
|
|
4
|
+
// pattern), MDX prose otherwise.
|
|
5
|
+
import {
|
|
6
|
+
getMdxSource as defaultGetMdxSource,
|
|
7
|
+
renderPage,
|
|
8
|
+
renderPageMetadata,
|
|
9
|
+
} from "@working-theory/page-renderer";
|
|
10
|
+
import type { Metadata } from "next";
|
|
11
|
+
import type { ReactElement } from "react";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
params: Promise<{ locale: string; slug: string }>;
|
|
15
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function generateMetadata({
|
|
19
|
+
params,
|
|
20
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
21
|
+
const { locale, slug } = await params;
|
|
22
|
+
return renderPageMetadata({ type: "Person", slug, locale });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default async function Page({
|
|
26
|
+
params,
|
|
27
|
+
getMdxSource = defaultGetMdxSource,
|
|
28
|
+
}: Props): Promise<ReactElement> {
|
|
29
|
+
const { locale, slug } = await params;
|
|
30
|
+
// Read once to pick the body mode. A null source still goes to renderPage,
|
|
31
|
+
// which owns the unknown-slug branch (#7537).
|
|
32
|
+
const source = await getMdxSource("Person", slug, locale);
|
|
33
|
+
|
|
34
|
+
const composed =
|
|
35
|
+
source !== null &&
|
|
36
|
+
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
37
|
+
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) > 0;
|
|
38
|
+
|
|
39
|
+
return renderPage({
|
|
40
|
+
type: "Person",
|
|
41
|
+
slug,
|
|
42
|
+
locale,
|
|
43
|
+
...(composed ? {} : { bodyMode: "mdx-body" as const }),
|
|
44
|
+
getMdxSource: async () => source,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The about routes (#8650 spec, AC-1 through AC-4):
|
|
3
|
+
*
|
|
4
|
+
* - AC-1: `/about/<slug>` renders a `ProfilePage` whose `mainEntity` is a
|
|
5
|
+
* `Person`, valid under the schema validator, with an About breadcrumb; an
|
|
6
|
+
* unknown slug 404s through `renderPage`.
|
|
7
|
+
* - AC-2: `/about` lists every `Person` page as a generated `team` section,
|
|
8
|
+
* each member linking to its own `/about/<slug>` page, before a trailing
|
|
9
|
+
* `cta` section.
|
|
10
|
+
* - AC-3: the routes map, sitemap and `llms.txt` all advertise a `Person`
|
|
11
|
+
* page at `/about/<slug>`, never a bare `/<slug>`.
|
|
12
|
+
* - AC-4: with zero `Person` pages, `/about` renders byte-identical to
|
|
13
|
+
* calling `renderPage` directly (C5 — a fresh scaffold is such a site).
|
|
14
|
+
*
|
|
15
|
+
* MDX sources are inline stubs (no fs reads) for AC-2/AC-4, and a real
|
|
16
|
+
* `createMdxAdapter` over a temp content root for AC-1 (the spec's own
|
|
17
|
+
* method) — both run unchanged inside a scaffold.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { render } from "@testing-library/react";
|
|
24
|
+
import { createMdxAdapter } from "@working-theory/content-adapters";
|
|
25
|
+
import * as pageRenderer from "@working-theory/page-renderer";
|
|
26
|
+
import { validatePageGraph } from "@working-theory/schema-validator";
|
|
27
|
+
import React from "react";
|
|
28
|
+
|
|
29
|
+
jest.mock("next/navigation", () => ({
|
|
30
|
+
__esModule: true,
|
|
31
|
+
notFound: jest.fn(() => {
|
|
32
|
+
throw new Error("NEXT_NOT_FOUND");
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
jest.mock("next-mdx-remote/rsc", () => ({
|
|
37
|
+
__esModule: true,
|
|
38
|
+
MDXRemote: ({ source }: { source: string }) =>
|
|
39
|
+
React.createElement("div", { "data-testid": "mdx-rendered" }, source),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
import AboutPage from "../page";
|
|
43
|
+
import AboutPersonPage from "../[slug]/page";
|
|
44
|
+
import { createSitemapHandler } from "../../../sitemap.xml/route";
|
|
45
|
+
import { createLlmsHandler } from "../../../llms.txt/route";
|
|
46
|
+
import type { ContentAdapter } from "@working-theory/content-adapters";
|
|
47
|
+
|
|
48
|
+
type JsonLd = Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
function jsonLdScripts(container: HTMLElement): JsonLd[] {
|
|
51
|
+
return [
|
|
52
|
+
...container.querySelectorAll("script[type='application/ld+json']"),
|
|
53
|
+
].map((s) => JSON.parse(s.textContent ?? "") as JsonLd);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("about/[slug] — a person's own page (AC-1)", () => {
|
|
57
|
+
let root: string;
|
|
58
|
+
|
|
59
|
+
beforeAll(() => {
|
|
60
|
+
root = mkdtempSync(join(tmpdir(), "ailk-8650-"));
|
|
61
|
+
mkdirSync(join(root, "en", "Person"), { recursive: true });
|
|
62
|
+
writeFileSync(
|
|
63
|
+
join(root, "en", "Person", "sam-henry.mdx"),
|
|
64
|
+
[
|
|
65
|
+
"---",
|
|
66
|
+
"type: Person",
|
|
67
|
+
"slug: sam-henry",
|
|
68
|
+
"title: Sam Henry",
|
|
69
|
+
"description: Co-founder and partner.",
|
|
70
|
+
"schema:",
|
|
71
|
+
" jobTitle: Partner",
|
|
72
|
+
"---",
|
|
73
|
+
"",
|
|
74
|
+
"Sam Henry, co-founder and partner.",
|
|
75
|
+
"",
|
|
76
|
+
].join("\n"),
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterAll(() => {
|
|
81
|
+
rmSync(root, { recursive: true, force: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("emits one ProfilePage graph whose Person carries @id #person, valid under the schema validator", async () => {
|
|
85
|
+
const adapter = createMdxAdapter({ contentRoot: root, defaultLocale: "en" });
|
|
86
|
+
const element = await AboutPersonPage({
|
|
87
|
+
params: Promise.resolve({ locale: "en", slug: "sam-henry" }),
|
|
88
|
+
getMdxSource: (type, slug, locale) =>
|
|
89
|
+
adapter.getPageSource(type, slug, locale),
|
|
90
|
+
});
|
|
91
|
+
const { container } = render(element);
|
|
92
|
+
const scripts = jsonLdScripts(container);
|
|
93
|
+
expect(scripts).toHaveLength(1);
|
|
94
|
+
const graph = scripts[0]!["@graph"] as JsonLd[];
|
|
95
|
+
expect(validatePageGraph(graph as never, "Person")).toEqual(
|
|
96
|
+
expect.objectContaining({ valid: true }),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const profile = graph.find((b) => b["@type"] === "ProfilePage")!;
|
|
100
|
+
const person = profile["mainEntity"] as JsonLd;
|
|
101
|
+
expect(person["@type"]).toBe("Person");
|
|
102
|
+
expect(person["@id"]).toBe("#person");
|
|
103
|
+
expect(person["name"]).toBe("Sam Henry");
|
|
104
|
+
expect(person["jobTitle"]).toBe("Partner");
|
|
105
|
+
|
|
106
|
+
const breadcrumb = graph.find((b) => b["@type"] === "BreadcrumbList")!;
|
|
107
|
+
const items = breadcrumb["itemListElement"] as Array<{ item: string }>;
|
|
108
|
+
expect(items[1]!.item).toMatch(/\/en\/about$/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("404s an unknown slug through renderPage, with no translations namespace", async () => {
|
|
112
|
+
const renderPageSpy = jest.spyOn(pageRenderer, "renderPage");
|
|
113
|
+
const adapter = createMdxAdapter({ contentRoot: root, defaultLocale: "en" });
|
|
114
|
+
await expect(
|
|
115
|
+
AboutPersonPage({
|
|
116
|
+
params: Promise.resolve({ locale: "en", slug: "nobody" }),
|
|
117
|
+
getMdxSource: (type, slug, locale) =>
|
|
118
|
+
adapter.getPageSource(type, slug, locale),
|
|
119
|
+
}),
|
|
120
|
+
).rejects.toThrow("NEXT_NOT_FOUND");
|
|
121
|
+
expect(renderPageSpy).toHaveBeenCalledTimes(1);
|
|
122
|
+
expect(
|
|
123
|
+
renderPageSpy.mock.calls[0]![0].translationsNamespace,
|
|
124
|
+
).toBeUndefined();
|
|
125
|
+
renderPageSpy.mockRestore();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const ABOUT_WITH_CTA = {
|
|
130
|
+
frontmatter: {
|
|
131
|
+
type: "AboutPage",
|
|
132
|
+
slug: "about",
|
|
133
|
+
title: "About",
|
|
134
|
+
description: "About us.",
|
|
135
|
+
sections: [
|
|
136
|
+
{
|
|
137
|
+
id: "intro",
|
|
138
|
+
type: "content-block",
|
|
139
|
+
data: { headline: "About us", body: "We build things." },
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "closing",
|
|
143
|
+
type: "cta",
|
|
144
|
+
data: {
|
|
145
|
+
headline: "Let's talk",
|
|
146
|
+
primaryCta: { label: "Contact us", href: "/contact" },
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
body: "",
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const SAM_SOURCE = {
|
|
155
|
+
frontmatter: {
|
|
156
|
+
type: "Person",
|
|
157
|
+
slug: "sam-henry",
|
|
158
|
+
title: "Sam Henry",
|
|
159
|
+
description: "Co-founder and partner.",
|
|
160
|
+
schema: { jobTitle: "Partner" },
|
|
161
|
+
},
|
|
162
|
+
body: "",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const WENDI_SOURCE = {
|
|
166
|
+
frontmatter: {
|
|
167
|
+
type: "Person",
|
|
168
|
+
slug: "wendi-henry",
|
|
169
|
+
title: "About Wendi",
|
|
170
|
+
description: "Co-founder and partner.",
|
|
171
|
+
schema: { name: "Wendi Henry" },
|
|
172
|
+
},
|
|
173
|
+
body: "",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
describe("about — the generated people list (AC-2)", () => {
|
|
177
|
+
it("lists every Person page as one team section, before the closing cta, linking to /about/<slug>", async () => {
|
|
178
|
+
const getMdxSource = jest.fn(
|
|
179
|
+
async (type: string, slug: string): Promise<unknown> => {
|
|
180
|
+
if (type === "AboutPage") return ABOUT_WITH_CTA;
|
|
181
|
+
if (type === "Person" && slug === "sam-henry") return SAM_SOURCE;
|
|
182
|
+
if (type === "Person" && slug === "wendi-henry") return WENDI_SOURCE;
|
|
183
|
+
return null;
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
const getMdxSlugs = jest
|
|
187
|
+
.fn()
|
|
188
|
+
.mockResolvedValue(["wendi-henry", "sam-henry", "ghost-member"]);
|
|
189
|
+
|
|
190
|
+
const element = await AboutPage({
|
|
191
|
+
params: Promise.resolve({ locale: "en" }),
|
|
192
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test double over a narrower DI surface than the real multi-type loader
|
|
193
|
+
getMdxSource: getMdxSource as any,
|
|
194
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test double
|
|
195
|
+
getMdxSlugs: getMdxSlugs as any,
|
|
196
|
+
});
|
|
197
|
+
const { container } = render(element);
|
|
198
|
+
|
|
199
|
+
const cards = container.querySelectorAll('[data-testid="team-member-card"]');
|
|
200
|
+
// The null-source "ghost-member" slug is skipped — two cards, not three.
|
|
201
|
+
expect(cards).toHaveLength(2);
|
|
202
|
+
|
|
203
|
+
const links = container.querySelectorAll('[data-testid^="member-page-link-"]');
|
|
204
|
+
const hrefs = [...links].map((l) => l.getAttribute("href"));
|
|
205
|
+
expect(hrefs).toEqual(["/about/sam-henry", "/about/wendi-henry"]);
|
|
206
|
+
for (const link of links) {
|
|
207
|
+
expect(link.textContent).toBe("View profile");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Wendi's card shows schema.name, not title; Sam's shows title (no
|
|
211
|
+
// schema.name override) with his jobTitle as the role.
|
|
212
|
+
expect(cards[0]!.textContent).toContain("Sam Henry");
|
|
213
|
+
expect(cards[0]!.textContent).toContain("Partner");
|
|
214
|
+
expect(cards[1]!.textContent).toContain("Wendi Henry");
|
|
215
|
+
expect(cards[1]!.textContent).not.toContain("About Wendi");
|
|
216
|
+
expect(cards[1]!.textContent).not.toContain("Partner");
|
|
217
|
+
|
|
218
|
+
// The team section sits before the closing cta section in document order.
|
|
219
|
+
const html = container.innerHTML;
|
|
220
|
+
expect(html.indexOf("team-member-card")).toBeLessThan(
|
|
221
|
+
html.indexOf("Let's talk"),
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("about — unchanged with zero Person pages (AC-4, C5)", () => {
|
|
227
|
+
it("renders byte-identical to a direct renderPage call", async () => {
|
|
228
|
+
const aboutSource = {
|
|
229
|
+
frontmatter: {
|
|
230
|
+
type: "AboutPage",
|
|
231
|
+
slug: "about",
|
|
232
|
+
title: "About",
|
|
233
|
+
description: "About us.",
|
|
234
|
+
},
|
|
235
|
+
body: "About us copy.",
|
|
236
|
+
};
|
|
237
|
+
const getMdxSource = jest.fn().mockResolvedValue(aboutSource);
|
|
238
|
+
const getMdxSlugs = jest.fn().mockResolvedValue([]);
|
|
239
|
+
|
|
240
|
+
const element = await AboutPage({
|
|
241
|
+
params: Promise.resolve({ locale: "en" }),
|
|
242
|
+
getMdxSource,
|
|
243
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test double over a narrower DI surface than the real multi-type loader
|
|
244
|
+
getMdxSlugs: getMdxSlugs as any,
|
|
245
|
+
});
|
|
246
|
+
const { container } = render(element);
|
|
247
|
+
|
|
248
|
+
const direct = await pageRenderer.renderPage({
|
|
249
|
+
type: "AboutPage",
|
|
250
|
+
slug: "about",
|
|
251
|
+
locale: "en",
|
|
252
|
+
translationsNamespace: "about",
|
|
253
|
+
getMdxSource,
|
|
254
|
+
});
|
|
255
|
+
const { container: directContainer } = render(direct);
|
|
256
|
+
|
|
257
|
+
expect(container.innerHTML).toBe(directContainer.innerHTML);
|
|
258
|
+
expect(
|
|
259
|
+
container.querySelectorAll('[data-testid="team-member-card"]'),
|
|
260
|
+
).toHaveLength(0);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug> (AC-3)", () => {
|
|
265
|
+
// The route handlers construct a Web `Response`, which jsdom (this file's
|
|
266
|
+
// test environment, needed by the React-rendering describes above) does
|
|
267
|
+
// not implement. A minimal same-shape stand-in, scoped to this describe,
|
|
268
|
+
// is enough for the one method these tests call: `.text()`.
|
|
269
|
+
let originalResponse: unknown;
|
|
270
|
+
|
|
271
|
+
beforeAll(() => {
|
|
272
|
+
originalResponse = (globalThis as { Response?: unknown }).Response;
|
|
273
|
+
class ResponseShim {
|
|
274
|
+
private readonly _body: string;
|
|
275
|
+
constructor(body: string, _init?: unknown) {
|
|
276
|
+
this._body = body;
|
|
277
|
+
}
|
|
278
|
+
async text(): Promise<string> {
|
|
279
|
+
return this._body;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
(globalThis as { Response: unknown }).Response = ResponseShim;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
afterAll(() => {
|
|
286
|
+
(globalThis as { Response?: unknown }).Response = originalResponse;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
function makeStubAdapter(): ContentAdapter {
|
|
290
|
+
// eslint-disable-next-line no-restricted-syntax -- jest.fn() stubs are not assignable to ContentAdapter's generic return types; partial stub is intentional, tracked in #1073
|
|
291
|
+
return {
|
|
292
|
+
listPages: jest.fn().mockImplementation(async (type: string) => {
|
|
293
|
+
if (type === "Person") {
|
|
294
|
+
return [
|
|
295
|
+
{
|
|
296
|
+
type: "Person",
|
|
297
|
+
slug: "sam-henry",
|
|
298
|
+
title: "Sam Henry",
|
|
299
|
+
description: "Co-founder and partner.",
|
|
300
|
+
},
|
|
301
|
+
];
|
|
302
|
+
}
|
|
303
|
+
return [];
|
|
304
|
+
}),
|
|
305
|
+
listDetail: jest.fn().mockResolvedValue([]),
|
|
306
|
+
getPageBySlug: jest.fn().mockResolvedValue(null),
|
|
307
|
+
getDetailBySlug: jest.fn().mockResolvedValue(null),
|
|
308
|
+
searchPages: jest.fn().mockResolvedValue([]),
|
|
309
|
+
extractStructuredData: jest.fn().mockResolvedValue(null),
|
|
310
|
+
createPage: jest.fn(),
|
|
311
|
+
updatePage: jest.fn(),
|
|
312
|
+
deletePage: jest.fn(),
|
|
313
|
+
getPageVersion: jest.fn(),
|
|
314
|
+
} as unknown as ContentAdapter;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
beforeEach(() => {
|
|
318
|
+
process.env.NEXT_PUBLIC_ENABLED_LOCALES = "en";
|
|
319
|
+
process.env.NEXT_PUBLIC_DEFAULT_LOCALE = "en";
|
|
320
|
+
process.env.NEXT_PUBLIC_BASE_URL = "https://site.example";
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
afterEach(() => {
|
|
324
|
+
delete process.env.NEXT_PUBLIC_ENABLED_LOCALES;
|
|
325
|
+
delete process.env.NEXT_PUBLIC_DEFAULT_LOCALE;
|
|
326
|
+
delete process.env.NEXT_PUBLIC_BASE_URL;
|
|
327
|
+
jest.resetAllMocks();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("sitemap.xml: one <loc> ending in /about/sam-henry, none ending in a bare /sam-henry", async () => {
|
|
331
|
+
const stubAdapter = makeStubAdapter();
|
|
332
|
+
const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter });
|
|
333
|
+
const response = await GET();
|
|
334
|
+
const text = await response.text();
|
|
335
|
+
const locs = Array.from(text.matchAll(/<loc>([^<]*)<\/loc>/g)).map(
|
|
336
|
+
(m) => m[1],
|
|
337
|
+
);
|
|
338
|
+
expect(locs).toContain("https://site.example/about/sam-henry");
|
|
339
|
+
expect(locs).not.toContain("https://site.example/sam-henry");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("llms.txt: names a URL ending in /about/sam-henry", async () => {
|
|
343
|
+
const stubAdapter = makeStubAdapter();
|
|
344
|
+
const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter });
|
|
345
|
+
const response = await GET();
|
|
346
|
+
const text = await response.text();
|
|
347
|
+
expect(text).toContain("URL: https://site.example/about/sam-henry");
|
|
348
|
+
expect(text).not.toContain("URL: https://site.example/sam-henry\n");
|
|
349
|
+
});
|
|
350
|
+
});
|