create-ailk 0.27.0 → 0.28.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/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +23 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +105 -9
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +38 -4
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +5 -2
- package/templates/apps/api/.env.example +9 -5
- package/templates/apps/api/CLAUDE.md +14 -9
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +25 -11
- package/templates/apps/web/CLAUDE.md +45 -4
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +157 -139
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +31 -106
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +86 -30
- package/templates/apps/web/middleware.ts +171 -11
- package/templates/apps/web/next.config.mjs +211 -2
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/versions.json +48 -48
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for consent-visitor.ts (the `ailk-vid` cookie helpers) and for
|
|
3
3
|
* `acceptCookiesAction`/`declineCookiesAction` (#8438 D12,
|
|
4
|
-
* `app/[locale]/consent.actions.ts`) — the banner's one write path.
|
|
4
|
+
* `app/[locale]/consent.actions.ts`) — the banner's one write path. The
|
|
5
|
+
* cookie is signed by the site (#8556, spec 9965 D7): a read returns the UUID
|
|
6
|
+
* only when the signature verifies.
|
|
5
7
|
*/
|
|
6
8
|
import {
|
|
7
9
|
consentVisitorHeader,
|
|
@@ -13,6 +15,72 @@ import {
|
|
|
13
15
|
|
|
14
16
|
const VALID_ID = "11111111-2222-4333-8444-555555555555";
|
|
15
17
|
|
|
18
|
+
/** The cookie value `ensureConsentVisitor` writes, captured from a real mint. */
|
|
19
|
+
function mintedCookie(): { id: string; value: string } {
|
|
20
|
+
const set = jest.fn();
|
|
21
|
+
const id = ensureConsentVisitor({ get: () => undefined, set });
|
|
22
|
+
return { id, value: set.mock.calls[0][1] as string };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `value` with the signature's first character changed (the last carries padding bits). */
|
|
26
|
+
function tampered(value: string): string {
|
|
27
|
+
const at = value.indexOf(".") + 1;
|
|
28
|
+
return `${value.slice(0, at)}${value[at] === "A" ? "B" : "A"}${value.slice(at + 1)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The env keys the signing secret is chosen from, and the two that decide whether a missing one throws. */
|
|
32
|
+
const SECRET_ENV_KEYS = [
|
|
33
|
+
"NODE_ENV",
|
|
34
|
+
"NEXT_PHASE",
|
|
35
|
+
"BETTER_AUTH_SECRET",
|
|
36
|
+
"AILK_HUB_SESSION_SECRET",
|
|
37
|
+
"AILK_SPOKE_KEY",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run `fn` with exactly `vars` set among `SECRET_ENV_KEYS` (the rest unset),
|
|
42
|
+
* restoring every one of them after.
|
|
43
|
+
*/
|
|
44
|
+
function withEnv<T>(
|
|
45
|
+
vars: Partial<Record<(typeof SECRET_ENV_KEYS)[number], string>>,
|
|
46
|
+
fn: () => T,
|
|
47
|
+
): T {
|
|
48
|
+
const saved = Object.fromEntries(SECRET_ENV_KEYS.map((key) => [key, process.env[key]]));
|
|
49
|
+
const restore = () => {
|
|
50
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
51
|
+
if (value === undefined) delete process.env[key];
|
|
52
|
+
else process.env[key] = value;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
for (const key of SECRET_ENV_KEYS) delete process.env[key];
|
|
56
|
+
Object.assign(process.env, vars);
|
|
57
|
+
let result: T;
|
|
58
|
+
try {
|
|
59
|
+
result = fn();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
restore();
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
if (result instanceof Promise) return result.finally(restore) as T;
|
|
65
|
+
restore();
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Run `fn` as a production process holding none of the three secrets. */
|
|
70
|
+
function withoutSecret<T>(fn: () => T): T {
|
|
71
|
+
return withEnv({ NODE_ENV: "production" }, fn);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A cookie value minted under `vars`, read back under `readVars`. */
|
|
75
|
+
function mintThenRead(
|
|
76
|
+
vars: Parameters<typeof withEnv>[0],
|
|
77
|
+
readVars: Parameters<typeof withEnv>[0],
|
|
78
|
+
): { id: string; read: string | undefined } {
|
|
79
|
+
const { id, value } = withEnv(vars, mintedCookie);
|
|
80
|
+
const read = withEnv(readVars, () => readConsentVisitor({ get: () => ({ value }) }));
|
|
81
|
+
return { id, read };
|
|
82
|
+
}
|
|
83
|
+
|
|
16
84
|
describe("isValidConsentVisitorId", () => {
|
|
17
85
|
it.each([VALID_ID, "aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee"])("%s is a valid v4 UUID", (id) => {
|
|
18
86
|
expect(isValidConsentVisitorId(id)).toBe(true);
|
|
@@ -30,26 +98,121 @@ describe("isValidConsentVisitorId", () => {
|
|
|
30
98
|
});
|
|
31
99
|
|
|
32
100
|
describe("readConsentVisitor", () => {
|
|
33
|
-
it("returns the
|
|
34
|
-
const
|
|
35
|
-
expect(readConsentVisitor(
|
|
101
|
+
it("returns the UUID of a value ensureConsentVisitor signed", () => {
|
|
102
|
+
const { id, value } = mintedCookie();
|
|
103
|
+
expect(readConsentVisitor({ get: () => ({ value }) })).toBe(id);
|
|
36
104
|
});
|
|
37
105
|
|
|
38
106
|
it("returns undefined with no cookie, or an invalid value", () => {
|
|
39
107
|
expect(readConsentVisitor({ get: () => undefined })).toBeUndefined();
|
|
40
108
|
expect(readConsentVisitor({ get: () => ({ value: "nope" }) })).toBeUndefined();
|
|
41
109
|
});
|
|
110
|
+
|
|
111
|
+
// #8556: a well-formed id the browser chose is not a visitor id the site minted.
|
|
112
|
+
it("returns undefined for a bare valid v4 UUID", () => {
|
|
113
|
+
expect(readConsentVisitor({ get: () => ({ value: VALID_ID }) })).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns undefined, without throwing, for a changed or truncated signature", () => {
|
|
117
|
+
const { value } = mintedCookie();
|
|
118
|
+
expect(readConsentVisitor({ get: () => ({ value: tampered(value) }) })).toBeUndefined();
|
|
119
|
+
expect(() => readConsentVisitor({ get: () => ({ value: value.slice(0, -3) }) })).not.toThrow();
|
|
120
|
+
expect(readConsentVisitor({ get: () => ({ value: value.slice(0, -3) }) })).toBeUndefined();
|
|
121
|
+
expect(readConsentVisitor({ get: () => ({ value: `${VALID_ID}.` }) })).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns undefined for another id under a valid signature", () => {
|
|
125
|
+
const { id, value } = mintedCookie();
|
|
126
|
+
const swapped = value.replace(id, VALID_ID);
|
|
127
|
+
expect(readConsentVisitor({ get: () => ({ value: swapped }) })).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns undefined, without throwing, when the secret cannot be resolved", () => {
|
|
131
|
+
const { value } = mintedCookie();
|
|
132
|
+
withoutSecret(() => expect(readConsentVisitor({ get: () => ({ value }) })).toBeUndefined());
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// #8556, operator decision option (b): the site signs with a secret it
|
|
137
|
+
// already holds — BETTER_AUTH_SECRET, else AILK_HUB_SESSION_SECRET, else
|
|
138
|
+
// AILK_SPOKE_KEY — and only with none of them does a production mint fail.
|
|
139
|
+
describe("the signing secret, in its fixed order (production)", () => {
|
|
140
|
+
const PROD = { NODE_ENV: "production" } as const;
|
|
141
|
+
const AUTH = "auth-secret-of-this-hub-0123456789abcdef";
|
|
142
|
+
const SESSION = "hub-session-secret-of-this-spoke-0123456789";
|
|
143
|
+
const SPOKE = "spoke-key-of-this-spoke-0123456789";
|
|
144
|
+
|
|
145
|
+
it("a hub signs with BETTER_AUTH_SECRET, ahead of the other two", () => {
|
|
146
|
+
const all = { ...PROD, BETTER_AUTH_SECRET: AUTH, AILK_HUB_SESSION_SECRET: SESSION, AILK_SPOKE_KEY: SPOKE };
|
|
147
|
+
const kept = mintThenRead(all, { ...PROD, BETTER_AUTH_SECRET: AUTH });
|
|
148
|
+
expect(kept.read).toBe(kept.id);
|
|
149
|
+
expect(mintThenRead(all, { ...PROD, AILK_HUB_SESSION_SECRET: SESSION }).read).toBeUndefined();
|
|
150
|
+
expect(mintThenRead(all, { ...PROD, AILK_SPOKE_KEY: SPOKE }).read).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("a hub-sign-in spoke signs with AILK_HUB_SESSION_SECRET, ahead of AILK_SPOKE_KEY", () => {
|
|
154
|
+
const spoke = { ...PROD, AILK_HUB_SESSION_SECRET: SESSION, AILK_SPOKE_KEY: SPOKE };
|
|
155
|
+
const kept = mintThenRead(spoke, { ...PROD, AILK_HUB_SESSION_SECRET: SESSION });
|
|
156
|
+
expect(kept.read).toBe(kept.id);
|
|
157
|
+
expect(mintThenRead(spoke, { ...PROD, AILK_SPOKE_KEY: SPOKE }).read).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("a spoke holding only AILK_SPOKE_KEY signs and verifies its own id", () => {
|
|
161
|
+
const spoke = { ...PROD, AILK_SPOKE_KEY: SPOKE };
|
|
162
|
+
const set = jest.fn();
|
|
163
|
+
const id = withEnv(spoke, () => ensureConsentVisitor({ get: () => undefined, set }));
|
|
164
|
+
const value = set.mock.calls[0][1] as string;
|
|
165
|
+
expect(value).toMatch(new RegExp(`^${id}\\.[A-Za-z0-9_-]{43}$`));
|
|
166
|
+
expect(withEnv(spoke, () => readConsentVisitor({ get: () => ({ value }) }))).toBe(id);
|
|
167
|
+
expect(withEnv(spoke, () => readConsentVisitorFromCookieHeader(`ailk-vid=${value}`))).toBe(id);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("a verifier accepts only what its own site signed", () => {
|
|
171
|
+
const other = (key: string) => `${key}-of-another-site`;
|
|
172
|
+
expect(mintThenRead({ ...PROD, AILK_SPOKE_KEY: SPOKE }, { ...PROD, AILK_SPOKE_KEY: other(SPOKE) }).read).toBeUndefined();
|
|
173
|
+
expect(
|
|
174
|
+
mintThenRead(
|
|
175
|
+
{ ...PROD, AILK_HUB_SESSION_SECRET: SESSION },
|
|
176
|
+
{ ...PROD, AILK_HUB_SESSION_SECRET: other(SESSION) },
|
|
177
|
+
).read,
|
|
178
|
+
).toBeUndefined();
|
|
179
|
+
expect(
|
|
180
|
+
mintThenRead({ ...PROD, BETTER_AUTH_SECRET: AUTH }, { ...PROD, BETTER_AUTH_SECRET: other(AUTH) }).read,
|
|
181
|
+
).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("a whitespace-only or short spoke secret is none: with nothing else set the mint throws", () => {
|
|
185
|
+
const set = jest.fn();
|
|
186
|
+
for (const vars of [
|
|
187
|
+
{ AILK_HUB_SESSION_SECRET: " ", AILK_SPOKE_KEY: " " },
|
|
188
|
+
{ AILK_HUB_SESSION_SECRET: "x".repeat(31), AILK_SPOKE_KEY: ` ${"y".repeat(31)} ` },
|
|
189
|
+
]) {
|
|
190
|
+
withEnv({ ...PROD, ...vars }, () =>
|
|
191
|
+
expect(() => ensureConsentVisitor({ get: () => undefined, set })).toThrow(/BETTER_AUTH_SECRET/),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
expect(set).not.toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("a short AILK_HUB_SESSION_SECRET is skipped for AILK_SPOKE_KEY", () => {
|
|
198
|
+
const kept = mintThenRead(
|
|
199
|
+
{ ...PROD, AILK_HUB_SESSION_SECRET: "too-short", AILK_SPOKE_KEY: SPOKE },
|
|
200
|
+
{ ...PROD, AILK_SPOKE_KEY: SPOKE },
|
|
201
|
+
);
|
|
202
|
+
expect(kept.read).toBe(kept.id);
|
|
203
|
+
});
|
|
42
204
|
});
|
|
43
205
|
|
|
44
206
|
describe("ensureConsentVisitor", () => {
|
|
45
|
-
it("keeps an existing
|
|
207
|
+
it("keeps an existing signed id and sets nothing", () => {
|
|
208
|
+
const { id, value } = mintedCookie();
|
|
46
209
|
const set = jest.fn();
|
|
47
|
-
const cookies = { get: () => ({ value
|
|
48
|
-
expect(ensureConsentVisitor(cookies)).toBe(
|
|
210
|
+
const cookies = { get: () => ({ value }), set };
|
|
211
|
+
expect(ensureConsentVisitor(cookies)).toBe(id);
|
|
49
212
|
expect(set).not.toHaveBeenCalled();
|
|
50
213
|
});
|
|
51
214
|
|
|
52
|
-
it("mints and sets a fresh v4 UUID when absent", () => {
|
|
215
|
+
it("mints and sets a fresh signed v4 UUID when absent", () => {
|
|
53
216
|
const set = jest.fn();
|
|
54
217
|
const cookies = { get: () => undefined, set };
|
|
55
218
|
const id = ensureConsentVisitor(cookies);
|
|
@@ -57,10 +220,25 @@ describe("ensureConsentVisitor", () => {
|
|
|
57
220
|
expect(set).toHaveBeenCalledTimes(1);
|
|
58
221
|
const [name, value, options] = set.mock.calls[0] as [string, string, Record<string, unknown>];
|
|
59
222
|
expect(name).toBe("ailk-vid");
|
|
60
|
-
expect(value).
|
|
223
|
+
expect(value).toMatch(new RegExp(`^${id}\\.[A-Za-z0-9_-]{43}$`));
|
|
61
224
|
expect(options).toMatchObject({ httpOnly: true, sameSite: "lax", path: "/" });
|
|
62
225
|
});
|
|
63
226
|
|
|
227
|
+
it("re-mints over a bare UUID, an unsigned cookie from before #8556", () => {
|
|
228
|
+
const set = jest.fn();
|
|
229
|
+
const id = ensureConsentVisitor({ get: () => ({ value: VALID_ID }), set });
|
|
230
|
+
expect(id).not.toBe(VALID_ID);
|
|
231
|
+
expect(set).toHaveBeenCalledTimes(1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("throws and sets nothing when the secret cannot be resolved", () => {
|
|
235
|
+
const set = jest.fn();
|
|
236
|
+
withoutSecret(() =>
|
|
237
|
+
expect(() => ensureConsentVisitor({ get: () => undefined, set })).toThrow(/BETTER_AUTH_SECRET/),
|
|
238
|
+
);
|
|
239
|
+
expect(set).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
|
|
64
242
|
it("mints a fresh id when the stored value fails the UUID shape", () => {
|
|
65
243
|
const set = jest.fn();
|
|
66
244
|
const cookies = { get: () => ({ value: "garbage" }), set };
|
|
@@ -78,8 +256,9 @@ describe("consentVisitorHeader", () => {
|
|
|
78
256
|
|
|
79
257
|
describe("readConsentVisitorFromCookieHeader", () => {
|
|
80
258
|
it("reads the id out of a raw Cookie header", () => {
|
|
81
|
-
|
|
82
|
-
expect(readConsentVisitorFromCookieHeader(`
|
|
259
|
+
const { id, value } = mintedCookie();
|
|
260
|
+
expect(readConsentVisitorFromCookieHeader(`ailk-vid=${value}`)).toBe(id);
|
|
261
|
+
expect(readConsentVisitorFromCookieHeader(`session=abc; ailk-vid=${value}; other=1`)).toBe(id);
|
|
83
262
|
});
|
|
84
263
|
|
|
85
264
|
it("returns undefined with no header, no matching cookie, or an invalid value", () => {
|
|
@@ -88,6 +267,12 @@ describe("readConsentVisitorFromCookieHeader", () => {
|
|
|
88
267
|
expect(readConsentVisitorFromCookieHeader("session=abc")).toBeUndefined();
|
|
89
268
|
expect(readConsentVisitorFromCookieHeader("ailk-vid=garbage")).toBeUndefined();
|
|
90
269
|
});
|
|
270
|
+
|
|
271
|
+
it("returns undefined for a bare UUID or a changed signature (#8556)", () => {
|
|
272
|
+
const { value } = mintedCookie();
|
|
273
|
+
expect(readConsentVisitorFromCookieHeader(`ailk-vid=${VALID_ID}`)).toBeUndefined();
|
|
274
|
+
expect(readConsentVisitorFromCookieHeader(`ailk-vid=${tampered(value)}`)).toBeUndefined();
|
|
275
|
+
});
|
|
91
276
|
});
|
|
92
277
|
|
|
93
278
|
// ── acceptCookiesAction / declineCookiesAction (D12) ──────────────────────
|
|
@@ -134,15 +319,29 @@ describe("acceptCookiesAction / declineCookiesAction", () => {
|
|
|
134
319
|
});
|
|
135
320
|
|
|
136
321
|
it("keeps an existing visitor id and declines", async () => {
|
|
137
|
-
|
|
322
|
+
const { id, value } = mintedCookie();
|
|
323
|
+
mockCookieGet.mockReturnValue({ value });
|
|
138
324
|
const { declineCookiesAction } = await import("../../app/[locale]/consent.actions");
|
|
139
325
|
await declineCookiesAction();
|
|
140
326
|
expect(mockCookieSet).not.toHaveBeenCalled();
|
|
141
327
|
const [body] = mockRecordCookieChoice.mock.calls[0] as [{ visitorId: string; choice: string }];
|
|
142
|
-
expect(body.visitorId).toBe(
|
|
328
|
+
expect(body.visitorId).toBe(id);
|
|
143
329
|
expect(body.choice).toBe("declined");
|
|
144
330
|
});
|
|
145
331
|
|
|
332
|
+
it("records nothing and resolves when the secret cannot be resolved (fail closed)", async () => {
|
|
333
|
+
const error = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
334
|
+
const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
|
|
335
|
+
try {
|
|
336
|
+
await withoutSecret(() => expect(acceptCookiesAction()).resolves.toBeUndefined());
|
|
337
|
+
expect(error).toHaveBeenCalled();
|
|
338
|
+
} finally {
|
|
339
|
+
error.mockRestore();
|
|
340
|
+
}
|
|
341
|
+
expect(mockCookieSet).not.toHaveBeenCalled();
|
|
342
|
+
expect(mockRecordCookieChoice).not.toHaveBeenCalled();
|
|
343
|
+
});
|
|
344
|
+
|
|
146
345
|
it("resolves even when the client rejects", async () => {
|
|
147
346
|
mockRecordCookieChoice.mockRejectedValue(new TypeError("fetch failed"));
|
|
148
347
|
const { acceptCookiesAction } = await import("../../app/[locale]/consent.actions");
|
|
@@ -50,6 +50,12 @@ describe("deckCandidates (#8687 D3, AC-2)", () => {
|
|
|
50
50
|
]);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
it("/pricing/<offering> resolves the offering's Pricing page (#10044 D14)", () => {
|
|
54
|
+
expect(deckCandidates("/pricing/website-rebuild", LOCALES)).toEqual([
|
|
55
|
+
{ type: "Pricing", slug: "website-rebuild" },
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
53
59
|
it("/about (core, always scaffolded) resolves the route row only", () => {
|
|
54
60
|
expect(deckCandidates("/about", LOCALES)).toEqual([
|
|
55
61
|
{ type: "AboutPage", slug: "about" },
|
|
@@ -108,7 +114,13 @@ describe("DECK_ROUTES parity (#8687 D3)", () => {
|
|
|
108
114
|
} catch {
|
|
109
115
|
continue;
|
|
110
116
|
}
|
|
111
|
-
|
|
117
|
+
// A content hub (#10121) names its body rule in its HubConfig; the
|
|
118
|
+
// "mdx-body" and "composed" rules can render the MDX body, as the
|
|
119
|
+
// inline `bodyMode` literal they replaced did. "sections" never does.
|
|
120
|
+
const rendersMdxBody =
|
|
121
|
+
pageSource.includes('bodyMode: "mdx-body"') ||
|
|
122
|
+
/\bbody: "(?:mdx-body|composed)"/.test(pageSource);
|
|
123
|
+
if (!rendersMdxBody) {
|
|
112
124
|
sectionsRoutes.add(row.route);
|
|
113
125
|
}
|
|
114
126
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*
|
|
4
|
+
* #10041 D8 / AC-2d — `listDocsPages` walks a docs directory and returns one
|
|
5
|
+
* entry per titled page at the docs' public path, by Fumadocs' slug rule.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
type DocsIndexModule = typeof import("../docs-index");
|
|
12
|
+
|
|
13
|
+
function loadWith(docsPath: string | undefined): DocsIndexModule {
|
|
14
|
+
if (docsPath === undefined) delete process.env.NEXT_PUBLIC_AILK_DOCS_PATH;
|
|
15
|
+
else process.env.NEXT_PUBLIC_AILK_DOCS_PATH = docsPath;
|
|
16
|
+
let mod: DocsIndexModule | undefined;
|
|
17
|
+
try {
|
|
18
|
+
jest.isolateModules(() => {
|
|
19
|
+
mod = jest.requireActual<DocsIndexModule>("../docs-index");
|
|
20
|
+
});
|
|
21
|
+
} finally {
|
|
22
|
+
delete process.env.NEXT_PUBLIC_AILK_DOCS_PATH;
|
|
23
|
+
}
|
|
24
|
+
return mod!;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function page(title: string | null, description?: string): string {
|
|
28
|
+
const lines = ["---"];
|
|
29
|
+
if (title !== null) lines.push(`title: ${title}`);
|
|
30
|
+
if (description) lines.push(`description: ${description}`);
|
|
31
|
+
lines.push("---", "", "Body.", "");
|
|
32
|
+
return lines.join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let dir: string;
|
|
36
|
+
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
dir = mkdtempSync(join(tmpdir(), "docs-index-"));
|
|
39
|
+
mkdirSync(join(dir, "guides", "(setup)"), { recursive: true });
|
|
40
|
+
writeFileSync(join(dir, "index.mdx"), page("Docs", "Everything."));
|
|
41
|
+
writeFileSync(join(dir, "configure.mdx"), page("Configure", "Set it up."));
|
|
42
|
+
writeFileSync(join(dir, "guides", "(setup)", "dns.mdx"), page("DNS"));
|
|
43
|
+
writeFileSync(join(dir, "draft.mdx"), page(null, "No title yet."));
|
|
44
|
+
writeFileSync(join(dir, "notes.txt"), "not a doc");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("listDocsPages", () => {
|
|
52
|
+
it("lists every titled page at the configured path, by the slug rule", () => {
|
|
53
|
+
const { listDocsPages } = loadWith("/working-theory/docs");
|
|
54
|
+
expect(listDocsPages(dir)).toEqual([
|
|
55
|
+
{
|
|
56
|
+
url: "/working-theory/docs/configure",
|
|
57
|
+
title: "Configure",
|
|
58
|
+
description: "Set it up.",
|
|
59
|
+
},
|
|
60
|
+
{ url: "/working-theory/docs/guides/dns", title: "DNS" },
|
|
61
|
+
{
|
|
62
|
+
url: "/working-theory/docs",
|
|
63
|
+
title: "Docs",
|
|
64
|
+
description: "Everything.",
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("lists at /docs with no setting", () => {
|
|
70
|
+
const { listDocsPages } = loadWith(undefined);
|
|
71
|
+
expect(listDocsPages(dir).map((p) => p.url)).toEqual([
|
|
72
|
+
"/docs/configure",
|
|
73
|
+
"/docs/guides/dns",
|
|
74
|
+
"/docs",
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns [] for an absent directory", () => {
|
|
79
|
+
const { listDocsPages } = loadWith("/working-theory/docs");
|
|
80
|
+
expect(listDocsPages(join(dir, "missing"))).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("maps an adapter docs row onto the docs path", () => {
|
|
84
|
+
const { adapterDocsUrl, isDocsType } = loadWith("/working-theory/docs");
|
|
85
|
+
expect(isDocsType("DocsDetail")).toBe(true);
|
|
86
|
+
expect(isDocsType("DocsList")).toBe(true);
|
|
87
|
+
expect(isDocsType("BlogPosting")).toBe(false);
|
|
88
|
+
expect(adapterDocsUrl("DocsDetail", "configure")).toBe(
|
|
89
|
+
"/working-theory/docs/configure",
|
|
90
|
+
);
|
|
91
|
+
expect(adapterDocsUrl("DocsList", "index")).toBe("/working-theory/docs");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*
|
|
4
|
+
* #10041 D2 — the one docs-path reader. `DOCS_PATH` is read from
|
|
5
|
+
* `NEXT_PUBLIC_AILK_DOCS_PATH` at module load, so every setting loads the
|
|
6
|
+
* module afresh under `jest.isolateModules`.
|
|
7
|
+
*/
|
|
8
|
+
import type { NavItem } from "@working-theory/ui";
|
|
9
|
+
import { Menu as Icon } from "lucide-react";
|
|
10
|
+
|
|
11
|
+
type DocsPathModule = typeof import("../docs-path");
|
|
12
|
+
|
|
13
|
+
function loadWith(docsPath: string | undefined): DocsPathModule {
|
|
14
|
+
const previous = process.env.NEXT_PUBLIC_AILK_DOCS_PATH;
|
|
15
|
+
if (docsPath === undefined) delete process.env.NEXT_PUBLIC_AILK_DOCS_PATH;
|
|
16
|
+
else process.env.NEXT_PUBLIC_AILK_DOCS_PATH = docsPath;
|
|
17
|
+
let mod: DocsPathModule | undefined;
|
|
18
|
+
jest.isolateModules(() => {
|
|
19
|
+
mod = jest.requireActual<DocsPathModule>("../docs-path");
|
|
20
|
+
});
|
|
21
|
+
if (previous === undefined) delete process.env.NEXT_PUBLIC_AILK_DOCS_PATH;
|
|
22
|
+
else process.env.NEXT_PUBLIC_AILK_DOCS_PATH = previous;
|
|
23
|
+
return mod!;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("docs-path — no setting (AC-3)", () => {
|
|
27
|
+
const m = loadWith(undefined);
|
|
28
|
+
|
|
29
|
+
it("serves the docs at the internal route", () => {
|
|
30
|
+
expect(m.DOCS_ROUTE).toBe("/docs");
|
|
31
|
+
expect(m.DOCS_PATH).toBe("/docs");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it.each([
|
|
35
|
+
"/docs",
|
|
36
|
+
"/docs/configure",
|
|
37
|
+
"/docs?q=1",
|
|
38
|
+
"/docs#a",
|
|
39
|
+
"/about",
|
|
40
|
+
"https://example.com/docs",
|
|
41
|
+
])("toPublicDocsHref is the identity for %s", (href) => {
|
|
42
|
+
expect(m.toPublicDocsHref(href)).toBe(href);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("maps nothing to the internal route", () => {
|
|
46
|
+
expect(m.toInternalDocsPath("/docs")).toBeNull();
|
|
47
|
+
expect(m.toInternalDocsPath("/working-theory/docs")).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("docs-path — docsPath /working-theory/docs (AC-2a, AC-2b)", () => {
|
|
52
|
+
const m = loadWith("/working-theory/docs");
|
|
53
|
+
|
|
54
|
+
it("reads the public base from the env", () => {
|
|
55
|
+
expect(m.DOCS_ROUTE).toBe("/docs");
|
|
56
|
+
expect(m.DOCS_PATH).toBe("/working-theory/docs");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it.each([
|
|
60
|
+
["/docs", "/working-theory/docs"],
|
|
61
|
+
["/docs/configure", "/working-theory/docs/configure"],
|
|
62
|
+
["/docs/deploy#dns", "/working-theory/docs/deploy#dns"],
|
|
63
|
+
["/docs?q=x", "/working-theory/docs?q=x"],
|
|
64
|
+
["/docs#top", "/working-theory/docs#top"],
|
|
65
|
+
])("rewrites %s to %s", (href, expected) => {
|
|
66
|
+
expect(m.toPublicDocsHref(href)).toBe(expected);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it.each([
|
|
70
|
+
"https://example.com/docs",
|
|
71
|
+
"/docsets",
|
|
72
|
+
"/docs-old",
|
|
73
|
+
"/pt-BR/docs",
|
|
74
|
+
"",
|
|
75
|
+
"#docs",
|
|
76
|
+
"mailto:a@b.co",
|
|
77
|
+
])("leaves %s unchanged", (href) => {
|
|
78
|
+
expect(m.toPublicDocsHref(href)).toBe(href);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it.each([
|
|
82
|
+
["/working-theory/docs", "/docs"],
|
|
83
|
+
["/working-theory/docs/configure", "/docs/configure"],
|
|
84
|
+
["/working-theory/docs/guides/dns", "/docs/guides/dns"],
|
|
85
|
+
])("maps the public %s to the internal %s", (bare, internal) => {
|
|
86
|
+
expect(m.toInternalDocsPath(bare)).toBe(internal);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it.each([
|
|
90
|
+
"/docs",
|
|
91
|
+
"/docs/configure",
|
|
92
|
+
"/working-theory",
|
|
93
|
+
"/working-theory/docsets",
|
|
94
|
+
"/working-theory/docs-x",
|
|
95
|
+
"/",
|
|
96
|
+
])("does not map %s", (bare) => {
|
|
97
|
+
expect(m.toInternalDocsPath(bare)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("maps every nav path at /docs and keeps everything else by reference (AC-2c)", () => {
|
|
101
|
+
const items: NavItem[] = [
|
|
102
|
+
{
|
|
103
|
+
labelKey: "product",
|
|
104
|
+
flyout: {
|
|
105
|
+
features: [
|
|
106
|
+
{ labelKey: "f1", path: "/docs/configure", icon: Icon },
|
|
107
|
+
{ labelKey: "f2", path: "/features/one" },
|
|
108
|
+
],
|
|
109
|
+
groups: [
|
|
110
|
+
{ headingKey: "h", items: [{ labelKey: "g1", path: "/docs" }] },
|
|
111
|
+
],
|
|
112
|
+
actions: [{ labelKey: "a1", path: "/docs/start", icon: Icon }],
|
|
113
|
+
width: "panel",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{ labelKey: "docs", path: "/docs" },
|
|
117
|
+
{ labelKey: "pricing", path: "/pricing", emphasis: "brand" },
|
|
118
|
+
];
|
|
119
|
+
const mapped = m.withPublicDocsPaths(items);
|
|
120
|
+
|
|
121
|
+
expect(mapped[0]!.flyout!.features.map((f) => f.path)).toEqual([
|
|
122
|
+
"/working-theory/docs/configure",
|
|
123
|
+
"/features/one",
|
|
124
|
+
]);
|
|
125
|
+
expect(mapped[0]!.flyout!.groups![0]!.items[0]!.path).toBe(
|
|
126
|
+
"/working-theory/docs",
|
|
127
|
+
);
|
|
128
|
+
expect(mapped[0]!.flyout!.actions[0]!.path).toBe(
|
|
129
|
+
"/working-theory/docs/start",
|
|
130
|
+
);
|
|
131
|
+
expect(mapped[0]!.flyout!.actions[0]!.icon).toBe(Icon);
|
|
132
|
+
expect(mapped[0]!.flyout!.features[0]!.icon).toBe(Icon);
|
|
133
|
+
expect(mapped[0]!.flyout!.width).toBe("panel");
|
|
134
|
+
expect(mapped[1]).toEqual({
|
|
135
|
+
labelKey: "docs",
|
|
136
|
+
path: "/working-theory/docs",
|
|
137
|
+
});
|
|
138
|
+
expect(mapped[2]).toEqual({
|
|
139
|
+
labelKey: "pricing",
|
|
140
|
+
path: "/pricing",
|
|
141
|
+
emphasis: "brand",
|
|
142
|
+
});
|
|
143
|
+
// The source array is not mutated.
|
|
144
|
+
expect(items[1]!.path).toBe("/docs");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("maps footer column items", () => {
|
|
148
|
+
const cols = [
|
|
149
|
+
{
|
|
150
|
+
headingKey: "h",
|
|
151
|
+
items: [
|
|
152
|
+
{ labelKey: "docs", path: "/docs/configure" },
|
|
153
|
+
{ labelKey: "a", path: "/about" },
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
expect(m.withPublicDocsColumns(cols)).toEqual([
|
|
158
|
+
{
|
|
159
|
+
headingKey: "h",
|
|
160
|
+
items: [
|
|
161
|
+
{ labelKey: "docs", path: "/working-theory/docs/configure" },
|
|
162
|
+
{ labelKey: "a", path: "/about" },
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
]);
|
|
166
|
+
expect(cols[0]!.items[0]!.path).toBe("/docs/configure");
|
|
167
|
+
});
|
|
168
|
+
});
|