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,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /[locale]/unsubscribe (#8438 D17, AC-3 unsubscribe).
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
5
|
+
import userEvent from "@testing-library/user-event";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One interpolating translator for both halves of the page: the server half
|
|
9
|
+
* (`getTranslations`, the heading and lead) and the client island
|
|
10
|
+
* (`useTranslations`, whose done line formats `{description}` in). The
|
|
11
|
+
* workspace's mapped `next-intl` stub ignores ICU values, so it is replaced
|
|
12
|
+
* here, the way this file already replaces `next-intl/server`.
|
|
13
|
+
*/
|
|
14
|
+
function mockTranslator(ns: string): (key: string, values?: Record<string, unknown>) => string {
|
|
15
|
+
const msgs: unknown = require("../../../../messages/en.json");
|
|
16
|
+
const dig = (obj: unknown, path: string): unknown =>
|
|
17
|
+
path.split(".").reduce<unknown>(
|
|
18
|
+
(acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
|
|
19
|
+
obj,
|
|
20
|
+
);
|
|
21
|
+
const messages = dig(msgs, ns);
|
|
22
|
+
return (key, values) => {
|
|
23
|
+
const value = dig(messages, key);
|
|
24
|
+
if (typeof value !== "string") return `${ns}.${key}`;
|
|
25
|
+
return value.replace(/\{(\w+)\}/g, (m, name: string) =>
|
|
26
|
+
values && name in values ? String(values[name]) : m,
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
jest.mock("next-intl/server", () => ({
|
|
32
|
+
__esModule: true,
|
|
33
|
+
getTranslations: async (arg: string | { namespace?: string; locale?: string }) =>
|
|
34
|
+
mockTranslator(typeof arg === "string" ? arg : (arg.namespace ?? "")),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
jest.mock("next-intl", () => ({
|
|
38
|
+
__esModule: true,
|
|
39
|
+
useTranslations: (ns: string) => mockTranslator(ns),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const mockUnsubscribe = jest.fn();
|
|
43
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
44
|
+
__esModule: true,
|
|
45
|
+
consent: { unsubscribe: (...args: unknown[]) => mockUnsubscribe(...args) },
|
|
46
|
+
visitorForwardHeaders: () => undefined,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
jest.mock("next/headers", () => ({
|
|
50
|
+
__esModule: true,
|
|
51
|
+
headers: jest.fn().mockResolvedValue(new Headers()),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
import Page, { generateMetadata } from "../page";
|
|
55
|
+
|
|
56
|
+
const ok = <T,>(body: T) => ({ ok: true, body });
|
|
57
|
+
const refusal = (status: number) => ({ ok: false, status, body: null });
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
mockUnsubscribe.mockReset();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
async function renderPage(token: string | undefined): Promise<void> {
|
|
64
|
+
render(
|
|
65
|
+
await Page({
|
|
66
|
+
params: Promise.resolve({ locale: "en" }),
|
|
67
|
+
searchParams: Promise.resolve(token === undefined ? {} : { t: token }),
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("metadata — noindex, no-referrer (D17, SEC-8438-9)", () => {
|
|
73
|
+
it("is noindex and sends no-referrer", async () => {
|
|
74
|
+
const meta = await generateMetadata({
|
|
75
|
+
params: Promise.resolve({ locale: "en" }),
|
|
76
|
+
searchParams: Promise.resolve({}),
|
|
77
|
+
});
|
|
78
|
+
expect(meta.robots).toEqual({ index: false, follow: true });
|
|
79
|
+
expect(meta.referrer).toBe("no-referrer");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("the page performs no write on GET", () => {
|
|
84
|
+
it("renders with no call to unsubscribeAction", async () => {
|
|
85
|
+
await renderPage("tok_1");
|
|
86
|
+
expect(mockUnsubscribe).not.toHaveBeenCalled();
|
|
87
|
+
expect(screen.getByTestId("unsubscribe-page")).toBeInTheDocument();
|
|
88
|
+
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Unsubscribe");
|
|
89
|
+
expect(screen.getByText("Stop these emails?")).toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("with no token", () => {
|
|
94
|
+
it("shows the invalid-link line and no button", async () => {
|
|
95
|
+
await renderPage(undefined);
|
|
96
|
+
expect(screen.getByTestId("unsubscribe-invalid")).toHaveTextContent("This link isn't valid.");
|
|
97
|
+
expect(screen.queryByTestId("unsubscribe-button")).not.toBeInTheDocument();
|
|
98
|
+
expect(mockUnsubscribe).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("clicking Unsubscribe", () => {
|
|
103
|
+
it("a changed answer shows the done line with the purpose's description", async () => {
|
|
104
|
+
mockUnsubscribe.mockResolvedValue(
|
|
105
|
+
ok({ purpose: { id: "marketing_email", description: "product news and offers" }, changed: true }),
|
|
106
|
+
);
|
|
107
|
+
await renderPage("tok_1");
|
|
108
|
+
await userEvent.click(screen.getByTestId("unsubscribe-button"));
|
|
109
|
+
expect(mockUnsubscribe).toHaveBeenCalledWith({ token: "tok_1" });
|
|
110
|
+
expect(await screen.findByTestId("unsubscribe-done")).toHaveTextContent(
|
|
111
|
+
"You're unsubscribed from product news and offers.",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("changed: false shows the already-unsubscribed line, with no second write implied", async () => {
|
|
116
|
+
mockUnsubscribe.mockResolvedValue(
|
|
117
|
+
ok({ purpose: { id: "marketing_email", description: "product news and offers" }, changed: false }),
|
|
118
|
+
);
|
|
119
|
+
await renderPage("tok_1");
|
|
120
|
+
await userEvent.click(screen.getByTestId("unsubscribe-button"));
|
|
121
|
+
expect(await screen.findByTestId("unsubscribe-already")).toHaveTextContent(
|
|
122
|
+
"You were already unsubscribed.",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("any 400 shows the invalid-link line", async () => {
|
|
127
|
+
mockUnsubscribe.mockResolvedValue(refusal(400));
|
|
128
|
+
await renderPage("tok_1");
|
|
129
|
+
await userEvent.click(screen.getByTestId("unsubscribe-button"));
|
|
130
|
+
expect(await screen.findByTestId("unsubscribe-invalid")).toHaveTextContent("This link isn't valid.");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("a 429 (rate-limited) shows the unavailable line, never the invalid-link line", async () => {
|
|
134
|
+
mockUnsubscribe.mockResolvedValue(refusal(429));
|
|
135
|
+
await renderPage("tok_1");
|
|
136
|
+
await userEvent.click(screen.getByTestId("unsubscribe-button"));
|
|
137
|
+
expect(await screen.findByTestId("unsubscribe-unavailable")).toHaveTextContent(
|
|
138
|
+
"Unsubscribing isn't available right now.",
|
|
139
|
+
);
|
|
140
|
+
expect(screen.queryByTestId("unsubscribe-invalid")).not.toBeInTheDocument();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("a 5xx shows the unavailable line", async () => {
|
|
144
|
+
mockUnsubscribe.mockResolvedValue(refusal(503));
|
|
145
|
+
await renderPage("tok_1");
|
|
146
|
+
await userEvent.click(screen.getByTestId("unsubscribe-button"));
|
|
147
|
+
expect(await screen.findByTestId("unsubscribe-unavailable")).toHaveTextContent(
|
|
148
|
+
"Unsubscribing isn't available right now.",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("a thrown error shows the unavailable line, never a crash", async () => {
|
|
153
|
+
mockUnsubscribe.mockRejectedValue(new TypeError("fetch failed"));
|
|
154
|
+
await renderPage("tok_1");
|
|
155
|
+
await userEvent.click(screen.getByTestId("unsubscribe-button"));
|
|
156
|
+
expect(await screen.findByTestId("unsubscribe-unavailable")).toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /[locale]/unsubscribe — the one unsubscribe landing every signed link
|
|
3
|
+
* calls (#8438 D17, NEW). Public, `noindex`, `referrer: no-referrer` so the
|
|
4
|
+
* `?t=` token never leaks through a Referer header off this page (D17,
|
|
5
|
+
* SEC-8438-9).
|
|
6
|
+
*
|
|
7
|
+
* RSC: reads `?t=` and renders the heading, the lead and the one
|
|
8
|
+
* interactive piece (`UnsubscribeButton`, "use client") that posts
|
|
9
|
+
* `unsubscribeAction(token)` ON CLICK — never on this GET, so a mail
|
|
10
|
+
* scanner's prefetch writes nothing. The island gets the token and nothing
|
|
11
|
+
* else: it reads its own strings, because anything callable handed across
|
|
12
|
+
* this server→client edge fails Flight serialization (the scaffold matrix's
|
|
13
|
+
* `GET /unsubscribe -> 500`; `__tests__/unsubscribe-rsc-boundary.test.tsx`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Heading, Text } from "@working-theory/ui/primitives";
|
|
17
|
+
import type { Metadata } from "next";
|
|
18
|
+
import { getTranslations } from "next-intl/server";
|
|
19
|
+
import type { ReactElement } from "react";
|
|
20
|
+
|
|
21
|
+
import { UnsubscribeButton } from "./unsubscribe-button";
|
|
22
|
+
|
|
23
|
+
interface Props {
|
|
24
|
+
params: Promise<{ locale: string }>;
|
|
25
|
+
searchParams: Promise<{ t?: string }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
29
|
+
const { locale } = await params;
|
|
30
|
+
const t = await getTranslations({ locale, namespace: "unsubscribe" });
|
|
31
|
+
return {
|
|
32
|
+
title: t("title"),
|
|
33
|
+
robots: { index: false, follow: true },
|
|
34
|
+
referrer: "no-referrer",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default async function UnsubscribePage({ params, searchParams }: Props): Promise<ReactElement> {
|
|
39
|
+
const { locale } = await params;
|
|
40
|
+
const { t: token } = await searchParams;
|
|
41
|
+
const t = await getTranslations({ locale, namespace: "unsubscribe" });
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="mx-auto flex max-w-md flex-col gap-4 px-4 py-16" data-testid="unsubscribe-page">
|
|
45
|
+
<Heading level={1} visual={2}>
|
|
46
|
+
{t("title")}
|
|
47
|
+
</Heading>
|
|
48
|
+
<Text as="p" variant="body">
|
|
49
|
+
{t("lead")}
|
|
50
|
+
</Text>
|
|
51
|
+
<UnsubscribeButton token={token ?? null} />
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use client"; // owns the click → unsubscribeAction → result state
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UnsubscribeButton — the unsubscribe page's one interactive piece (#8438
|
|
5
|
+
* D17). Posts `unsubscribeAction(token)` on click only — never on render,
|
|
6
|
+
* so a mail scanner's GET prefetch of the page writes nothing.
|
|
7
|
+
*
|
|
8
|
+
* Reads its own strings through `useTranslations` (the island idiom
|
|
9
|
+
* `FlowStepperClient` uses) rather than taking them as props: the done line
|
|
10
|
+
* needs the purpose's description formatted in, and a formatting FUNCTION
|
|
11
|
+
* handed across from the page (a Server Component) cannot be serialized
|
|
12
|
+
* into the Flight payload — the scaffold matrix's `GET /unsubscribe -> 500`
|
|
13
|
+
* (`unsubscribe-rsc-boundary.test.tsx` holds the regression).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Button, InlineMessage } from "@working-theory/ui/primitives";
|
|
17
|
+
import { useTranslations } from "next-intl";
|
|
18
|
+
import { useState, useTransition, type ReactElement } from "react";
|
|
19
|
+
|
|
20
|
+
import { unsubscribeAction, type UnsubscribeResult } from "./unsubscribe.actions";
|
|
21
|
+
|
|
22
|
+
export function UnsubscribeButton({ token }: { token: string | null }): ReactElement {
|
|
23
|
+
const t = useTranslations("unsubscribe");
|
|
24
|
+
const [result, setResult] = useState<UnsubscribeResult | null>(null);
|
|
25
|
+
const [pending, startTransition] = useTransition();
|
|
26
|
+
|
|
27
|
+
if (token === null || result?.kind === "invalid") {
|
|
28
|
+
return (
|
|
29
|
+
<InlineMessage variant="error" role="alert" data-testid="unsubscribe-invalid">
|
|
30
|
+
{t("invalid")}
|
|
31
|
+
</InlineMessage>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (result?.kind === "done") {
|
|
36
|
+
return (
|
|
37
|
+
<InlineMessage variant="success" role="status" data-testid="unsubscribe-done">
|
|
38
|
+
{t("done", { description: result.description })}
|
|
39
|
+
</InlineMessage>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (result?.kind === "already") {
|
|
44
|
+
return (
|
|
45
|
+
<InlineMessage variant="success" role="status" data-testid="unsubscribe-already">
|
|
46
|
+
{t("already")}
|
|
47
|
+
</InlineMessage>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (result?.kind === "unavailable") {
|
|
52
|
+
return (
|
|
53
|
+
<InlineMessage variant="error" role="alert" data-testid="unsubscribe-unavailable">
|
|
54
|
+
{t("unavailable")}
|
|
55
|
+
</InlineMessage>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Button
|
|
61
|
+
type="button"
|
|
62
|
+
disabled={pending}
|
|
63
|
+
onClick={() => {
|
|
64
|
+
startTransition(async () => {
|
|
65
|
+
setResult(await unsubscribeAction(token));
|
|
66
|
+
});
|
|
67
|
+
}}
|
|
68
|
+
data-testid="unsubscribe-button"
|
|
69
|
+
>
|
|
70
|
+
{t("button")}
|
|
71
|
+
</Button>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* unsubscribeAction — the unsubscribe page's one write (#8438 D9, D17).
|
|
5
|
+
*
|
|
6
|
+
* Calls `POST /v1/consent/unsubscribe` (`consent.unsubscribe`) through
|
|
7
|
+
* `@working-theory/api-client`, carrying the visitor-forward headers a
|
|
8
|
+
* spoke attaches today — the same idiom `contact.actions.ts` uses. Never
|
|
9
|
+
* writes anything on its own: the page's GET never calls this, so a mail
|
|
10
|
+
* scanner's prefetch of the link changes nothing (D17).
|
|
11
|
+
*
|
|
12
|
+
* The token is the only authority (D9) — this action never names an email,
|
|
13
|
+
* a workspace or a person.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { consent, visitorForwardHeaders } from "@working-theory/api-client";
|
|
17
|
+
import { headers } from "next/headers";
|
|
18
|
+
|
|
19
|
+
export type UnsubscribeResult =
|
|
20
|
+
| { kind: "done"; description: string }
|
|
21
|
+
| { kind: "already" }
|
|
22
|
+
| { kind: "invalid" }
|
|
23
|
+
| { kind: "unavailable" };
|
|
24
|
+
|
|
25
|
+
export async function unsubscribeAction(token: string): Promise<UnsubscribeResult> {
|
|
26
|
+
let init: { headers: Record<string, string> } | undefined;
|
|
27
|
+
try {
|
|
28
|
+
const forwarded = visitorForwardHeaders(
|
|
29
|
+
await headers(),
|
|
30
|
+
process.env.AILK_VISITOR_FORWARD_KEY,
|
|
31
|
+
);
|
|
32
|
+
if (forwarded) init = { headers: forwarded };
|
|
33
|
+
} catch {
|
|
34
|
+
// No request scope to read — call with no forwarding.
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const res = init
|
|
39
|
+
? await consent.unsubscribe({ token }, init)
|
|
40
|
+
: await consent.unsubscribe({ token });
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
// 429 is transient (a rate limit), never the token's fault — "invalid"
|
|
43
|
+
// would tell a visitor the link itself is bad when retrying would work.
|
|
44
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 429) return { kind: "invalid" };
|
|
45
|
+
return { kind: "unavailable" };
|
|
46
|
+
}
|
|
47
|
+
if (!res.body.changed) return { kind: "already" };
|
|
48
|
+
return { kind: "done", description: res.body.purpose.description };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// The api-client never attaches the request payload to a thrown error,
|
|
51
|
+
// so logging `err` carries no token.
|
|
52
|
+
console.error("[unsubscribeAction]", err);
|
|
53
|
+
return { kind: "unavailable" };
|
|
54
|
+
}
|
|
55
|
+
}
|
package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx
CHANGED
|
@@ -62,7 +62,7 @@ describe("whitepapers/[slug] — snapshot regression", () => {
|
|
|
62
62
|
});
|
|
63
63
|
const { container } = render(element);
|
|
64
64
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
65
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">AILK AEO Strategy 2026</h1><p data-testid="whitepaper-description" class="mt-block-tight text-lead">In-depth research on answer engine optimization for AILK products.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div class="ailk-prose max-w-none text-(--color-
|
|
65
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">AILK AEO Strategy 2026</h1><p data-testid="whitepaper-description" class="mt-block-tight text-lead">In-depth research on answer engine optimization for AILK products.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div class="ailk-prose max-w-none text-(--color-text-muted) text-body-sm leading-relaxed [&>*:first-child]:mt-0 [&_h2]:mt-block-loose [&_h2]:scroll-mt-24 [&_h2]:text-heading-3 [&_h2]:font-display [&_h2]:text-(--color-fg) [&_h3]:mt-block-loose [&_h3]:scroll-mt-24 [&_h3]:text-heading-4 [&_h3]:font-display [&_h3]:text-(--color-fg) [&_h4]:mt-block-tight [&_h4]:scroll-mt-24 [&_h4]:text-heading-4 [&_h4]:font-display [&_h4]:text-(--color-fg) [&_h5]:mt-block-tight [&_h5]:scroll-mt-24 [&_h5]:text-heading-4 [&_h5]:font-display [&_h5]:text-(--color-fg) [&_h6]:mt-block-tight [&_h6]:scroll-mt-24 [&_h6]:text-heading-4 [&_h6]:font-display [&_h6]:text-(--color-fg) [&_p]:mt-paragraph [&_strong]:font-semibold [&_strong]:text-(--color-fg) [&_em]:italic [&_ul]:mt-paragraph [&_ul]:list-disc [&_ul]:ps-6 [&_ol]:mt-paragraph [&_ol]:list-decimal [&_ol]:ps-6 [&_li]:mt-1 [&_li]:ps-1 [&_li]:marker:text-(--color-text-muted) [&_li>ul]:mt-1 [&_li>ol]:mt-1 [&_blockquote]:mt-paragraph [&_blockquote]:border-s-4 [&_blockquote]:border-(--color-border-strong) [&_blockquote]:ps-4 [&_blockquote]:italic [&_blockquote]:text-(--color-text-muted) [&_hr]:my-block-loose [&_hr]:border-(--color-border) [&_a]:text-(--color-accent) [&_a]:underline [&_a]:underline-offset-4 [&_:not(pre)>code]:rounded [&_:not(pre)>code]:bg-(--color-surface-secondary) [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:text-code [&_:not(pre)>code]:font-mono [&_:not(pre)>code]:text-(--color-fg) [&_pre]:mt-paragraph [&_pre]:overflow-auto [&_pre]:rounded-md [&_pre]:border-hairline [&_pre]:border-(--color-border) [&_pre]:bg-(--color-bg) [&_pre]:p-4 [&_pre]:font-mono [&_pre]:text-body-sm [&_img]:mt-paragraph [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-lg [&_table]:mt-paragraph [&_table]:w-full [&_table]:border-collapse [&_table]:text-body-sm [&_th]:border-b [&_th]:border-(--color-border) [&_th]:bg-(--color-surface-secondary) [&_th]:px-3 [&_th]:py-2 [&_th]:text-start [&_th]:font-semibold [&_th]:text-(--color-fg) [&_td]:border-b [&_td]:border-(--color-border) [&_td]:px-3 [&_td]:py-2 [&_kbd]:rounded [&_kbd]:border-hairline [&_kbd]:border-(--color-border) [&_kbd]:bg-(--color-surface-secondary) [&_kbd]:px-1.5 [&_kbd]:py-0.5 [&_kbd]:text-caption [&_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Executive Summary
|
|
66
66
|
|
|
67
67
|
Body content.</div></div></article></div></div></div></main>"
|
|
68
68
|
`);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for /working-agreement (#8661 D10, AC-6) — the twin of /terms and
|
|
3
|
+
* /privacy, serving the registered `WorkingAgreementPage` type so the
|
|
4
|
+
* contact form's terms statement never links a 404.
|
|
5
|
+
*
|
|
6
|
+
* `renderPage`/`renderPageMetadata` are mocked at the
|
|
7
|
+
* `@working-theory/page-renderer` boundary (the /reports suite's shape); each
|
|
8
|
+
* assertion inspects the calls directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const mockRenderPage = jest.fn(async (..._args: unknown[]) => "<rendered/>");
|
|
12
|
+
const mockRenderPageMetadata = jest.fn((..._args: unknown[]) => undefined);
|
|
13
|
+
|
|
14
|
+
jest.mock("@working-theory/page-renderer", () => ({
|
|
15
|
+
__esModule: true,
|
|
16
|
+
renderPage: (...args: unknown[]) => mockRenderPage(...args),
|
|
17
|
+
renderPageMetadata: (...args: unknown[]) => mockRenderPageMetadata(...args),
|
|
18
|
+
getMdxSource: jest.fn(),
|
|
19
|
+
getPageSections: jest.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import WorkingAgreementPage, { generateMetadata } from "../page";
|
|
23
|
+
|
|
24
|
+
beforeEach(() => jest.clearAllMocks());
|
|
25
|
+
|
|
26
|
+
describe("WorkingAgreementPage", () => {
|
|
27
|
+
it("renders the WorkingAgreementPage type at slug working-agreement, in its own namespace", async () => {
|
|
28
|
+
await WorkingAgreementPage({ params: Promise.resolve({ locale: "en" }) });
|
|
29
|
+
|
|
30
|
+
expect(mockRenderPage).toHaveBeenCalledTimes(1);
|
|
31
|
+
expect(mockRenderPage).toHaveBeenCalledWith({
|
|
32
|
+
type: "WorkingAgreementPage",
|
|
33
|
+
slug: "working-agreement",
|
|
34
|
+
locale: "en",
|
|
35
|
+
translationsNamespace: "workingAgreement",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("passes injected content loaders through", async () => {
|
|
40
|
+
const getMdxSource = jest.fn();
|
|
41
|
+
const getPageSections = jest.fn();
|
|
42
|
+
|
|
43
|
+
await WorkingAgreementPage({
|
|
44
|
+
params: Promise.resolve({ locale: "fr" }),
|
|
45
|
+
getMdxSource,
|
|
46
|
+
getPageSections,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(mockRenderPage).toHaveBeenCalledWith(
|
|
50
|
+
expect.objectContaining({ locale: "fr", getMdxSource, getPageSections }),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("builds metadata from the same type and slug", async () => {
|
|
55
|
+
await generateMetadata({ params: Promise.resolve({ locale: "en" }) });
|
|
56
|
+
|
|
57
|
+
expect(mockRenderPageMetadata).toHaveBeenCalledWith({
|
|
58
|
+
type: "WorkingAgreementPage",
|
|
59
|
+
slug: "working-agreement",
|
|
60
|
+
locale: "en",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Working Agreement page (#8661 D10). The /working-agreement route for the registered
|
|
2
|
+
// `WorkingAgreementPage` type: reads its MDX frontmatter, renders sections via
|
|
3
|
+
// SectionRenderer, and emits a JSON-LD <script>.
|
|
4
|
+
import type {
|
|
5
|
+
getMdxSource as defaultGetMdxSource,
|
|
6
|
+
getPageSections as defaultGetPageSections} from "@working-theory/page-renderer";
|
|
7
|
+
import {
|
|
8
|
+
renderPage,
|
|
9
|
+
renderPageMetadata
|
|
10
|
+
} from "@working-theory/page-renderer";
|
|
11
|
+
import type { Metadata } from "next";
|
|
12
|
+
import type { ReactElement } from "react";
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
params: Promise<{ locale: string }>;
|
|
16
|
+
getMdxSource?: typeof defaultGetMdxSource;
|
|
17
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function generateMetadata({
|
|
21
|
+
params,
|
|
22
|
+
}: Pick<Props, "params">): Promise<Metadata> {
|
|
23
|
+
const { locale } = await params;
|
|
24
|
+
return renderPageMetadata({ type: "WorkingAgreementPage", slug: "working-agreement", locale });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default async function Page({
|
|
28
|
+
params,
|
|
29
|
+
getMdxSource,
|
|
30
|
+
getPageSections,
|
|
31
|
+
}: Props): Promise<ReactElement> {
|
|
32
|
+
const { locale } = await params;
|
|
33
|
+
return renderPage({
|
|
34
|
+
type: "WorkingAgreementPage",
|
|
35
|
+
slug: "working-agreement",
|
|
36
|
+
locale,
|
|
37
|
+
translationsNamespace: "workingAgreement",
|
|
38
|
+
...(getMdxSource !== undefined && { getMdxSource }),
|
|
39
|
+
...(getPageSections !== undefined && { getPageSections }),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -8,28 +8,31 @@
|
|
|
8
8
|
* (comma-separated paths, e.g. "/admin/,/private/")
|
|
9
9
|
* - STEALTH=true disallows every path for every crawler — layer 1 of the
|
|
10
10
|
* three-layer stealth posture (#884, #7374); it subsumes any
|
|
11
|
-
* AILK_ROBOTS_DISALLOW paths
|
|
11
|
+
* AILK_ROBOTS_DISALLOW paths and drops the Sitemap line, so a hidden
|
|
12
|
+
* site advertises no URLs (#8519). STEALTH is read through the shared
|
|
13
|
+
* parse (#8511), so 1, yes and on count too, and an unknown value throws.
|
|
12
14
|
*/
|
|
13
15
|
|
|
16
|
+
import { parseStealth } from "@working-theory/web-i18n/stealth";
|
|
17
|
+
|
|
14
18
|
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
15
19
|
|
|
16
20
|
export function GET(): Response {
|
|
17
21
|
const baseUrl = resolveBaseUrl();
|
|
18
22
|
const disallowEnv = process.env["AILK_ROBOTS_DISALLOW"] ?? "";
|
|
23
|
+
const stealth = parseStealth(process.env["STEALTH"]);
|
|
19
24
|
|
|
20
|
-
const disallowPaths =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.filter((p) => p.length > 0);
|
|
25
|
+
const disallowPaths = stealth
|
|
26
|
+
? ["/"]
|
|
27
|
+
: disallowEnv
|
|
28
|
+
.split(",")
|
|
29
|
+
.map((p) => p.trim())
|
|
30
|
+
.filter((p) => p.length > 0);
|
|
27
31
|
|
|
28
32
|
const lines = [
|
|
29
33
|
"User-agent: *",
|
|
30
34
|
...disallowPaths.map((p) => `Disallow: ${p}`),
|
|
31
|
-
"",
|
|
32
|
-
`Sitemap: ${baseUrl}/sitemap.xml`,
|
|
35
|
+
...(stealth ? [] : ["", `Sitemap: ${baseUrl}/sitemap.xml`]),
|
|
33
36
|
];
|
|
34
37
|
|
|
35
38
|
return new Response(lines.join("\n"), {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// but no Vitest is installed anywhere yet). JSDOM env so RTL can mount components.
|
|
3
3
|
module.exports = {
|
|
4
4
|
preset: "ts-jest",
|
|
5
|
+
globalSetup: require.resolve("../../tools/jest/pin-timezone.cjs"),
|
|
5
6
|
testEnvironment: "jsdom",
|
|
6
7
|
roots: [
|
|
7
8
|
"<rootDir>/__tests__",
|
|
@@ -89,6 +90,9 @@ module.exports = {
|
|
|
89
90
|
"<rootDir>/node_modules/@working-theory/site-config/dist/schema/index.js",
|
|
90
91
|
"^@working-theory/validation$": "<rootDir>/node_modules/@working-theory/validation/dist/index.js",
|
|
91
92
|
"^@working-theory/web-i18n$": "<rootDir>/node_modules/@working-theory/web-i18n/dist/index.js",
|
|
93
|
+
// The Edge-safe `./stealth` subpath (#8511) → source, like the root entry.
|
|
94
|
+
"^@working-theory/web-i18n/stealth$":
|
|
95
|
+
"<rootDir>/node_modules/@working-theory/web-i18n/dist/stealth-metadata.js",
|
|
92
96
|
// The dependency-free tenancy-constants subpath (database/tenancy.ts).
|
|
93
97
|
// Mapped to source like every sibling above; `database/dist` is not
|
|
94
98
|
// built in this workspace pre-`pnpm build`.
|