create-ailk 0.27.0 → 0.28.1
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/chrome-readers.d.ts +21 -5
- package/dist/lib/chrome-readers.js +128 -20
- package/dist/lib/declared-env.d.ts +6 -0
- package/dist/lib/declared-env.js +2 -0
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/import-graph.d.ts +16 -0
- package/dist/lib/import-graph.js +53 -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/template-record.d.ts +13 -4
- package/dist/template-record.js +8 -4
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +34 -2
- package/dist/update.js +395 -48
- 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 +224 -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 +237 -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 +279 -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/templates/tools/declared-env.ts +7 -0
- package/versions.json +48 -48
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// JobPosting detail page. Reads the JobPosting MDX for the given slug, renders via
|
|
2
|
-
//
|
|
2
|
+
// @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
|
|
3
3
|
// Careers list at /careers links here; each job posting lives at /careers/<slug>.
|
|
4
4
|
//
|
|
5
5
|
// Under the posting, inside <main>, an Apply button (#8849 D8) opens
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
// form this page used to draw under `#apply`. The posting's JSON-LD
|
|
10
10
|
// ApplyAction targets the same URL (#8849 D3). The page makes no hub read.
|
|
11
11
|
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
getMdxSource as loadMdxSource,
|
|
14
|
+
renderPage,
|
|
15
|
+
renderPageMetadata,
|
|
16
|
+
} from "@working-theory/page-renderer";
|
|
13
17
|
import { Button, Container, Link, localeHref } from "@working-theory/ui";
|
|
14
18
|
import type { Metadata } from "next";
|
|
15
19
|
import { getTranslations } from "next-intl/server";
|
|
@@ -32,12 +36,25 @@ export default async function Page({
|
|
|
32
36
|
getMdxSource,
|
|
33
37
|
}: Props): Promise<ReactElement> {
|
|
34
38
|
const { locale, slug } = await params;
|
|
39
|
+
// A page that composes `sections[]` renders them (JSON-LD is built from
|
|
40
|
+
// the same sections); a prose page renders its MDX body — the rule
|
|
41
|
+
// /vs/[slug] applies (#10122).
|
|
42
|
+
const source = await (getMdxSource ?? loadMdxSource)(
|
|
43
|
+
"JobPosting",
|
|
44
|
+
slug,
|
|
45
|
+
locale,
|
|
46
|
+
);
|
|
47
|
+
const composed =
|
|
48
|
+
source !== null &&
|
|
49
|
+
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
50
|
+
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
|
|
51
|
+
0;
|
|
35
52
|
const t = await getTranslations({ locale, namespace: "careers" });
|
|
36
53
|
return renderPage({
|
|
37
54
|
type: "JobPosting",
|
|
38
55
|
slug,
|
|
39
56
|
locale,
|
|
40
|
-
bodyMode: "mdx-body",
|
|
57
|
+
...(composed ? {} : { bodyMode: "mdx-body" as const }),
|
|
41
58
|
trailing: (
|
|
42
59
|
<Container className="pb-section">
|
|
43
60
|
<Button asChild size="big">
|
|
@@ -10,6 +10,13 @@ import {
|
|
|
10
10
|
} from "../../../../src/lib/rate-limit";
|
|
11
11
|
import { captureLeadAction } from "../[slug]/apply.actions";
|
|
12
12
|
|
|
13
|
+
// #9255 — the write bot check's verdict. `checkBotId` reads Vercel's
|
|
14
|
+
// request context, which jest has none of, so every case decides it here.
|
|
15
|
+
const checkBotIdMock = jest.fn();
|
|
16
|
+
jest.mock("botid/server", () => ({
|
|
17
|
+
checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
|
|
18
|
+
}));
|
|
19
|
+
|
|
13
20
|
jest.mock("@working-theory/api-client", () => ({
|
|
14
21
|
leads: { submit: jest.fn() },
|
|
15
22
|
visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
|
|
@@ -80,6 +87,23 @@ afterEach(() => {
|
|
|
80
87
|
});
|
|
81
88
|
|
|
82
89
|
describe("captureLeadAction on a job page (#8746 AC-5)", () => {
|
|
90
|
+
// On Vercel, where the edge-set address is read (#9967 D1); the bot check
|
|
91
|
+
// has its own suite below.
|
|
92
|
+
const LIMIT_ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK"] as const;
|
|
93
|
+
const priorLimitEnv = new Map(
|
|
94
|
+
LIMIT_ENV_KEYS.map((name) => [name, process.env[name]]),
|
|
95
|
+
);
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
process.env.VERCEL = "1";
|
|
98
|
+
process.env.AILK_BOT_CHECK = "off";
|
|
99
|
+
});
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
for (const [name, value] of priorLimitEnv) {
|
|
102
|
+
if (value === undefined) delete process.env[name];
|
|
103
|
+
else process.env[name] = value;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
83
107
|
it("writes one flow:job-application lead with name = first + last and the hidden job slug", async () => {
|
|
84
108
|
const result = await captureLeadAction(JOB, null, form(VALID));
|
|
85
109
|
|
|
@@ -197,7 +221,7 @@ describe("captureLeadAction on a job page (#8746 AC-5)", () => {
|
|
|
197
221
|
it("shares /contact's per-visitor bucket", async () => {
|
|
198
222
|
mockHeaders.mockResolvedValue({
|
|
199
223
|
get: (name: string) =>
|
|
200
|
-
name === "x-forwarded-for" ? "203.0.113.7" : null,
|
|
224
|
+
name === "x-vercel-forwarded-for" ? "203.0.113.7" : null,
|
|
201
225
|
});
|
|
202
226
|
for (let i = 0; i < 30; i += 1)
|
|
203
227
|
checkRateLimit("contact:203.0.113.7", 30, 10 * 60 * 1000);
|
|
@@ -247,7 +271,7 @@ describe("captureLeadAction on a job page (#8746 AC-5)", () => {
|
|
|
247
271
|
it("limits one visitor to 30 applications per 10 minutes", async () => {
|
|
248
272
|
mockHeaders.mockResolvedValue({
|
|
249
273
|
get: (name: string) =>
|
|
250
|
-
name === "x-forwarded-for" ? "203.0.113.9" : null,
|
|
274
|
+
name === "x-vercel-forwarded-for" ? "203.0.113.9" : null,
|
|
251
275
|
});
|
|
252
276
|
for (let i = 0; i < 30; i += 1) {
|
|
253
277
|
expect(await captureLeadAction(JOB, null, form(VALID))).toEqual({
|
|
@@ -259,6 +283,27 @@ describe("captureLeadAction on a job page (#8746 AC-5)", () => {
|
|
|
259
283
|
});
|
|
260
284
|
expect(mockSubmit).toHaveBeenCalledTimes(30);
|
|
261
285
|
});
|
|
286
|
+
|
|
287
|
+
it("charges a visitor with no readable address /contact's shared bucket, never skips it (#9967)", async () => {
|
|
288
|
+
// A client-sent x-forwarded-for is no address: it changes on every post.
|
|
289
|
+
let n = 0;
|
|
290
|
+
mockHeaders.mockImplementation(async () => {
|
|
291
|
+
n += 1;
|
|
292
|
+
return {
|
|
293
|
+
get: (name: string) =>
|
|
294
|
+
name === "x-forwarded-for" ? `10.0.${n >> 8}.${n & 255}` : null,
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
for (let i = 0; i < 300; i += 1) {
|
|
298
|
+
expect(await captureLeadAction(JOB, null, form(VALID))).toEqual({
|
|
299
|
+
kind: "success",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
expect(await captureLeadAction(JOB, null, form(VALID))).toMatchObject({
|
|
303
|
+
kind: "error",
|
|
304
|
+
});
|
|
305
|
+
expect(mockSubmit).toHaveBeenCalledTimes(300);
|
|
306
|
+
});
|
|
262
307
|
});
|
|
263
308
|
|
|
264
309
|
describe("the thank-you's account link (#8771 AC-6)", () => {
|
|
@@ -317,3 +362,136 @@ describe("the thank-you's account link (#8771 AC-6)", () => {
|
|
|
317
362
|
expect(mockSubmit).not.toHaveBeenCalled();
|
|
318
363
|
});
|
|
319
364
|
});
|
|
365
|
+
|
|
366
|
+
// #9255 D4 — the write bot check runs after the honeypot, the consent and
|
|
367
|
+
// the schema, immediately before the write (spec
|
|
368
|
+
// docs/specs/9255-an-edge-bot-challenge-in-spec.md §5.1 AC-2).
|
|
369
|
+
describe("captureLeadAction on a job page — the write bot check (#9255)", () => {
|
|
370
|
+
const BOT = { isHuman: false, isBot: true, isVerifiedBot: false, bypassed: false };
|
|
371
|
+
const HUMAN = { ...BOT, isHuman: true, isBot: false };
|
|
372
|
+
const BOT_ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK", "AILK_BOOKING_BOT_CHECK"] as const;
|
|
373
|
+
const priorBotEnv = new Map(BOT_ENV_KEYS.map((name) => [name, process.env[name]]));
|
|
374
|
+
|
|
375
|
+
let botLogSpies: jest.SpyInstance[] = [];
|
|
376
|
+
|
|
377
|
+
beforeEach(() => {
|
|
378
|
+
checkBotIdMock.mockReset();
|
|
379
|
+
for (const name of BOT_ENV_KEYS) delete process.env[name];
|
|
380
|
+
process.env.VERCEL = "1";
|
|
381
|
+
botLogSpies = [
|
|
382
|
+
jest.spyOn(console, "warn").mockImplementation(() => undefined),
|
|
383
|
+
jest.spyOn(console, "error").mockImplementation(() => undefined),
|
|
384
|
+
];
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
afterEach(() => {
|
|
388
|
+
for (const [name, value] of priorBotEnv) {
|
|
389
|
+
if (value === undefined) delete process.env[name];
|
|
390
|
+
else process.env[name] = value;
|
|
391
|
+
}
|
|
392
|
+
for (const spy of botLogSpies) spy.mockRestore();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("a bot gets the generic error, and nothing is sent", async () => {
|
|
396
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
397
|
+
expect(await captureLeadAction(JOB, null, form(VALID))).toStrictEqual({
|
|
398
|
+
kind: "error",
|
|
399
|
+
message: "Something went wrong, please try again.",
|
|
400
|
+
});
|
|
401
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
402
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("a check that fails is unavailable, and nothing is sent", async () => {
|
|
406
|
+
checkBotIdMock.mockRejectedValue(new Error("boom"));
|
|
407
|
+
expect(await captureLeadAction(JOB, null, form(VALID))).toStrictEqual({ kind: "unavailable" });
|
|
408
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("a human sends exactly what the check off sends", async () => {
|
|
412
|
+
checkBotIdMock.mockResolvedValue(HUMAN);
|
|
413
|
+
delete process.env.VERCEL;
|
|
414
|
+
await captureLeadAction(JOB, null, form(VALID));
|
|
415
|
+
process.env.VERCEL = "1";
|
|
416
|
+
expect(await captureLeadAction(JOB, null, form(VALID))).toEqual({ kind: "success" });
|
|
417
|
+
expect(checkBotIdMock).toHaveBeenCalledTimes(1);
|
|
418
|
+
expect(mockSubmit).toHaveBeenCalledTimes(2);
|
|
419
|
+
expect(mockSubmit.mock.calls[1]).toEqual(mockSubmit.mock.calls[0]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("a filled honeypot answers success with no check", async () => {
|
|
423
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
424
|
+
expect(await captureLeadAction(JOB, null, form({ ...VALID, ailk_hp: "spam" }))).toEqual({
|
|
425
|
+
kind: "success",
|
|
426
|
+
});
|
|
427
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("a post without consent answers the consent error with no check", async () => {
|
|
431
|
+
checkBotIdMock.mockResolvedValue(BOT);
|
|
432
|
+
const { consent: _consent, ...unticked } = VALID;
|
|
433
|
+
expect(await captureLeadAction(JOB, null, form(unticked))).toEqual({
|
|
434
|
+
kind: "error",
|
|
435
|
+
fieldErrors: { consent: expect.any(String) },
|
|
436
|
+
});
|
|
437
|
+
expect(checkBotIdMock).not.toHaveBeenCalled();
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe("captureLeadAction records the posting's named flow (#10054 D6, AC-7)", () => {
|
|
442
|
+
const ENV_KEYS = ["VERCEL", "AILK_BOT_CHECK"] as const;
|
|
443
|
+
const prior = new Map(ENV_KEYS.map((name) => [name, process.env[name]]));
|
|
444
|
+
beforeEach(() => {
|
|
445
|
+
process.env.VERCEL = "1";
|
|
446
|
+
process.env.AILK_BOT_CHECK = "off";
|
|
447
|
+
});
|
|
448
|
+
afterEach(() => {
|
|
449
|
+
for (const [name, value] of prior) {
|
|
450
|
+
if (value === undefined) delete process.env[name];
|
|
451
|
+
else process.env[name] = value;
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const NAMED = { job: "writer", flow: "writer-application" };
|
|
456
|
+
|
|
457
|
+
it("writes applicationFlow beside job, keeping the Job application source and consent", async () => {
|
|
458
|
+
expect(await captureLeadAction(NAMED, null, form(VALID))).toEqual({ kind: "success" });
|
|
459
|
+
const body = submitted();
|
|
460
|
+
expect(body.source).toBe("flow:job-application");
|
|
461
|
+
expect(body.consent).toBe(true);
|
|
462
|
+
expect(body.payload).toEqual({
|
|
463
|
+
name: "Ada Lovelace",
|
|
464
|
+
first_name: "Ada",
|
|
465
|
+
last_name: "Lovelace",
|
|
466
|
+
job: "writer",
|
|
467
|
+
applicationFlow: "writer-application",
|
|
468
|
+
page: "careers",
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("a form field named applicationFlow does not override it", async () => {
|
|
473
|
+
await captureLeadAction(NAMED, null, form({ ...VALID, applicationFlow: "other-flow" }));
|
|
474
|
+
expect(submitted().payload["applicationFlow"]).toBe("writer-application");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("a string posting writes no applicationFlow, even from a form field", async () => {
|
|
478
|
+
await captureLeadAction(JOB, null, form({ ...VALID, applicationFlow: "other-flow" }));
|
|
479
|
+
expect(submitted().payload).not.toHaveProperty("applicationFlow");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it.each([
|
|
483
|
+
[{ job: "writer", flow: "Bad Flow" }],
|
|
484
|
+
[{ job: "Bad", flow: "x" }],
|
|
485
|
+
[{ job: "writer", flow: "a".repeat(60) }],
|
|
486
|
+
[{ job: "writer" }],
|
|
487
|
+
])("%p answers the generic error with nothing sent", async (posting) => {
|
|
488
|
+
const result = await captureLeadAction(
|
|
489
|
+
// eslint-disable-next-line no-restricted-syntax -- a malformed bound posting is exactly a value of the wrong shape
|
|
490
|
+
posting as unknown as { job: string; flow: string },
|
|
491
|
+
null,
|
|
492
|
+
form(VALID),
|
|
493
|
+
);
|
|
494
|
+
expect(result).toMatchObject({ kind: "error", message: expect.any(String) });
|
|
495
|
+
expect(mockSubmit).not.toHaveBeenCalled();
|
|
496
|
+
});
|
|
497
|
+
});
|
|
@@ -70,7 +70,7 @@ const getMdxSource = jest.fn<ReturnType<GetMdxSource>, Parameters<GetMdxSource>>
|
|
|
70
70
|
body: "",
|
|
71
71
|
};
|
|
72
72
|
if (type === "JobPosting" && slug === "writer")
|
|
73
|
-
return { frontmatter: WRITER, body: "
|
|
73
|
+
return { frontmatter: WRITER, body: "# Writer\n\nThe writer role." };
|
|
74
74
|
return null;
|
|
75
75
|
});
|
|
76
76
|
const getMdxSlugs = jest.fn().mockResolvedValue(["writer"]);
|
|
@@ -117,7 +117,11 @@ describe("a writer posting renders through the careers set (#8849 AC-7)", () =>
|
|
|
117
117
|
getMdxSource,
|
|
118
118
|
}),
|
|
119
119
|
);
|
|
120
|
-
|
|
120
|
+
// #7495 — the posting's body owns its H1; MDXRemote is mocked, so the
|
|
121
|
+
// body's `# Writer` arrives as its source.
|
|
122
|
+
expect(screen.getByTestId("mdx-rendered").textContent).toBe(
|
|
123
|
+
"# Writer\n\nThe writer role.",
|
|
124
|
+
);
|
|
121
125
|
const cta = screen.getByTestId("job-apply-cta");
|
|
122
126
|
expect(cta.getAttribute("href")).toBe("/careers/writer/apply");
|
|
123
127
|
|
|
@@ -401,6 +401,128 @@ describe("a submission from the Free apply page lands as a job application lead
|
|
|
401
401
|
});
|
|
402
402
|
});
|
|
403
403
|
|
|
404
|
+
describe("the Free apply page runs the flow its posting names (#10054 D4, D6)", () => {
|
|
405
|
+
const NAMED = "writer-application";
|
|
406
|
+
|
|
407
|
+
/** The seed, renamed, with a portfolio field on step 1 (`first_name`, `last_name`, `email` kept). */
|
|
408
|
+
function writerConfig(): FlowConfig {
|
|
409
|
+
const config = JSON.parse(JSON.stringify(jobApplicationFlowConfig)) as FlowConfig;
|
|
410
|
+
config.slug = NAMED;
|
|
411
|
+
(config.steps[0] as FieldStep).fields.splice(3, 0, {
|
|
412
|
+
name: "portfolio_url",
|
|
413
|
+
inputType: "url",
|
|
414
|
+
required: false,
|
|
415
|
+
copy: { label: { en: "Portfolio URL" } },
|
|
416
|
+
});
|
|
417
|
+
return config;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function namePosting(applicationFlow?: string) {
|
|
421
|
+
mockPostingSource.mockImplementation(async (_type, slug) =>
|
|
422
|
+
slug === JOB
|
|
423
|
+
? {
|
|
424
|
+
...jobSource,
|
|
425
|
+
frontmatter: {
|
|
426
|
+
...jobSource.frontmatter,
|
|
427
|
+
...(applicationFlow !== undefined && { applicationFlow }),
|
|
428
|
+
},
|
|
429
|
+
}
|
|
430
|
+
: null,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** `flows.get` answers `named` for the posting's flow, the seed for job-application. */
|
|
435
|
+
function serve(named: () => Promise<unknown>) {
|
|
436
|
+
mockFlowsGet.mockImplementation(async (slug: string) =>
|
|
437
|
+
slug === NAMED
|
|
438
|
+
? named()
|
|
439
|
+
: { ok: true, status: 200, body: jobApplicationFlowConfig },
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function submit(): Promise<{ source: string; consent?: unknown; payload: Record<string, unknown> }> {
|
|
444
|
+
for (const [name, value] of Object.entries({
|
|
445
|
+
first_name: "Ada",
|
|
446
|
+
last_name: "Lovelace",
|
|
447
|
+
email: "ada@example.com",
|
|
448
|
+
})) {
|
|
449
|
+
fireEvent.change(screen.getByTestId(`field-${name}`), { target: { value } });
|
|
450
|
+
}
|
|
451
|
+
fireEvent.click(screen.getByTestId("contact-consent"));
|
|
452
|
+
await act(async () => {
|
|
453
|
+
fireEvent.submit(screen.getByTestId("lead-capture-form"));
|
|
454
|
+
});
|
|
455
|
+
await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(1));
|
|
456
|
+
return mockSubmit.mock.calls[0][0];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let warn: jest.SpyInstance;
|
|
460
|
+
beforeEach(() => {
|
|
461
|
+
warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
462
|
+
});
|
|
463
|
+
afterEach(() => warn.mockRestore());
|
|
464
|
+
|
|
465
|
+
it("AC-5 — renders the named flow's step 1 and records the posting and the flow", async () => {
|
|
466
|
+
namePosting(NAMED);
|
|
467
|
+
serve(async () => ({ ok: true, status: 200, body: writerConfig() }));
|
|
468
|
+
await renderApplyPage();
|
|
469
|
+
expect(fieldNames()).toContain("portfolio_url");
|
|
470
|
+
expect(mockFlowsGet).not.toHaveBeenCalledWith("job-application");
|
|
471
|
+
const body = await submit();
|
|
472
|
+
expect(body.source).toBe("flow:job-application");
|
|
473
|
+
expect(body.consent).toBe(true);
|
|
474
|
+
expect(body.payload["job"]).toBe(JOB);
|
|
475
|
+
expect(body.payload["applicationFlow"]).toBe(NAMED);
|
|
476
|
+
expect(warn).not.toHaveBeenCalled();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it.each([
|
|
480
|
+
["absent", async () => ({ ok: false, status: 404, body: null })],
|
|
481
|
+
[
|
|
482
|
+
"unmappable",
|
|
483
|
+
async () => {
|
|
484
|
+
const config = writerConfig();
|
|
485
|
+
const step = config.steps[0] as FieldStep;
|
|
486
|
+
step.fields = step.fields.filter((f) => f.name !== "last_name");
|
|
487
|
+
return { ok: true, status: 200, body: config };
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
])("AC-6 — an %s flow renders the Job application, posts no applicationFlow and warns once", async (reason, named) => {
|
|
491
|
+
namePosting(NAMED);
|
|
492
|
+
serve(named);
|
|
493
|
+
await renderApplyPage();
|
|
494
|
+
expect(fieldNames()).toEqual(["first_name", "last_name", "email", "phone"]);
|
|
495
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
496
|
+
const logged = JSON.stringify(warn.mock.calls[0]);
|
|
497
|
+
expect(logged).toContain(JOB);
|
|
498
|
+
expect(logged).toContain(NAMED);
|
|
499
|
+
expect(logged).toContain(reason);
|
|
500
|
+
const body = await submit();
|
|
501
|
+
expect(body.payload).not.toHaveProperty("applicationFlow");
|
|
502
|
+
expect(body.payload["job"]).toBe(JOB);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("AC-6 — a named flow whose read throws renders the Job application and warns nothing", async () => {
|
|
506
|
+
namePosting(NAMED);
|
|
507
|
+
serve(async () => {
|
|
508
|
+
throw new Error("down");
|
|
509
|
+
});
|
|
510
|
+
await renderApplyPage();
|
|
511
|
+
expect(fieldNames()).toEqual(["first_name", "last_name", "email", "phone"]);
|
|
512
|
+
expect(warn).not.toHaveBeenCalled();
|
|
513
|
+
const body = await submit();
|
|
514
|
+
expect(body.payload).not.toHaveProperty("applicationFlow");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("AC-6 — a posting naming no flow posts a payload with no applicationFlow key", async () => {
|
|
518
|
+
await renderApplyPage();
|
|
519
|
+
const body = await submit();
|
|
520
|
+
expect(body.payload).not.toHaveProperty("applicationFlow");
|
|
521
|
+
for (const call of mockFlowsGet.mock.calls) expect(call[0]).toBe("job-application");
|
|
522
|
+
expect(warn).not.toHaveBeenCalled();
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
404
526
|
describe("the /careers index links to each job and carries no form (#8746 D1, #8849 D10)", () => {
|
|
405
527
|
it("renders the list with no apply form and reads no magnet", async () => {
|
|
406
528
|
const element = await CareersPage({
|
|
@@ -158,7 +158,7 @@ describe("careers/[slug]/page — snapshot regression", () => {
|
|
|
158
158
|
// #8849 D3, D8 — the JobPosting's ApplyAction targets the apply page, and
|
|
159
159
|
// the page passes renderPage an Apply button inside <main>.
|
|
160
160
|
expect(container.innerHTML).toMatchInlineSnapshot(
|
|
161
|
-
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Senior Engineer","description":"Build the AILK platform.","mainEntity":{"@context":"https://schema.org","@type":"JobPosting","title":"Senior Engineer","description":"Build the AILK platform.","potentialAction":{"@type":"ApplyAction","target":"http://localhost:3000/en/careers/senior-engineer/apply"}}},{"@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":"Careers","item":"http://localhost:3000/en/careers"},{"@type":"ListItem","position":3,"name":"Senior Engineer","item":"http://localhost:3000/en/careers/senior-engineer"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><
|
|
161
|
+
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Senior Engineer","description":"Build the AILK platform.","mainEntity":{"@context":"https://schema.org","@type":"JobPosting","title":"Senior Engineer","description":"Build the AILK platform.","potentialAction":{"@type":"ApplyAction","target":"http://localhost:3000/en/careers/senior-engineer/apply"}}},{"@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":"Careers","item":"http://localhost:3000/en/careers"},{"@type":"ListItem","position":3,"name":"Senior Engineer","item":"http://localhost:3000/en/careers/senior-engineer"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle"><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 motion-safe:transition motion-safe:duration-(--button-hover-duration) 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) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) 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 motion-safe:transition motion-safe:duration-(--button-hover-duration) 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) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) 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">mdx-body-content</div></div></article></div></div></div><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 pb-section"><a class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) 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 bg-(--color-brand) text-(--color-fg-on-brand) not-disabled:hover:bg-(--button-hover-bg) not-disabled:hover:text-(--button-hover-fg) not-disabled:hover:outline not-disabled:hover:outline-1 not-disabled:hover:-outline-offset-1 not-disabled:hover:outline-(--button-hover-border) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:opacity-80 rounded-control px-control-x min-h-(--control-height-big)" href="/careers/senior-engineer/apply" data-testid="job-apply-cta">Apply for this role</a></div></main>"`,
|
|
162
162
|
);
|
|
163
163
|
});
|
|
164
164
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Case Study detail page. Reads the CaseStudy MDX for the given slug, renders via
|
|
2
|
-
//
|
|
2
|
+
// @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
|
|
3
3
|
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getMdxSource as loadMdxSource,
|
|
6
|
+
renderPage,
|
|
7
|
+
renderPageMetadata,
|
|
8
|
+
} from "@working-theory/page-renderer";
|
|
5
9
|
import type { Metadata } from "next";
|
|
6
10
|
import type { ReactElement } from "react";
|
|
7
11
|
|
|
@@ -22,11 +26,24 @@ export default async function Page({
|
|
|
22
26
|
getMdxSource,
|
|
23
27
|
}: Props): Promise<ReactElement> {
|
|
24
28
|
const { locale, slug } = await params;
|
|
29
|
+
// A page that composes `sections[]` renders them (JSON-LD is built from
|
|
30
|
+
// the same sections); a prose page renders its MDX body — the rule
|
|
31
|
+
// /vs/[slug] applies (#10122).
|
|
32
|
+
const source = await (getMdxSource ?? loadMdxSource)(
|
|
33
|
+
"CaseStudy",
|
|
34
|
+
slug,
|
|
35
|
+
locale,
|
|
36
|
+
);
|
|
37
|
+
const composed =
|
|
38
|
+
source !== null &&
|
|
39
|
+
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
40
|
+
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
|
|
41
|
+
0;
|
|
25
42
|
return renderPage({
|
|
26
43
|
type: "CaseStudy",
|
|
27
44
|
slug,
|
|
28
45
|
locale,
|
|
29
|
-
bodyMode: "mdx-body",
|
|
46
|
+
...(composed ? {} : { bodyMode: "mdx-body" as const }),
|
|
30
47
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
31
48
|
});
|
|
32
49
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub child cards (#10121) for /case-studies. The shared cases live in
|
|
3
|
+
* `lib/__tests__/hub-children.fixtures.ts`; this file sits beside the route so
|
|
4
|
+
* a scaffold without /case-studies drops it with the route (#10157).
|
|
5
|
+
*/
|
|
6
|
+
const mockRenderPage = jest.fn(async (..._args: unknown[]) => "<rendered/>");
|
|
7
|
+
|
|
8
|
+
jest.mock("@working-theory/page-renderer", () => ({
|
|
9
|
+
__esModule: true,
|
|
10
|
+
renderPage: (...args: unknown[]) => mockRenderPage(...args),
|
|
11
|
+
renderPageMetadata: jest.fn(),
|
|
12
|
+
// Never reached — every call in this suite passes its loaders via DI.
|
|
13
|
+
getMdxSlugs: jest.fn(),
|
|
14
|
+
getMdxSource: jest.fn(),
|
|
15
|
+
getPageSections: jest.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
jest.mock("@working-theory/ui/sections", () => ({
|
|
19
|
+
__esModule: true,
|
|
20
|
+
SectionRenderer: () => null,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import CaseStudiesPage from "../page";
|
|
24
|
+
import {
|
|
25
|
+
describeHub,
|
|
26
|
+
type HubPage,
|
|
27
|
+
renderInput,
|
|
28
|
+
titlesOf,
|
|
29
|
+
} from "../../../../lib/__tests__/hub-children.fixtures";
|
|
30
|
+
|
|
31
|
+
describeHub(
|
|
32
|
+
[
|
|
33
|
+
"case-studies",
|
|
34
|
+
CaseStudiesPage as HubPage,
|
|
35
|
+
"CaseStudiesList",
|
|
36
|
+
"CaseStudy",
|
|
37
|
+
"mdx-body",
|
|
38
|
+
],
|
|
39
|
+
mockRenderPage,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
describe("prose hub linking a child in its body", () => {
|
|
43
|
+
it("gives that child no second card", async () => {
|
|
44
|
+
const l = {
|
|
45
|
+
getMdxSource: jest.fn(async (type: string, slug: string) =>
|
|
46
|
+
type === "CaseStudiesList"
|
|
47
|
+
? {
|
|
48
|
+
frontmatter: { type, slug, title: "Hub", description: "d" },
|
|
49
|
+
body: "Read [the Acme story](/case-studies/acme).",
|
|
50
|
+
}
|
|
51
|
+
: { frontmatter: { type, slug, title: slug }, body: "" },
|
|
52
|
+
),
|
|
53
|
+
getPageSections: jest.fn(async () => []),
|
|
54
|
+
getMdxSlugs: jest.fn(async () => ["acme", "beta"]),
|
|
55
|
+
};
|
|
56
|
+
const input = await renderInput(
|
|
57
|
+
mockRenderPage,
|
|
58
|
+
CaseStudiesPage as HubPage,
|
|
59
|
+
l,
|
|
60
|
+
);
|
|
61
|
+
const sections = await input.getPageSections({});
|
|
62
|
+
expect(titlesOf(sections[0])).toEqual(["beta"]);
|
|
63
|
+
expect(input.trailing?.props.sections).toEqual(sections);
|
|
64
|
+
});
|
|
65
|
+
});
|