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,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for consent-visitor.ts (the `ailk-vid` cookie helpers) and for
|
|
3
|
+
* `acceptCookiesAction`/`declineCookiesAction` (#8438 D12,
|
|
4
|
+
* `app/[locale]/consent.actions.ts`) — the banner's one write path.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
consentVisitorHeader,
|
|
8
|
+
ensureConsentVisitor,
|
|
9
|
+
isValidConsentVisitorId,
|
|
10
|
+
readConsentVisitor,
|
|
11
|
+
readConsentVisitorFromCookieHeader,
|
|
12
|
+
} from "../consent-visitor";
|
|
13
|
+
|
|
14
|
+
const VALID_ID = "11111111-2222-4333-8444-555555555555";
|
|
15
|
+
|
|
16
|
+
describe("isValidConsentVisitorId", () => {
|
|
17
|
+
it.each([VALID_ID, "aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee"])("%s is a valid v4 UUID", (id) => {
|
|
18
|
+
expect(isValidConsentVisitorId(id)).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it.each([
|
|
22
|
+
"",
|
|
23
|
+
"not-a-uuid",
|
|
24
|
+
"11111111-2222-3333-8444-555555555555", // version nibble is 3, not 4
|
|
25
|
+
"11111111-2222-4333-c444-555555555555", // variant nibble is c, not 8/9/a/b
|
|
26
|
+
`${VALID_ID} `,
|
|
27
|
+
])("%j is not", (value) => {
|
|
28
|
+
expect(isValidConsentVisitorId(value)).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("readConsentVisitor", () => {
|
|
33
|
+
it("returns the cookie's value when it is a valid id", () => {
|
|
34
|
+
const cookies = { get: () => ({ value: VALID_ID }) };
|
|
35
|
+
expect(readConsentVisitor(cookies)).toBe(VALID_ID);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns undefined with no cookie, or an invalid value", () => {
|
|
39
|
+
expect(readConsentVisitor({ get: () => undefined })).toBeUndefined();
|
|
40
|
+
expect(readConsentVisitor({ get: () => ({ value: "nope" }) })).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("ensureConsentVisitor", () => {
|
|
45
|
+
it("keeps an existing valid id and sets nothing", () => {
|
|
46
|
+
const set = jest.fn();
|
|
47
|
+
const cookies = { get: () => ({ value: VALID_ID }), set };
|
|
48
|
+
expect(ensureConsentVisitor(cookies)).toBe(VALID_ID);
|
|
49
|
+
expect(set).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("mints and sets a fresh v4 UUID when absent", () => {
|
|
53
|
+
const set = jest.fn();
|
|
54
|
+
const cookies = { get: () => undefined, set };
|
|
55
|
+
const id = ensureConsentVisitor(cookies);
|
|
56
|
+
expect(isValidConsentVisitorId(id)).toBe(true);
|
|
57
|
+
expect(set).toHaveBeenCalledTimes(1);
|
|
58
|
+
const [name, value, options] = set.mock.calls[0] as [string, string, Record<string, unknown>];
|
|
59
|
+
expect(name).toBe("ailk-vid");
|
|
60
|
+
expect(value).toBe(id);
|
|
61
|
+
expect(options).toMatchObject({ httpOnly: true, sameSite: "lax", path: "/" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("mints a fresh id when the stored value fails the UUID shape", () => {
|
|
65
|
+
const set = jest.fn();
|
|
66
|
+
const cookies = { get: () => ({ value: "garbage" }), set };
|
|
67
|
+
const id = ensureConsentVisitor(cookies);
|
|
68
|
+
expect(id).not.toBe("garbage");
|
|
69
|
+
expect(set).toHaveBeenCalledTimes(1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("consentVisitorHeader", () => {
|
|
74
|
+
it("returns the one header, exactly", () => {
|
|
75
|
+
expect(consentVisitorHeader(VALID_ID)).toEqual({ "x-ailk-consent-visitor": VALID_ID });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("readConsentVisitorFromCookieHeader", () => {
|
|
80
|
+
it("reads the id out of a raw Cookie header", () => {
|
|
81
|
+
expect(readConsentVisitorFromCookieHeader(`ailk-vid=${VALID_ID}`)).toBe(VALID_ID);
|
|
82
|
+
expect(readConsentVisitorFromCookieHeader(`session=abc; ailk-vid=${VALID_ID}; other=1`)).toBe(VALID_ID);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns undefined with no header, no matching cookie, or an invalid value", () => {
|
|
86
|
+
expect(readConsentVisitorFromCookieHeader(null)).toBeUndefined();
|
|
87
|
+
expect(readConsentVisitorFromCookieHeader(undefined)).toBeUndefined();
|
|
88
|
+
expect(readConsentVisitorFromCookieHeader("session=abc")).toBeUndefined();
|
|
89
|
+
expect(readConsentVisitorFromCookieHeader("ailk-vid=garbage")).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── acceptCookiesAction / declineCookiesAction (D12) ──────────────────────
|
|
94
|
+
|
|
95
|
+
const mockRecordCookieChoice = jest.fn();
|
|
96
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
97
|
+
__esModule: true,
|
|
98
|
+
consent: { recordCookieChoice: (...args: unknown[]) => mockRecordCookieChoice(...args) },
|
|
99
|
+
visitorForwardHeaders: () => undefined,
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
const mockCookieGet = jest.fn();
|
|
103
|
+
const mockCookieSet = jest.fn();
|
|
104
|
+
jest.mock("next/headers", () => ({
|
|
105
|
+
__esModule: true,
|
|
106
|
+
cookies: jest.fn().mockImplementation(async () => ({
|
|
107
|
+
get: (...args: unknown[]) => mockCookieGet(...args),
|
|
108
|
+
set: (...args: unknown[]) => mockCookieSet(...args),
|
|
109
|
+
})),
|
|
110
|
+
headers: jest.fn().mockResolvedValue(new Headers()),
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
jest.mock("next-intl/server", () => ({
|
|
114
|
+
__esModule: true,
|
|
115
|
+
getLocale: jest.fn().mockResolvedValue("en"),
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
describe("acceptCookiesAction / declineCookiesAction", () => {
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
mockRecordCookieChoice.mockReset().mockResolvedValue({ ok: true, body: { recorded: true } });
|
|
121
|
+
mockCookieGet.mockReset().mockReturnValue(undefined);
|
|
122
|
+
mockCookieSet.mockReset();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("mints a visitor id when absent and calls recordCookieChoice once, accepted", async () => {
|
|
126
|
+
const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
|
|
127
|
+
await acceptCookiesAction();
|
|
128
|
+
expect(mockCookieSet).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(mockRecordCookieChoice).toHaveBeenCalledTimes(1);
|
|
130
|
+
const [body] = mockRecordCookieChoice.mock.calls[0] as [{ visitorId: string; choice: string; locale: string }];
|
|
131
|
+
expect(isValidConsentVisitorId(body.visitorId)).toBe(true);
|
|
132
|
+
expect(body.choice).toBe("accepted");
|
|
133
|
+
expect(body.locale).toBe("en");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("keeps an existing visitor id and declines", async () => {
|
|
137
|
+
mockCookieGet.mockReturnValue({ value: VALID_ID });
|
|
138
|
+
const { declineCookiesAction } = await import("../../app/[locale]/consent.actions");
|
|
139
|
+
await declineCookiesAction();
|
|
140
|
+
expect(mockCookieSet).not.toHaveBeenCalled();
|
|
141
|
+
const [body] = mockRecordCookieChoice.mock.calls[0] as [{ visitorId: string; choice: string }];
|
|
142
|
+
expect(body.visitorId).toBe(VALID_ID);
|
|
143
|
+
expect(body.choice).toBe("declined");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("resolves even when the client rejects", async () => {
|
|
147
|
+
mockRecordCookieChoice.mockRejectedValue(new TypeError("fetch failed"));
|
|
148
|
+
const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
|
|
149
|
+
await expect(acceptCookiesAction()).resolves.toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("resolves even when the write is refused", async () => {
|
|
153
|
+
mockRecordCookieChoice.mockResolvedValue({ ok: false, status: 503, body: null });
|
|
154
|
+
const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
|
|
155
|
+
await expect(acceptCookiesAction()).resolves.toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("never writes the ailk-consent cookie", async () => {
|
|
159
|
+
const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
|
|
160
|
+
await acceptCookiesAction();
|
|
161
|
+
expect(mockCookieSet).not.toHaveBeenCalledWith("ailk-consent", expect.anything(), expect.anything());
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deck-page resolver (#8687 D3, AC-2).
|
|
3
|
+
*
|
|
4
|
+
* `deckCandidates` and the `DECK_ROUTES` parity are pure functions over the
|
|
5
|
+
* real routes map. `resolveDeckPage` and `inputsFor` (captured off the
|
|
6
|
+
* `setDeckPages` registration call) are exercised with `getMdxSource` and
|
|
7
|
+
* `next/headers` mocked, in `renderPage-extension.test.tsx`'s idiom.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
import { ROUTES } from "@working-theory/templates/routes";
|
|
13
|
+
|
|
14
|
+
const mockGetMdxSource = jest.fn();
|
|
15
|
+
const mockSetDeckPages = jest.fn();
|
|
16
|
+
|
|
17
|
+
jest.mock("@working-theory/page-renderer", () => {
|
|
18
|
+
const actual = jest.requireActual("@working-theory/page-renderer");
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
getMdxSource: (...args: unknown[]) => mockGetMdxSource(...args),
|
|
22
|
+
setDeckPages: (...args: unknown[]) => mockSetDeckPages(...args),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const mockHeaders = jest.fn();
|
|
27
|
+
jest.mock("next/headers", () => ({ headers: () => mockHeaders() }));
|
|
28
|
+
|
|
29
|
+
const savedLocales = process.env["NEXT_PUBLIC_ENABLED_LOCALES"];
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
process.env["NEXT_PUBLIC_ENABLED_LOCALES"] = "en,es";
|
|
33
|
+
});
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
if (savedLocales === undefined) delete process.env["NEXT_PUBLIC_ENABLED_LOCALES"];
|
|
36
|
+
else process.env["NEXT_PUBLIC_ENABLED_LOCALES"] = savedLocales;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
import { DECK_ROUTES, deckCandidates, resolveDeckPage } from "../deck-page";
|
|
40
|
+
import { siteChrome } from "../site-chrome";
|
|
41
|
+
|
|
42
|
+
describe("deckCandidates (#8687 D3, AC-2)", () => {
|
|
43
|
+
const LOCALES = ["en", "es"];
|
|
44
|
+
|
|
45
|
+
it("/pricing resolves the route row, then the [slug] fallback", () => {
|
|
46
|
+
expect(deckCandidates("/pricing", LOCALES)).toEqual([
|
|
47
|
+
{ type: "Pricing", slug: "pricing" },
|
|
48
|
+
{ type: "LandingPage", slug: "pricing" },
|
|
49
|
+
{ type: "BlogList", slug: "pricing" },
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("/about (core, always scaffolded) resolves the route row only", () => {
|
|
54
|
+
expect(deckCandidates("/about", LOCALES)).toEqual([
|
|
55
|
+
{ type: "AboutPage", slug: "about" },
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("/es/offer/ (no route match) falls through to [slug] alone", () => {
|
|
60
|
+
expect(deckCandidates("/es/offer/", LOCALES)).toEqual([
|
|
61
|
+
{ type: "LandingPage", slug: "offer" },
|
|
62
|
+
{ type: "BlogList", slug: "offer" },
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("/projects/apollo resolves the detail row's directory type", () => {
|
|
67
|
+
expect(deckCandidates("/projects/apollo", LOCALES)).toEqual([
|
|
68
|
+
{ type: "Project", slug: "apollo" },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("/careers (gateable, no DECK_ROUTES entry) falls through to [slug]", () => {
|
|
73
|
+
expect(deckCandidates("/careers", LOCALES)).toEqual([
|
|
74
|
+
{ type: "LandingPage", slug: "careers" },
|
|
75
|
+
{ type: "BlogList", slug: "careers" },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it.each(["/", "/software", "/sign-in", "/blog/post", "/edit/LandingPage/x", "/a/b"])(
|
|
80
|
+
"%s resolves no candidates",
|
|
81
|
+
(pathname) => {
|
|
82
|
+
expect(deckCandidates(pathname, LOCALES)).toEqual([]);
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("DECK_ROUTES parity (#8687 D3)", () => {
|
|
88
|
+
it("contains every routes-map row with seed content whose page.tsx renders sections, minus /", () => {
|
|
89
|
+
// DECK_ROUTES is a fixed superset, not a per-scaffold exact set: a shape
|
|
90
|
+
// that doesn't ship one of these routes (e.g. a Free scaffold without
|
|
91
|
+
// /alternatives) simply has no page.tsx there, so deckCandidates/
|
|
92
|
+
// resolveDeckPage never get a request for it (harmless). What would be a
|
|
93
|
+
// real bug is the OTHER direction — an actually-shipped sections route
|
|
94
|
+
// this build's own tree carries that DECK_ROUTES fails to name.
|
|
95
|
+
const appRoot = path.join(__dirname, "..", "..", "app", "[locale]");
|
|
96
|
+
const sectionsRoutes = new Set<string>();
|
|
97
|
+
for (const row of ROUTES) {
|
|
98
|
+
if (row.route === "/" || !row.contentPath?.startsWith("content/en/")) continue;
|
|
99
|
+
const dir = row.route
|
|
100
|
+
.split("/")
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.map((segment) => (segment.startsWith(":") ? "[slug]" : segment))
|
|
103
|
+
.join("/");
|
|
104
|
+
let pageSource: string;
|
|
105
|
+
try {
|
|
106
|
+
pageSource = readFileSync(path.join(appRoot, dir, "page.tsx"), "utf8");
|
|
107
|
+
} catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!pageSource.includes('bodyMode: "mdx-body"')) {
|
|
111
|
+
sectionsRoutes.add(row.route);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const deckRoutesSet = new Set<string>(DECK_ROUTES);
|
|
115
|
+
const missing = [...sectionsRoutes].filter((route) => !deckRoutesSet.has(route));
|
|
116
|
+
expect(missing).toEqual([]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("resolveDeckPage (#8687 D3, AC-2)", () => {
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
mockGetMdxSource.mockReset();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns the first candidate with a source", async () => {
|
|
126
|
+
mockGetMdxSource.mockImplementation((type: string) =>
|
|
127
|
+
Promise.resolve(
|
|
128
|
+
type === "LandingPage"
|
|
129
|
+
? null
|
|
130
|
+
: type === "BlogList"
|
|
131
|
+
? { frontmatter: { type: "BlogList", slug: "offer" }, body: "" }
|
|
132
|
+
: null,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const result = await resolveDeckPage("/es/offer/", "en");
|
|
137
|
+
|
|
138
|
+
expect(result).toEqual({
|
|
139
|
+
type: "BlogList",
|
|
140
|
+
slug: "offer",
|
|
141
|
+
frontmatter: { type: "BlogList", slug: "offer" },
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns null when a read throws", async () => {
|
|
146
|
+
mockGetMdxSource.mockRejectedValue(new Error("read failed"));
|
|
147
|
+
|
|
148
|
+
expect(await resolveDeckPage("/pricing", "en")).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns null for a path with no candidates, with zero getMdxSource calls (NFR 2)", async () => {
|
|
152
|
+
expect(await resolveDeckPage("/software", "en")).toBeNull();
|
|
153
|
+
expect(mockGetMdxSource).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("the registered inputsFor (#8687 D3, AC-2)", () => {
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
mockGetMdxSource.mockReset();
|
|
160
|
+
mockHeaders.mockReset();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
function registeredInputsFor(): (ctx: {
|
|
164
|
+
type: string;
|
|
165
|
+
slug: string;
|
|
166
|
+
locale: string;
|
|
167
|
+
}) => Promise<unknown> {
|
|
168
|
+
const call = mockSetDeckPages.mock.calls[mockSetDeckPages.mock.calls.length - 1];
|
|
169
|
+
return (
|
|
170
|
+
call?.[0] as {
|
|
171
|
+
inputsFor: (ctx: { type: string; slug: string; locale: string }) => Promise<unknown>;
|
|
172
|
+
}
|
|
173
|
+
).inputsFor;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it("resolves the design's inputs when the resolved page matches and opts in", async () => {
|
|
177
|
+
mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
|
|
178
|
+
mockGetMdxSource.mockImplementation((type: string) =>
|
|
179
|
+
Promise.resolve(
|
|
180
|
+
type === "Pricing"
|
|
181
|
+
? null
|
|
182
|
+
: type === "LandingPage"
|
|
183
|
+
? { frontmatter: { type: "LandingPage", slug: "pricing", layout: "deck" }, body: "" }
|
|
184
|
+
: null,
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const result = await registeredInputsFor()({
|
|
189
|
+
type: "LandingPage",
|
|
190
|
+
slug: "pricing",
|
|
191
|
+
locale: "en",
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// The scaffold's generated site-chrome carries the DESIGN's values — minimalism
|
|
195
|
+
// presents the deck (deckPresentation true), standard does not — so the expected
|
|
196
|
+
// inputs are read from it rather than hard-coded to one design.
|
|
197
|
+
const chrome = siteChrome as typeof siteChrome & {
|
|
198
|
+
deckGrounds?: unknown;
|
|
199
|
+
deckPresentation?: true;
|
|
200
|
+
};
|
|
201
|
+
expect(result).toEqual({
|
|
202
|
+
...(chrome.deckGrounds !== undefined ? { deckGrounds: chrome.deckGrounds } : {}),
|
|
203
|
+
deckPresentation: chrome.deckPresentation === true,
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("returns null when the resolved page does not match this render", async () => {
|
|
208
|
+
mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
|
|
209
|
+
mockGetMdxSource.mockResolvedValue(
|
|
210
|
+
{ frontmatter: { type: "Pricing", slug: "pricing" }, body: "" },
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const result = await registeredInputsFor()({
|
|
214
|
+
type: "Pricing",
|
|
215
|
+
slug: "pricing",
|
|
216
|
+
locale: "en",
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(result).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns null for a resolved page without the field", async () => {
|
|
223
|
+
mockHeaders.mockResolvedValue(new Map([["x-pathname", "/pricing"]]));
|
|
224
|
+
mockGetMdxSource.mockImplementation((type: string) =>
|
|
225
|
+
Promise.resolve(
|
|
226
|
+
type === "LandingPage"
|
|
227
|
+
? { frontmatter: { type: "LandingPage", slug: "pricing" }, body: "" }
|
|
228
|
+
: null,
|
|
229
|
+
),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(
|
|
233
|
+
await registeredInputsFor()({ type: "LandingPage", slug: "pricing", locale: "en" }),
|
|
234
|
+
).toBeNull();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns null with no x-pathname header", async () => {
|
|
238
|
+
mockHeaders.mockResolvedValue(new Map());
|
|
239
|
+
|
|
240
|
+
expect(
|
|
241
|
+
await registeredInputsFor()({ type: "LandingPage", slug: "pricing", locale: "en" }),
|
|
242
|
+
).toBeNull();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -456,6 +456,55 @@ describe("AC-4 — the visitor, unchanged; a cross-site post, refused", () => {
|
|
|
456
456
|
});
|
|
457
457
|
});
|
|
458
458
|
|
|
459
|
+
describe("the banner's visitor id (#8438 D12)", () => {
|
|
460
|
+
const VALID_ID = "11111111-2222-4333-8444-555555555555";
|
|
461
|
+
const LEAD_PATHS = ["/v1/leads/submit", "/v1/leads/subscribe", "/v1/leads/callback"];
|
|
462
|
+
|
|
463
|
+
it.each(LEAD_PATHS)(
|
|
464
|
+
"%s carries x-ailk-consent-visitor from the request's own ailk-vid cookie",
|
|
465
|
+
async (path) => {
|
|
466
|
+
await handleRelay(relayRequest(path, { headers: { cookie: `ailk-vid=${VALID_ID}` } }));
|
|
467
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBe(VALID_ID);
|
|
468
|
+
},
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
it("sends no header when the request carries no ailk-vid cookie", async () => {
|
|
472
|
+
await handleRelay(relayRequest("/v1/leads/submit"));
|
|
473
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it("sends no header when the cookie value fails the UUID shape", async () => {
|
|
477
|
+
await handleRelay(relayRequest("/v1/leads/submit", { headers: { cookie: "ailk-vid=not-a-uuid" } }));
|
|
478
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("the schedule route carries no consent-visitor header", async () => {
|
|
482
|
+
await handleRelay(
|
|
483
|
+
relayRequest("/v1/schedule/availability", {
|
|
484
|
+
method: "GET",
|
|
485
|
+
headers: { cookie: `ailk-vid=${VALID_ID}` },
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("the session route carries no consent-visitor header", async () => {
|
|
492
|
+
await handleRelay(
|
|
493
|
+
relayRequest("/v1/commerce/invoices", { headers: { cookie: `ailk-vid=${VALID_ID}` } }),
|
|
494
|
+
);
|
|
495
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("a client-sent x-ailk-consent-visitor header is stripped — only the cookie counts", async () => {
|
|
499
|
+
await handleRelay(
|
|
500
|
+
relayRequest("/v1/leads/submit", {
|
|
501
|
+
headers: { "x-ailk-consent-visitor": "22222222-2222-4222-8222-222222222222" },
|
|
502
|
+
}),
|
|
503
|
+
);
|
|
504
|
+
expect(sent().headers.get("x-ailk-consent-visitor")).toBeNull();
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
459
508
|
describe("hardening — what the site serves, and a missing hub address", () => {
|
|
460
509
|
const mediaType = (res: Response) =>
|
|
461
510
|
res.headers.get("content-type")?.split(";")[0]?.trim();
|
|
@@ -240,10 +240,12 @@ describe("structured data — every content page builds valid JSON-LD", () => {
|
|
|
240
240
|
"content/$locale/$typeDir — every page validates as its type",
|
|
241
241
|
async ({ locale, typeDir }) => {
|
|
242
242
|
if (!(SCHEMA_ORG_TYPES as readonly string[]).includes(typeDir)) {
|
|
243
|
-
// Identity + sub-entity types (Organization,
|
|
243
|
+
// Identity + sub-entity types (Organization, BreadcrumbList,
|
|
244
244
|
// ContactPoint) hold sub-objects other pages' builders embed — never
|
|
245
245
|
// rendered as pages, and skipped by tools/content-compile the same
|
|
246
|
-
// way. Anything else is a directory nothing will ever render.
|
|
246
|
+
// way. Anything else is a directory nothing will ever render. Person
|
|
247
|
+
// (#8650) is a registered page type (`/about/<slug>`), so it is
|
|
248
|
+
// covered by the SCHEMA_ORG_TYPES branch above, not this one.
|
|
247
249
|
if (!knownSchemaOrgTypes.has(typeDir)) {
|
|
248
250
|
throw new Error(
|
|
249
251
|
`content/${locale}/${typeDir}: "${typeDir}" is not a registered page type — no route or builder will ever render it (see packages/content-adapters/src/types.ts SCHEMA_ORG_TYPES)`,
|
|
@@ -295,10 +297,25 @@ describe("structured data — every content page builds valid JSON-LD", () => {
|
|
|
295
297
|
* exempt route whose page.tsx does render through `renderPage` fails too,
|
|
296
298
|
* because the reason given for it is false.
|
|
297
299
|
*/
|
|
300
|
+
// Literal, not imported: apps/web does not depend on create-ailk (the import
|
|
301
|
+
// boundary). Mirrors packages/create-ailk/src/spoke-checkout.ts's
|
|
302
|
+
// SPOKE_CHECKOUT_PAGE_MARKER; packages/create-ailk/__tests__/
|
|
303
|
+
// spoke-checkout.test.ts pins the two in sync (#8513 AC-5).
|
|
304
|
+
const CHECKOUT_PAGE_MARKER = "GENERATED by create-ailk (#7838)";
|
|
305
|
+
const CHECKOUT_PAGE_NOINDEX_LINE = "robots: { index: false, follow: false }";
|
|
306
|
+
|
|
298
307
|
const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
|
|
299
308
|
route: string;
|
|
300
309
|
reason: string;
|
|
301
310
|
subtree?: true;
|
|
311
|
+
/**
|
|
312
|
+
* Strings the page file must carry for this entry to apply (#8513 D1). An
|
|
313
|
+
* entry naming no strings applies to its route unconditionally, as every
|
|
314
|
+
* entry did before. Keys the exemption on the file, not the topology seam:
|
|
315
|
+
* a spoke can carry a hand-written page at this route, and a site can
|
|
316
|
+
* edit the generated one — either is checked like any other route.
|
|
317
|
+
*/
|
|
318
|
+
requires?: readonly string[];
|
|
302
319
|
}> = [
|
|
303
320
|
// Fumadocs routes: JSON-LD is emitted by @working-theory/fumadocs-schema
|
|
304
321
|
// from the fumadocs `.source` collection, which is not built in this
|
|
@@ -349,6 +366,10 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
|
|
|
349
366
|
subtree: true,
|
|
350
367
|
},
|
|
351
368
|
{ route: "meet", reason: "booking surface, API-backed", subtree: true },
|
|
369
|
+
{
|
|
370
|
+
route: "unsubscribe",
|
|
371
|
+
reason: "app-shaped landing, API-backed; no content page behind it",
|
|
372
|
+
},
|
|
352
373
|
// #6084 — both purchase return pages read a Checkout Session (or read
|
|
353
374
|
// nothing, for cancel) and render via SectionRenderer directly; neither
|
|
354
375
|
// is a content-adapter page, and the paid render's own schema.org node
|
|
@@ -367,6 +388,23 @@ const NO_STRUCTURED_DATA_ROUTES: ReadonlyArray<{
|
|
|
367
388
|
reason: "auth surfaces, not content pages",
|
|
368
389
|
subtree: true,
|
|
369
390
|
},
|
|
391
|
+
// #8513 — a spoke carrying commerce gets two generated checkout return
|
|
392
|
+
// pages (create-ailk #7838): noindex, rendered through SectionRenderer
|
|
393
|
+
// directly, no JSON-LD. Exempt only when the page on disk still carries
|
|
394
|
+
// both the generated marker and the noindex line (see `requires` above) —
|
|
395
|
+
// a hand-written or edited page at either route is checked like any other.
|
|
396
|
+
{
|
|
397
|
+
route: "checkout/thank-you",
|
|
398
|
+
reason:
|
|
399
|
+
"spoke checkout return page (create-ailk #7838) — noindex, renders sections only, no JSON-LD",
|
|
400
|
+
requires: [CHECKOUT_PAGE_MARKER, CHECKOUT_PAGE_NOINDEX_LINE],
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
route: "checkout/unavailable",
|
|
404
|
+
reason:
|
|
405
|
+
"spoke checkout return page (create-ailk #7838) — noindex, renders sections only, no JSON-LD",
|
|
406
|
+
requires: [CHECKOUT_PAGE_MARKER, CHECKOUT_PAGE_NOINDEX_LINE],
|
|
407
|
+
},
|
|
370
408
|
];
|
|
371
409
|
|
|
372
410
|
interface RouteEntry {
|
|
@@ -392,12 +430,20 @@ function walkRoutes(dir: string, out: RouteEntry[] = []): RouteEntry[] {
|
|
|
392
430
|
return out;
|
|
393
431
|
}
|
|
394
432
|
|
|
395
|
-
function exemption(route: string): string | undefined {
|
|
396
|
-
|
|
433
|
+
function exemption(route: string, src: string): string | undefined {
|
|
434
|
+
const entry = NO_STRUCTURED_DATA_ROUTES.find(
|
|
397
435
|
(e) =>
|
|
398
436
|
route === e.route ||
|
|
399
437
|
(e.subtree === true && route.startsWith(e.route + "/")),
|
|
400
|
-
)
|
|
438
|
+
);
|
|
439
|
+
if (entry === undefined) return undefined;
|
|
440
|
+
if (
|
|
441
|
+
entry.requires !== undefined &&
|
|
442
|
+
!entry.requires.every((s) => src.includes(s))
|
|
443
|
+
) {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
return entry.reason;
|
|
401
447
|
}
|
|
402
448
|
|
|
403
449
|
// The page identity a route renders — the three shapes
|
|
@@ -432,9 +478,8 @@ const RENDER_PAGE_DYNAMIC_RE =
|
|
|
432
478
|
const GET_MDX_SOURCE_RE = /getMdxSource\s*\(\s*["']([A-Za-z]+)["']/s;
|
|
433
479
|
|
|
434
480
|
function routeIdentity(
|
|
435
|
-
|
|
481
|
+
src: string,
|
|
436
482
|
): { type: string; slug: string | null } | null {
|
|
437
|
-
const src = readFileSync(file, "utf8");
|
|
438
483
|
const exact = RENDER_PAGE_EXACT_RE.exec(src);
|
|
439
484
|
if (exact) return { type: exact[1] as string, slug: exact[2] as string };
|
|
440
485
|
const dynamic = RENDER_PAGE_DYNAMIC_RE.exec(src);
|
|
@@ -487,8 +532,9 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
|
|
|
487
532
|
|
|
488
533
|
it.each(routes)("$route", async ({ route, file }) => {
|
|
489
534
|
const label = `/${DEFAULT_LOCALE}${route === "" ? "" : "/" + route}`;
|
|
490
|
-
const
|
|
491
|
-
const
|
|
535
|
+
const src = readFileSync(file, "utf8");
|
|
536
|
+
const exempt = exemption(route, src);
|
|
537
|
+
const identity = routeIdentity(src);
|
|
492
538
|
if (exempt !== undefined) {
|
|
493
539
|
// Declared, with a reason, in NO_STRUCTURED_DATA_ROUTES above. The
|
|
494
540
|
// reason is false for a route that does render through renderPage —
|
|
@@ -570,6 +616,51 @@ describe("structured data — every route emits a valid JSON-LD script", () => {
|
|
|
570
616
|
});
|
|
571
617
|
});
|
|
572
618
|
|
|
619
|
+
// ── 2a. exemption(route, src) — the generated-checkout routes (#8513) ──────
|
|
620
|
+
//
|
|
621
|
+
// Proof that the two conditional entries above key on the FILE, not the
|
|
622
|
+
// route alone, and that every pre-existing entry is unaffected. The
|
|
623
|
+
// monorepo's own app/[locale]/ has no checkout route (§1 of the spec), so
|
|
624
|
+
// the route walk above never meets one — these cases are what exercise D1 on
|
|
625
|
+
// every PR.
|
|
626
|
+
|
|
627
|
+
describe("structured data — exemption(route, src) keys the generated checkout routes on the file (#8513)", () => {
|
|
628
|
+
const GENERATED_SRC = `// The checkout thank-you page — ${CHECKOUT_PAGE_MARKER}.\nexport async function generateMetadata() {\n return { ${CHECKOUT_PAGE_NOINDEX_LINE} };\n}\n`;
|
|
629
|
+
const NO_MARKER_SRC = `// hand-written\nexport async function generateMetadata() {\n return { ${CHECKOUT_PAGE_NOINDEX_LINE} };\n}\n`;
|
|
630
|
+
const NO_NOINDEX_SRC = `// The checkout thank-you page — ${CHECKOUT_PAGE_MARKER}.\nexport async function generateMetadata() {\n return {};\n}\n`;
|
|
631
|
+
|
|
632
|
+
it("exempts checkout/thank-you only when the file carries both the marker and the noindex line", () => {
|
|
633
|
+
expect(exemption("checkout/thank-you", GENERATED_SRC)).toContain(
|
|
634
|
+
"noindex",
|
|
635
|
+
);
|
|
636
|
+
expect(exemption("checkout/thank-you", NO_MARKER_SRC)).toBeUndefined();
|
|
637
|
+
expect(exemption("checkout/thank-you", NO_NOINDEX_SRC)).toBeUndefined();
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it("exempts checkout/unavailable only when the file carries both the marker and the noindex line", () => {
|
|
641
|
+
expect(exemption("checkout/unavailable", GENERATED_SRC)).toContain(
|
|
642
|
+
"noindex",
|
|
643
|
+
);
|
|
644
|
+
expect(exemption("checkout/unavailable", NO_MARKER_SRC)).toBeUndefined();
|
|
645
|
+
expect(exemption("checkout/unavailable", NO_NOINDEX_SRC)).toBeUndefined();
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("does not exempt a neighboring checkout route or an unrelated route", () => {
|
|
649
|
+
expect(exemption("checkout/other", GENERATED_SRC)).toBeUndefined();
|
|
650
|
+
expect(exemption("about", GENERATED_SRC)).toBeUndefined();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("every pre-existing entry still returns its reason for its own route with any src, subtree entries included", () => {
|
|
654
|
+
for (const entry of NO_STRUCTURED_DATA_ROUTES) {
|
|
655
|
+
if (entry.requires !== undefined) continue; // the two conditional entries above, checked above
|
|
656
|
+
expect(exemption(entry.route, "")).toBe(entry.reason);
|
|
657
|
+
if (entry.subtree === true) {
|
|
658
|
+
expect(exemption(`${entry.route}/child`, "")).toBe(entry.reason);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
573
664
|
// ── 3. The two defect shapes that shipped, pinned ───────────────────────────
|
|
574
665
|
//
|
|
575
666
|
// These prove the checks above reach the validator rule that rejects each
|