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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* /[locale]/flows/[slug]/results/[payload] — the cold-load results route
|
|
4
|
+
* (#4770).
|
|
5
|
+
*
|
|
6
|
+
* Server-fetches the flow config exactly as `flows/[slug]/page.tsx` does
|
|
7
|
+
* (`flows.get(slug)`, always dynamically rendered — the config is DB-backed
|
|
8
|
+
* live data).
|
|
9
|
+
* Decodes the versioned, answers-only permalink (`decodePermalink`,
|
|
10
|
+
* `@working-theory/validation`) and `redirect()`s to `/[locale]/flows/[slug]`
|
|
11
|
+
* — step 1, never an error page — on ANY of: an invalid payload (FR6), a
|
|
12
|
+
* stale-version payload (FR6), a payload whose own `flowSlug` does not match
|
|
13
|
+
* this route's `[slug]` (D2/FR6), or a served flow that is not a
|
|
14
|
+
* recommending register (D8 — `mode !== "recommending" || !offers`, checked
|
|
15
|
+
* against the field `recommend()` is about to be handed rather than
|
|
16
|
+
* inferring the compiler's five-keys-travel-together guarantee).
|
|
17
|
+
*
|
|
18
|
+
* On a valid payload against a recommending flow, renders `ResultsClient`,
|
|
19
|
+
* which runs the already-shipped pure `recommend()` in the browser and
|
|
20
|
+
* renders the returned fits through the existing `RecommendationCard`
|
|
21
|
+
* (D10 — a minimal render; Epic #4759 recomposes it against the LOCKED
|
|
22
|
+
* DESIGN.md).
|
|
23
|
+
*
|
|
24
|
+
* `?checkout=success|cancelled` (#4842 FR7/D6) is read only after every
|
|
25
|
+
* guard above and rendered by `ResultsClient` as a one-line status over the
|
|
26
|
+
* identical recommendation.
|
|
27
|
+
*
|
|
28
|
+
* #9071 D3 — the recommender's landing. A flow with an offer carrying a
|
|
29
|
+
* string `lookupKey` (an Offerings recommender) never renders here: after
|
|
30
|
+
* the recommending guard the page redirects to `/<locale>/pricing?plan=<plan>`
|
|
31
|
+
* when the first fit's lookup key is in the current shown set — read from
|
|
32
|
+
* `offerings.shown()`, the endpoint `/pricing` renders from, through this
|
|
33
|
+
* page's own read shared per instance for 15 seconds — and to `/<locale>/pricing` with no badge otherwise (no fit, a
|
|
34
|
+
* key no longer shown, any failed read). The URL is built only from an
|
|
35
|
+
* allowlisted locale and a `parseLookupKey` plan. A flow with no such offer,
|
|
36
|
+
* or whose first fit carries none, renders as before with no read.
|
|
37
|
+
*/
|
|
38
|
+
import { flows, offerings } from "@working-theory/api-client";
|
|
39
|
+
import {
|
|
40
|
+
decodePermalink,
|
|
41
|
+
type FlowConfig,
|
|
42
|
+
parseLookupKey,
|
|
43
|
+
recommend,
|
|
44
|
+
resolveLocalizedString,
|
|
45
|
+
shownOfferingsAnswerSchema,
|
|
46
|
+
type WaitlistAnswers,
|
|
47
|
+
type WorkspaceShownSetWire,
|
|
48
|
+
} from "@working-theory/validation";
|
|
49
|
+
import type { Metadata } from "next";
|
|
50
|
+
import { redirect } from "next/navigation";
|
|
51
|
+
import type { ReactElement } from "react";
|
|
52
|
+
|
|
53
|
+
import { browserApiOrigin } from "../../../../../../lib/api-origin.generated";
|
|
54
|
+
|
|
55
|
+
import type { ResultsReturnState } from "./ResultsClient";
|
|
56
|
+
import { ResultsClient } from "./ResultsClient";
|
|
57
|
+
|
|
58
|
+
export const dynamic = "force-dynamic";
|
|
59
|
+
|
|
60
|
+
interface Props {
|
|
61
|
+
params: Promise<{ locale: string; slug: string; payload: string }>;
|
|
62
|
+
/** FR7 (#4842) — `?checkout=success|cancelled` after Stripe's hosted page. */
|
|
63
|
+
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* D6 (#4842) — the return micro-state, validated against the two literals;
|
|
68
|
+
* anything else (absent, an array, an unknown value) renders no status line.
|
|
69
|
+
*/
|
|
70
|
+
function resolveReturnState(
|
|
71
|
+
value: string | string[] | undefined,
|
|
72
|
+
): ResultsReturnState | undefined {
|
|
73
|
+
return value === "success" || value === "cancelled" ? value : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Mirrors `ResultsClient.tsx` — a path is built only from an enabled locale.
|
|
77
|
+
const DEFAULT_LOCALE = "en";
|
|
78
|
+
const ENABLED_LOCALES = (
|
|
79
|
+
process.env.NEXT_PUBLIC_ENABLED_LOCALES ?? DEFAULT_LOCALE
|
|
80
|
+
).split(",");
|
|
81
|
+
|
|
82
|
+
/** #9071 D3a — `/pricing`'s and the buy route's window. */
|
|
83
|
+
const SHOWN_READ_TTL_MS = 15_000;
|
|
84
|
+
/** #9071 NFR-4 — no landing read holds a visitor longer than this. */
|
|
85
|
+
const SHOWN_READ_TIMEOUT_MS = 5_000;
|
|
86
|
+
|
|
87
|
+
let shownRead:
|
|
88
|
+
| { at: number; read: Promise<WorkspaceShownSetWire | null> }
|
|
89
|
+
| undefined;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The three names `getApiBase()` reads, the browser-facing one through the
|
|
93
|
+
* topology seam (#7874 D12), which a spoke renders with no env read (#9480):
|
|
94
|
+
* the browser-facing name set on a spoke at runtime counts as no origin.
|
|
95
|
+
*/
|
|
96
|
+
function apiOriginConfigured(): boolean {
|
|
97
|
+
return Boolean(
|
|
98
|
+
process.env.AILK_API_URL ||
|
|
99
|
+
browserApiOrigin() ||
|
|
100
|
+
process.env.NEXT_PUBLIC_API_URL,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* #9071 D3a — the current shown set, or `null` for "nothing shown": no API
|
|
106
|
+
* origin in production (no call), `unconnected`, `empty`, an expired set, a
|
|
107
|
+
* non-2xx, a body the schema refuses, a throw, or no answer within 5 s.
|
|
108
|
+
* Concurrent and failed reads are shared like a good one.
|
|
109
|
+
*/
|
|
110
|
+
function readCurrentShownSet(): Promise<WorkspaceShownSetWire | null> {
|
|
111
|
+
if (process.env.NODE_ENV === "production" && !apiOriginConfigured()) {
|
|
112
|
+
return Promise.resolve(null);
|
|
113
|
+
}
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (shownRead && now - shownRead.at < SHOWN_READ_TTL_MS) return shownRead.read;
|
|
116
|
+
const read = (async (): Promise<WorkspaceShownSetWire | null> => {
|
|
117
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
118
|
+
try {
|
|
119
|
+
const timeout = new Promise<null>((resolve) => {
|
|
120
|
+
timer = setTimeout(() => resolve(null), SHOWN_READ_TIMEOUT_MS);
|
|
121
|
+
});
|
|
122
|
+
const res = await Promise.race([offerings.shown(), timeout]);
|
|
123
|
+
if (res === null || !res.ok) return null;
|
|
124
|
+
const parsed = shownOfferingsAnswerSchema.safeParse(res.body);
|
|
125
|
+
if (!parsed.success || parsed.data.status !== "shown") return null;
|
|
126
|
+
return parsed.data.shownSet.state === "current" ? parsed.data.shownSet : null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
}
|
|
132
|
+
})();
|
|
133
|
+
shownRead = { at: now, read };
|
|
134
|
+
return read;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isOfferingsOffer(offer: unknown): boolean {
|
|
138
|
+
return (
|
|
139
|
+
typeof offer === "object" &&
|
|
140
|
+
offer !== null &&
|
|
141
|
+
typeof (offer as { lookupKey?: unknown }).lookupKey === "string"
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* #9071 D3 — where a completed Offerings recommender lands, or `null` to
|
|
147
|
+
* render the results page as before. The served config is not re-parsed
|
|
148
|
+
* here, so every read of it is shape-checked and any throw is `null`.
|
|
149
|
+
*/
|
|
150
|
+
async function recommenderLanding(
|
|
151
|
+
config: FlowConfig,
|
|
152
|
+
answers: WaitlistAnswers,
|
|
153
|
+
locale: string,
|
|
154
|
+
): Promise<string | null> {
|
|
155
|
+
try {
|
|
156
|
+
const offers: unknown = config.offers;
|
|
157
|
+
if (!Array.isArray(offers) || !offers.some(isOfferingsOffer)) return null;
|
|
158
|
+
|
|
159
|
+
let fitKey: string | undefined;
|
|
160
|
+
try {
|
|
161
|
+
fitKey = recommend(
|
|
162
|
+
{
|
|
163
|
+
key: config.experiment?.key ?? config.slug,
|
|
164
|
+
offers: config.offers,
|
|
165
|
+
pairings: config.pairings,
|
|
166
|
+
rateTable: config.rateTable,
|
|
167
|
+
scoring: config.scoring,
|
|
168
|
+
},
|
|
169
|
+
answers,
|
|
170
|
+
).fits[0]?.offerKey;
|
|
171
|
+
} catch {
|
|
172
|
+
fitKey = undefined;
|
|
173
|
+
}
|
|
174
|
+
const fit: unknown =
|
|
175
|
+
fitKey === undefined
|
|
176
|
+
? undefined
|
|
177
|
+
: offers.find(
|
|
178
|
+
(o: unknown) =>
|
|
179
|
+
typeof o === "object" && o !== null && (o as { key?: unknown }).key === fitKey,
|
|
180
|
+
);
|
|
181
|
+
if (fit !== undefined && !isOfferingsOffer(fit)) return null;
|
|
182
|
+
|
|
183
|
+
const safeLocale = ENABLED_LOCALES.includes(locale) ? locale : DEFAULT_LOCALE;
|
|
184
|
+
const base = `/${safeLocale}/pricing`;
|
|
185
|
+
if (fit === undefined) return base;
|
|
186
|
+
const lookupKey = (fit as { lookupKey: string }).lookupKey;
|
|
187
|
+
const parsed = parseLookupKey(lookupKey);
|
|
188
|
+
if (parsed === null) return base;
|
|
189
|
+
|
|
190
|
+
const shown = await readCurrentShownSet();
|
|
191
|
+
const isShown =
|
|
192
|
+
shown?.offerings.some((o) => o.prices.some((p) => p.lookupKey === lookupKey)) ??
|
|
193
|
+
false;
|
|
194
|
+
return isShown ? `${base}?plan=${parsed.plan}` : base;
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
201
|
+
const { locale, slug, payload } = await params;
|
|
202
|
+
|
|
203
|
+
const decoded = decodePermalink(payload);
|
|
204
|
+
if (!decoded.ok || decoded.value.flowSlug !== slug) return {};
|
|
205
|
+
|
|
206
|
+
const res = await flows.get(slug);
|
|
207
|
+
if (!res.ok) return {};
|
|
208
|
+
|
|
209
|
+
const { recommendation } = res.body;
|
|
210
|
+
if (!recommendation) return {};
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
title: resolveLocalizedString(
|
|
214
|
+
recommendation.copy.title,
|
|
215
|
+
locale,
|
|
216
|
+
res.body.defaultLocale,
|
|
217
|
+
),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export default async function Page({
|
|
222
|
+
params,
|
|
223
|
+
searchParams,
|
|
224
|
+
}: Props): Promise<ReactElement> {
|
|
225
|
+
const { locale, slug, payload } = await params;
|
|
226
|
+
|
|
227
|
+
const decoded = decodePermalink(payload);
|
|
228
|
+
if (!decoded.ok) {
|
|
229
|
+
redirect(`/${locale}/flows/${slug}`);
|
|
230
|
+
}
|
|
231
|
+
if (decoded.value.flowSlug !== slug) {
|
|
232
|
+
redirect(`/${locale}/flows/${slug}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const res = await flows.get(slug);
|
|
236
|
+
if (!res.ok) {
|
|
237
|
+
redirect(`/${locale}/flows/${slug}`);
|
|
238
|
+
}
|
|
239
|
+
if (res.body.mode !== "recommending" || res.body.offers === undefined) {
|
|
240
|
+
redirect(`/${locale}/flows/${slug}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const landing = await recommenderLanding(
|
|
244
|
+
res.body,
|
|
245
|
+
decoded.value.answers,
|
|
246
|
+
locale,
|
|
247
|
+
);
|
|
248
|
+
if (landing !== null) redirect(landing);
|
|
249
|
+
|
|
250
|
+
// FR7 — read AFTER every redirect guard above, so a return param never
|
|
251
|
+
// changes which guard fires or where it sends the visitor.
|
|
252
|
+
const returnState = resolveReturnState((await searchParams)?.checkout);
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<ResultsClient
|
|
256
|
+
config={res.body}
|
|
257
|
+
locale={locale}
|
|
258
|
+
answers={decoded.value.answers}
|
|
259
|
+
slug={slug}
|
|
260
|
+
payload={payload}
|
|
261
|
+
{...(returnState !== undefined ? { returnState } : {})}
|
|
262
|
+
/>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
"use server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* save.actions.ts — "Email me this plan" (#4842 D7): validates with
|
|
6
|
+
* `flowSaveInputSchema`, calls `flows.save`, returns the lead id. A sibling of
|
|
7
|
+
* `flow.actions.ts` (ADR 0013 `*.actions.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Split out of `checkout.actions.ts` by #6786: saving a recommendation is the
|
|
10
|
+
* results page's own job, so it ships with the page (the `flows` surface,
|
|
11
|
+
* signal-collectors), while `startCheckoutAction` stays commerce's. The shared
|
|
12
|
+
* validation, IP-forwarding and status → kind helpers are `email-moment.ts`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { flows } from "@working-theory/api-client";
|
|
16
|
+
import type { FlowSaveInput } from "@working-theory/validation";
|
|
17
|
+
import {
|
|
18
|
+
flowSaveInputSchema,
|
|
19
|
+
flowSlugSchema,
|
|
20
|
+
} from "@working-theory/validation";
|
|
21
|
+
|
|
22
|
+
import { runBotCheck } from "../../../../lib/bot-check";
|
|
23
|
+
|
|
24
|
+
import type { EmailMomentFailure } from "./email-moment";
|
|
25
|
+
import {
|
|
26
|
+
emailMomentFields,
|
|
27
|
+
errorName,
|
|
28
|
+
failureFor,
|
|
29
|
+
resolveVisitorIpInit,
|
|
30
|
+
} from "./email-moment";
|
|
31
|
+
|
|
32
|
+
export type SaveActionResult =
|
|
33
|
+
{ ok: true; leadId: string } | EmailMomentFailure;
|
|
34
|
+
|
|
35
|
+
export async function saveRecommendationAction(
|
|
36
|
+
slug: string,
|
|
37
|
+
body: FlowSaveInput,
|
|
38
|
+
): Promise<SaveActionResult> {
|
|
39
|
+
const parsedSlug = flowSlugSchema.safeParse(slug);
|
|
40
|
+
if (!parsedSlug.success) return { ok: false, kind: "invalid_input" };
|
|
41
|
+
|
|
42
|
+
const parsed = flowSaveInputSchema.safeParse(body);
|
|
43
|
+
if (!parsed.success) return { ok: false, kind: "invalid_input" };
|
|
44
|
+
|
|
45
|
+
const payload = emailMomentFields(parsed.data);
|
|
46
|
+
|
|
47
|
+
// #9255 D4 — the spoke edge's bot check, after the input checks and before
|
|
48
|
+
// the write.
|
|
49
|
+
const botCheck = await runBotCheck({
|
|
50
|
+
source: "bot-check",
|
|
51
|
+
target: "saveRecommendationAction",
|
|
52
|
+
scope: "write",
|
|
53
|
+
});
|
|
54
|
+
if (botCheck !== "pass") return { ok: false, kind: "unavailable" };
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const visitorIpInit = await resolveVisitorIpInit();
|
|
58
|
+
const res = visitorIpInit
|
|
59
|
+
? await flows.save(parsedSlug.data, payload, visitorIpInit)
|
|
60
|
+
: await flows.save(parsedSlug.data, payload);
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const failure = failureFor(res);
|
|
63
|
+
console.error(
|
|
64
|
+
`[saveRecommendationAction] slug=${parsedSlug.data} kind=${failure.kind} status=${res.status}`,
|
|
65
|
+
);
|
|
66
|
+
return failure;
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, leadId: res.body.leadId };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error(
|
|
71
|
+
`[saveRecommendationAction] slug=${parsedSlug.data} kind=network errName=${errorName(err)}`,
|
|
72
|
+
);
|
|
73
|
+
return { ok: false, kind: "network" };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub child cards (#10121) for /for. The shared cases live in
|
|
3
|
+
* `lib/__tests__/hub-children.fixtures.ts`; this file sits beside the route so
|
|
4
|
+
* a scaffold without /for 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 ForPage from "../page";
|
|
24
|
+
import {
|
|
25
|
+
describeHub,
|
|
26
|
+
type HubPage,
|
|
27
|
+
} from "../../../../lib/__tests__/hub-children.fixtures";
|
|
28
|
+
|
|
29
|
+
describeHub(
|
|
30
|
+
["for", ForPage as HubPage, "UseCasesList", "UseCase", "composed"],
|
|
31
|
+
mockRenderPage,
|
|
32
|
+
);
|
|
@@ -54,6 +54,8 @@ describe("ForPage — snapshot regression", () => {
|
|
|
54
54
|
it("empty state renders deterministic HTML", async () => {
|
|
55
55
|
const element = await ForPage({
|
|
56
56
|
params: Promise.resolve({ locale: "en" }),
|
|
57
|
+
// No child pages: this snapshot pins the hub's own render (#10121).
|
|
58
|
+
getMdxSlugs: async () => [],
|
|
57
59
|
getMdxSource: getMdxSourceEmpty,
|
|
58
60
|
});
|
|
59
61
|
const { container } = render(element);
|
|
@@ -76,11 +78,13 @@ describe("ForPage — snapshot regression", () => {
|
|
|
76
78
|
|
|
77
79
|
const element = await ForPage({
|
|
78
80
|
params: Promise.resolve({ locale: "en" }),
|
|
81
|
+
// No child pages: this snapshot pins the hub's own render (#10121).
|
|
82
|
+
getMdxSlugs: async () => [],
|
|
79
83
|
getMdxSource: getMdxSourcePopulated,
|
|
80
84
|
});
|
|
81
85
|
const { container } = render(element);
|
|
82
86
|
expect(container.innerHTML).toMatchInlineSnapshot(
|
|
83
|
-
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Use Cases","description":"How teams use our platform.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Article"}]}}</script><main id="main"><div><
|
|
87
|
+
`"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Use Cases","description":"How teams use our platform.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Article"}]}}</script><main id="main"><div><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 data-testid="mdx-remote" data-source=""></div></article></div></div></div></main>"`,
|
|
84
88
|
);
|
|
85
89
|
});
|
|
86
90
|
});
|
|
@@ -1,53 +1,51 @@
|
|
|
1
1
|
// Customers (/for) hub page (#6878 D2 — renamed from /use-cases). Reads
|
|
2
2
|
// UseCasesList MDX via the content adapter and emits a JSON-LD CollectionPage
|
|
3
|
-
// via the dispatcher.
|
|
3
|
+
// via the dispatcher. Sections when the frontmatter carries `sections[]` (the
|
|
4
|
+
// Free template's hub, operator ruling 2026-09-23), the MDX body otherwise.
|
|
5
|
+
// It lists every UseCase page through one generated card section (#10121,
|
|
6
|
+
// apps/web/lib/hub-children.tsx — the /alternatives and /templates shape).
|
|
4
7
|
import type {
|
|
8
|
+
getMdxSlugs as defaultGetMdxSlugs,
|
|
5
9
|
getMdxSource as defaultGetMdxSource,
|
|
10
|
+
getPageSections as defaultGetPageSections,
|
|
6
11
|
} from "@working-theory/page-renderer";
|
|
7
|
-
import {
|
|
8
|
-
getMdxSource as loadMdxSource,
|
|
9
|
-
renderPage,
|
|
10
|
-
renderPageMetadata,
|
|
11
|
-
} from "@working-theory/page-renderer";
|
|
12
|
+
import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
|
|
12
13
|
import type { Metadata } from "next";
|
|
13
14
|
import type { ReactElement } from "react";
|
|
14
15
|
|
|
16
|
+
import { type HubConfig, hubPageInput } from "../../../lib/hub-children";
|
|
17
|
+
|
|
15
18
|
interface Props {
|
|
16
19
|
params: Promise<{ locale: string }>;
|
|
17
20
|
getMdxSource?: typeof defaultGetMdxSource;
|
|
21
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
22
|
+
getMdxSlugs?: typeof defaultGetMdxSlugs;
|
|
18
23
|
}
|
|
19
24
|
|
|
25
|
+
const HUB: HubConfig = {
|
|
26
|
+
listType: "UseCasesList",
|
|
27
|
+
slug: "for",
|
|
28
|
+
childType: "UseCase",
|
|
29
|
+
linkLabel: "Read the use case",
|
|
30
|
+
sectionId: "use-case-cards",
|
|
31
|
+
body: "composed",
|
|
32
|
+
};
|
|
33
|
+
|
|
20
34
|
export async function generateMetadata({
|
|
21
35
|
params,
|
|
22
36
|
}: Pick<Props, "params">): Promise<Metadata> {
|
|
23
37
|
const { locale } = await params;
|
|
24
|
-
return renderPageMetadata({
|
|
25
|
-
type: "UseCasesList",
|
|
26
|
-
slug: "for",
|
|
27
|
-
locale,
|
|
28
|
-
});
|
|
38
|
+
return renderPageMetadata({ type: "UseCasesList", slug: "for", locale });
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
export default async function Page({
|
|
32
42
|
params,
|
|
33
|
-
|
|
43
|
+
...loaders
|
|
34
44
|
}: Props): Promise<ReactElement> {
|
|
35
45
|
const { locale } = await params;
|
|
36
|
-
// A UseCasesList page that composes `sections[]` (the Free template's
|
|
37
|
-
// /for hub, operator ruling 2026-09-23) renders them; a prose-only page
|
|
38
|
-
// keeps the MDX-body render. The CollectionPage JSON-LD is built from the
|
|
39
|
-
// sections either way (buildUseCasesList).
|
|
40
|
-
const source = await (getMdxSource ?? loadMdxSource)("UseCasesList", "for", locale);
|
|
41
|
-
const composed =
|
|
42
|
-
source !== null &&
|
|
43
|
-
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
44
|
-
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) > 0;
|
|
45
46
|
return renderPage({
|
|
46
47
|
type: "UseCasesList",
|
|
47
48
|
slug: "for",
|
|
48
|
-
locale,
|
|
49
|
-
translationsNamespace: "for",
|
|
50
|
-
...(composed ? {} : { bodyMode: "mdx-body" as const }),
|
|
51
|
-
...(getMdxSource !== undefined && { getMdxSource }),
|
|
49
|
+
...(await hubPageInput(HUB, locale, loaders)),
|
|
52
50
|
});
|
|
53
51
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// HowTo detail page. Reads the HowTo MDX for the given slug, renders via
|
|
2
2
|
// @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs trigger 404.
|
|
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,12 +26,25 @@ 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)("HowTo", slug, locale);
|
|
33
|
+
const composed =
|
|
34
|
+
source !== null &&
|
|
35
|
+
Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
|
|
36
|
+
((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) >
|
|
37
|
+
0;
|
|
25
38
|
return renderPage({
|
|
26
39
|
type: "HowTo",
|
|
27
40
|
slug,
|
|
28
41
|
locale,
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
...(composed
|
|
43
|
+
? {}
|
|
44
|
+
: {
|
|
45
|
+
bodyMode: "mdx-body" as const,
|
|
46
|
+
populatedDescriptionTestId: "how-to-description",
|
|
47
|
+
}),
|
|
31
48
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
32
49
|
});
|
|
33
50
|
}
|
|
@@ -58,7 +58,7 @@ describe("how-to/[slug] — snapshot regression", () => {
|
|
|
58
58
|
});
|
|
59
59
|
const { container } = render(element);
|
|
60
60
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
61
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel.","mainEntity":{"@context":"https://schema.org","@type":"HowTo","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel."}},{"@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":"How-To Guides","item":"http://localhost:3000/en/how-to"},{"@type":"ListItem","position":3,"name":"Deploy AILK to Vercel","item":"http://localhost:3000/en/how-to/deploy-ailk-vercel"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><
|
|
61
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel.","mainEntity":{"@context":"https://schema.org","@type":"HowTo","name":"Deploy AILK to Vercel","description":"Step-by-step guide to deploying AI Launch Kit on Vercel."}},{"@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":"How-To Guides","item":"http://localhost:3000/en/how-to"},{"@type":"ListItem","position":3,"name":"Deploy AILK to Vercel","item":"http://localhost:3000/en/how-to/deploy-ailk-vercel"}]}]}</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">## Steps
|
|
62
62
|
|
|
63
63
|
Body content.</div></div></article></div></div></div></main>"
|
|
64
64
|
`);
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Integration detail page. Reads the Integration MDX for the given slug, renders
|
|
2
|
-
// via
|
|
2
|
+
// via @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,12 +26,29 @@ 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
|
+
"Integration",
|
|
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: "Integration",
|
|
27
44
|
slug,
|
|
28
45
|
locale,
|
|
29
|
-
|
|
30
|
-
|
|
46
|
+
...(composed
|
|
47
|
+
? {}
|
|
48
|
+
: {
|
|
49
|
+
bodyMode: "mdx-body" as const,
|
|
50
|
+
populatedDescriptionTestId: "integration-description",
|
|
51
|
+
}),
|
|
31
52
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
32
53
|
});
|
|
33
54
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub child cards (#10121) for /integrations. The shared cases live in
|
|
3
|
+
* `lib/__tests__/hub-children.fixtures.ts`; this file sits beside the route so
|
|
4
|
+
* a scaffold without /integrations 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 IntegrationsPage from "../page";
|
|
24
|
+
import {
|
|
25
|
+
describeHub,
|
|
26
|
+
type HubPage,
|
|
27
|
+
} from "../../../../lib/__tests__/hub-children.fixtures";
|
|
28
|
+
|
|
29
|
+
describeHub(
|
|
30
|
+
[
|
|
31
|
+
"integrations",
|
|
32
|
+
IntegrationsPage as HubPage,
|
|
33
|
+
"IntegrationsList",
|
|
34
|
+
"Integration",
|
|
35
|
+
"mdx-body",
|
|
36
|
+
],
|
|
37
|
+
mockRenderPage,
|
|
38
|
+
);
|