create-ailk 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -9
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +25 -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 +112 -11
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +90 -6
- 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 +12 -2
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +16 -5
- package/templates/apps/api/CLAUDE.md +32 -11
- 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/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +27 -0
- package/templates/apps/web/CLAUDE.md +56 -8
- 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]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -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 +161 -128
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
- 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/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- 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 +44 -4
- 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/sign-up-agreements.ts +17 -6
- 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 +144 -3
- package/templates/apps/web/middleware.ts +179 -12
- package/templates/apps/web/next.config.mjs +220 -3
- package/templates/apps/web/package.json +3 -2
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/versions.json +48 -48
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* calls per 10 minutes per visitor address, before the write. From a server
|
|
17
17
|
* action the hub sees this site's egress address, so one visitor could
|
|
18
18
|
* otherwise drain the hub's per-IP budget for every visitor of the site. With
|
|
19
|
-
* no readable address the
|
|
20
|
-
* own per-IP limit on the
|
|
19
|
+
* no readable address the post is counted in one shared bucket at ten times
|
|
20
|
+
* the limit, never skipped (#9967). The hub keeps its own per-IP limit on the
|
|
21
|
+
* route.
|
|
21
22
|
*
|
|
22
23
|
* The write (#8747): every lead goes through `writeLead`, the site's lead
|
|
23
24
|
* writer (`src/lib/lead-write.ts`), which marks it `payload.site =
|
|
@@ -68,6 +69,11 @@
|
|
|
68
69
|
* requires of a contact lead and records on it); an API `400
|
|
69
70
|
* consent_required` is shown as the same field error.
|
|
70
71
|
*
|
|
72
|
+
* The bot check (#9255 D4): after the honeypot, consent, schema and
|
|
73
|
+
* per-visitor refusals and immediately before the write, `runBotCheck`
|
|
74
|
+
* (lib/bot-check.ts) asks Vercel BotID for a verdict. A bot gets the generic
|
|
75
|
+
* error and a check that cannot decide `unavailable`; neither is sent.
|
|
76
|
+
*
|
|
71
77
|
* Visitor forwarding (#7394): on a spoke whose API is a hub, the call
|
|
72
78
|
* carries the visitor's IP and the spoke's `AILK_VISITOR_FORWARD_KEY`
|
|
73
79
|
* (server-only, never `NEXT_PUBLIC_`) via `visitorForwardHeaders`, so the
|
|
@@ -86,35 +92,43 @@
|
|
|
86
92
|
* be read — answers exactly `{ kind: "success" }`: a written lead is never
|
|
87
93
|
* reported as a failure.
|
|
88
94
|
*
|
|
89
|
-
* Continue to checkout (#8757 D11): `{ completion: "checkout",
|
|
95
|
+
* Continue to checkout (#8757 D11, #9171): `{ completion: "checkout",
|
|
90
96
|
* checkoutOffering }` — the magnet sends the submitter on to the offering's
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* `
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
97
|
+
* checkout — answers `{ kind: "success", redirect:
|
|
98
|
+
* "/api/offerings/checkout/<lookupKey>" }`, the Offerings buy route every
|
|
99
|
+
* `/pricing` card links; the legacy `/api/checkout/<offering>` is retired
|
|
100
|
+
* (#9142). The form hard-navigates there (`LeadCaptureForm`'s `redirect`),
|
|
101
|
+
* because the route starts a session only for a top-level navigation and a
|
|
102
|
+
* Server Action `redirect()` is fetched as RSC, which it sends to `/pricing`.
|
|
103
|
+
* The stored value is a `{plan}`, so the key is the one that plan's
|
|
104
|
+
* `/pricing` card links first: the first of `<plan>_monthly`,
|
|
105
|
+
* `<plan>_yearly` and `<plan>_once` the shown set shows, read through
|
|
106
|
+
* `offerings.shown()` within 5 seconds and shared per instance for 15, as
|
|
107
|
+
* the buy route reads it. It runs only after the write resolved `ok`. The
|
|
108
|
+
* value is re-checked with `flowCheckoutOfferingSchema` first; one that
|
|
109
|
+
* fails, a plan the shown set does not show, or a read that fails or times
|
|
110
|
+
* out answers `{ kind: "success" }`, as `lead` does.
|
|
111
|
+
* A failed write never answers the buy route.
|
|
99
112
|
*/
|
|
100
113
|
|
|
101
|
-
import { visitorForwardHeaders } from "@working-theory/api-client";
|
|
114
|
+
import { offerings, visitorForwardHeaders } from "@working-theory/api-client";
|
|
102
115
|
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
103
116
|
import {
|
|
104
117
|
CONTACT_LEAD_SOURCES,
|
|
105
118
|
flowCheckoutOfferingSchema,
|
|
106
119
|
leadSubmitNextSchema,
|
|
120
|
+
LOOKUP_KEY_PERIODS,
|
|
107
121
|
} from "@working-theory/validation";
|
|
108
122
|
import { cookies, headers } from "next/headers";
|
|
109
|
-
import { redirect } from "next/navigation";
|
|
110
123
|
import { getLocale, getTranslations } from "next-intl/server";
|
|
111
124
|
import { z } from "zod";
|
|
112
125
|
|
|
113
126
|
import { accountInviteHref } from "../../../lib/account-invite";
|
|
127
|
+
import { runBotCheck } from "../../../lib/bot-check";
|
|
114
128
|
import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
|
|
115
|
-
import {
|
|
129
|
+
import { isShownLookupKey, offeringsCheckoutHref } from "../../../lib/offerings-pricing";
|
|
116
130
|
import { writeLead } from "../../../src/lib/lead-write";
|
|
117
|
-
import {
|
|
131
|
+
import { takeVisitorLimit } from "../../../src/lib/rate-limit";
|
|
118
132
|
|
|
119
133
|
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
120
134
|
const RATE_LIMITED = "Too many messages from here. Please try again in a few minutes.";
|
|
@@ -122,6 +136,8 @@ const RATE_LIMITED = "Too many messages from here. Please try again in a few min
|
|
|
122
136
|
/** The per-visitor budget (#8747): the consumer sites' own values. */
|
|
123
137
|
const RATE_LIMIT_MAX_CALLS = 30;
|
|
124
138
|
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
|
139
|
+
/** Outside a request scope there is no address: the shared bucket. */
|
|
140
|
+
const NO_HEADERS = { get: () => null };
|
|
125
141
|
|
|
126
142
|
/** Field names the action reads itself — never copied into `payload`. */
|
|
127
143
|
const RESERVED_FIELDS = new Set([
|
|
@@ -196,16 +212,54 @@ function isApiUnreachable(res: { status: number }): boolean {
|
|
|
196
212
|
return res.status === 0 || res.status === 502 || res.status === 504;
|
|
197
213
|
}
|
|
198
214
|
|
|
215
|
+
/** The buy route's own bounds on its shown-set read. */
|
|
216
|
+
const SHOWN_READ_TIMEOUT_MS = 5_000;
|
|
217
|
+
const SHOWN_READ_TTL_MS = 15_000;
|
|
218
|
+
|
|
219
|
+
/** The shown set `/pricing` renders from, or `null`: any other answer, a throw, or none in time. */
|
|
220
|
+
async function readShownSet(): Promise<unknown> {
|
|
221
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
222
|
+
const late = new Promise<null>((resolve) => {
|
|
223
|
+
timer = setTimeout(() => resolve(null), SHOWN_READ_TIMEOUT_MS);
|
|
224
|
+
});
|
|
225
|
+
try {
|
|
226
|
+
const res = await Promise.race([offerings.shown(), late]);
|
|
227
|
+
return res?.ok && res.body.status === "shown" ? res.body.shownSet : null;
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
} finally {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let shownRead: { at: number; read: Promise<unknown> } | undefined;
|
|
236
|
+
|
|
199
237
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
238
|
+
* One read per instance per SHOWN_READ_TTL_MS: `offerings.shown()` forwards
|
|
239
|
+
* no visitor header, so a read per submit would count every visitor against
|
|
240
|
+
* this server's one per-IP limit at the API. Failed reads are shared too.
|
|
202
241
|
*/
|
|
203
|
-
function
|
|
204
|
-
|
|
242
|
+
function sharedShownSet(): Promise<unknown> {
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
if (shownRead && now - shownRead.at < SHOWN_READ_TTL_MS) return shownRead.read;
|
|
245
|
+
shownRead = { at: now, read: readShownSet() };
|
|
246
|
+
return shownRead.read;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* #8757 D11, #9171 — the Offerings buy route for the plan a `checkout` answer
|
|
251
|
+
* names: its first shown key in `/pricing`'s card order; else `null`.
|
|
252
|
+
*/
|
|
253
|
+
async function checkoutHrefOf(next: unknown): Promise<string | null> {
|
|
205
254
|
const parsed = leadSubmitNextSchema.safeParse(next);
|
|
206
255
|
if (!parsed.success || parsed.data.completion !== "checkout") return null;
|
|
207
|
-
const
|
|
208
|
-
|
|
256
|
+
const plan = flowCheckoutOfferingSchema.safeParse(parsed.data.checkoutOffering);
|
|
257
|
+
if (!plan.success) return null;
|
|
258
|
+
const shownSet = await sharedShownSet();
|
|
259
|
+
const key = LOOKUP_KEY_PERIODS.map((period) => `${plan.data}_${period}`).find((k) =>
|
|
260
|
+
isShownLookupKey(shownSet, k),
|
|
261
|
+
);
|
|
262
|
+
return key === undefined ? null : offeringsCheckoutHref(key);
|
|
209
263
|
}
|
|
210
264
|
|
|
211
265
|
/**
|
|
@@ -294,13 +348,11 @@ export async function captureLeadAction(
|
|
|
294
348
|
// No request scope to read — no forwarding, and the visitor is "unknown".
|
|
295
349
|
}
|
|
296
350
|
|
|
297
|
-
// No readable address:
|
|
298
|
-
//
|
|
299
|
-
// per-IP limit still applies.
|
|
300
|
-
const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
|
|
351
|
+
// No readable address: the shared, wider bucket, never a skip (#9967 D1).
|
|
352
|
+
// The hub's own per-IP limit still applies.
|
|
301
353
|
if (
|
|
302
|
-
|
|
303
|
-
|
|
354
|
+
!takeVisitorLimit("contact", requestHeaders ?? NO_HEADERS, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
|
|
355
|
+
.allowed
|
|
304
356
|
) {
|
|
305
357
|
return { kind: "error", message: RATE_LIMITED };
|
|
306
358
|
}
|
|
@@ -321,6 +373,12 @@ export async function captureLeadAction(
|
|
|
321
373
|
// No request scope to read — submit with no visitor-link header.
|
|
322
374
|
}
|
|
323
375
|
|
|
376
|
+
// #9255 D4 — the spoke edge's bot check, after every local refusal and
|
|
377
|
+
// immediately before the write.
|
|
378
|
+
const botCheck = await runBotCheck({ source: "bot-check", target: "captureLeadAction", scope: "write" });
|
|
379
|
+
if (botCheck === "bot") return { kind: "error", message: GENERIC_ERROR };
|
|
380
|
+
if (botCheck === "unavailable") return { kind: "unavailable" };
|
|
381
|
+
|
|
324
382
|
// writeLead never throws: a thrown submit is logged there by error name
|
|
325
383
|
// only, with no field values (#4042), and resolves as `status: 0`.
|
|
326
384
|
const res = await writeLead({ source, email, page: "contact", consent: true, payload }, init);
|
|
@@ -331,9 +389,9 @@ export async function captureLeadAction(
|
|
|
331
389
|
if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
|
|
332
390
|
return { kind: "error", message: GENERIC_ERROR };
|
|
333
391
|
}
|
|
334
|
-
// #
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
392
|
+
// #9171 — the form navigates: the buy route refuses a Server Action redirect.
|
|
393
|
+
const checkoutHref = await checkoutHrefOf(res.body.next);
|
|
394
|
+
if (checkoutHref !== null) return { kind: "success", name, redirect: checkoutHref };
|
|
337
395
|
// #8847 D7 — the parsed name, which the magnet's ending fills `{name}` with.
|
|
338
396
|
return { ...(await successState(res.body.next, email)), name };
|
|
339
397
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Course detail page. Reads the Course 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)("Course", 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: "Course",
|
|
27
40
|
slug,
|
|
28
41
|
locale,
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
...(composed
|
|
43
|
+
? {}
|
|
44
|
+
: {
|
|
45
|
+
bodyMode: "mdx-body" as const,
|
|
46
|
+
populatedDescriptionTestId: "course-description",
|
|
47
|
+
}),
|
|
31
48
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
32
49
|
});
|
|
33
50
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub child cards (#10121) for /courses. The shared cases live in
|
|
3
|
+
* `lib/__tests__/hub-children.fixtures.ts`; this file sits beside the route so
|
|
4
|
+
* a scaffold without /courses 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 CoursesPage from "../page";
|
|
24
|
+
import {
|
|
25
|
+
describeHub,
|
|
26
|
+
type HubPage,
|
|
27
|
+
} from "../../../../lib/__tests__/hub-children.fixtures";
|
|
28
|
+
|
|
29
|
+
describeHub(
|
|
30
|
+
["courses", CoursesPage as HubPage, "CoursesList", "Course", "mdx-body"],
|
|
31
|
+
mockRenderPage,
|
|
32
|
+
);
|
|
@@ -45,6 +45,8 @@ describe("courses/page — snapshot regression", () => {
|
|
|
45
45
|
|
|
46
46
|
const element = await CoursesPage({
|
|
47
47
|
params: Promise.resolve({ locale: "en" }),
|
|
48
|
+
// No child pages: this snapshot pins the hub's own render (#10121).
|
|
49
|
+
getMdxSlugs: async () => [],
|
|
48
50
|
getMdxSource,
|
|
49
51
|
});
|
|
50
52
|
const { container } = render(element);
|
|
@@ -66,11 +68,13 @@ describe("courses/page — snapshot regression", () => {
|
|
|
66
68
|
|
|
67
69
|
const element = await CoursesPage({
|
|
68
70
|
params: Promise.resolve({ locale: "en" }),
|
|
71
|
+
// No child pages: this snapshot pins the hub's own render (#10121).
|
|
72
|
+
getMdxSlugs: async () => [],
|
|
69
73
|
getMdxSource,
|
|
70
74
|
});
|
|
71
75
|
const { container } = render(element);
|
|
72
76
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
73
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Courses","description":"Structured learning paths for teams adopting AILK.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Course"}]}}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><
|
|
77
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Courses","description":"Structured learning paths for teams adopting AILK.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Course"}]}}</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">## Courses
|
|
74
78
|
|
|
75
79
|
Body content.</div></div></article></div></div></div></main>"
|
|
76
80
|
`);
|
|
@@ -99,7 +103,7 @@ describe("courses/[slug] — snapshot regression", () => {
|
|
|
99
103
|
});
|
|
100
104
|
const { container } = render(element);
|
|
101
105
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
102
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules.","mainEntity":{"@context":"https://schema.org","@type":"Course","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules."}},{"@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":"Courses","item":"http://localhost:3000/en/courses"},{"@type":"ListItem","position":3,"name":"AEO Fundamentals","item":"http://localhost:3000/en/courses/aeo-fundamentals"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><
|
|
106
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules.","mainEntity":{"@context":"https://schema.org","@type":"Course","name":"AEO Fundamentals","description":"A practical course covering Layer 1 AEO rules."}},{"@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":"Courses","item":"http://localhost:3000/en/courses"},{"@type":"ListItem","position":3,"name":"AEO Fundamentals","item":"http://localhost:3000/en/courses/aeo-fundamentals"}]}]}</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">## AEO Fundamentals
|
|
103
107
|
|
|
104
108
|
Body content.</div></div></article></div></div></div></main>"
|
|
105
109
|
`);
|
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
// Courses list page. Reads CoursesList MDX via the content adapter and emits
|
|
2
2
|
// a JSON-LD CollectionPage via the dispatcher.
|
|
3
|
-
|
|
3
|
+
// It lists every Course page through one generated card section (#10121,
|
|
4
|
+
// apps/web/lib/hub-children.tsx — the /alternatives and /templates shape).
|
|
5
|
+
import type {
|
|
6
|
+
getMdxSlugs as defaultGetMdxSlugs,
|
|
7
|
+
getMdxSource as defaultGetMdxSource,
|
|
8
|
+
getPageSections as defaultGetPageSections,
|
|
9
|
+
} from "@working-theory/page-renderer";
|
|
4
10
|
import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
|
|
5
11
|
import type { Metadata } from "next";
|
|
6
12
|
import type { ReactElement } from "react";
|
|
7
13
|
|
|
14
|
+
import { type HubConfig, hubPageInput } from "../../../lib/hub-children";
|
|
15
|
+
|
|
8
16
|
interface Props {
|
|
9
17
|
params: Promise<{ locale: string }>;
|
|
10
18
|
getMdxSource?: typeof defaultGetMdxSource;
|
|
19
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
20
|
+
getMdxSlugs?: typeof defaultGetMdxSlugs;
|
|
11
21
|
}
|
|
12
22
|
|
|
23
|
+
const HUB: HubConfig = {
|
|
24
|
+
listType: "CoursesList",
|
|
25
|
+
slug: "courses",
|
|
26
|
+
childType: "Course",
|
|
27
|
+
linkLabel: "View the course",
|
|
28
|
+
sectionId: "course-cards",
|
|
29
|
+
body: "mdx-body",
|
|
30
|
+
};
|
|
31
|
+
|
|
13
32
|
export async function generateMetadata({
|
|
14
33
|
params,
|
|
15
34
|
}: Pick<Props, "params">): Promise<Metadata> {
|
|
@@ -19,15 +38,12 @@ export async function generateMetadata({
|
|
|
19
38
|
|
|
20
39
|
export default async function Page({
|
|
21
40
|
params,
|
|
22
|
-
|
|
41
|
+
...loaders
|
|
23
42
|
}: Props): Promise<ReactElement> {
|
|
24
43
|
const { locale } = await params;
|
|
25
44
|
return renderPage({
|
|
26
45
|
type: "CoursesList",
|
|
27
46
|
slug: "courses",
|
|
28
|
-
locale,
|
|
29
|
-
translationsNamespace: "courses",
|
|
30
|
-
bodyMode: "mdx-body",
|
|
31
|
-
...(getMdxSource !== undefined && { getMdxSource }),
|
|
47
|
+
...(await hubPageInput(HUB, locale, loaders)),
|
|
32
48
|
});
|
|
33
49
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Event detail page. Reads the Event MDX for the given slug, renders via
|
|
2
2
|
// renderPage, 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,20 @@ 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)("Event", 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: "Event",
|
|
27
40
|
slug,
|
|
28
41
|
locale,
|
|
29
|
-
bodyMode: "mdx-body",
|
|
42
|
+
...(composed ? {} : { bodyMode: "mdx-body" as const }),
|
|
30
43
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
31
44
|
});
|
|
32
45
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub child cards (#10121) for /events. The shared cases live in
|
|
3
|
+
* `lib/__tests__/hub-children.fixtures.ts`; this file sits beside the route so
|
|
4
|
+
* a scaffold without /events 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 EventsPage from "../page";
|
|
24
|
+
import {
|
|
25
|
+
describeHub,
|
|
26
|
+
type HubPage,
|
|
27
|
+
} from "../../../../lib/__tests__/hub-children.fixtures";
|
|
28
|
+
|
|
29
|
+
describeHub(
|
|
30
|
+
["events", EventsPage as HubPage, "EventsList", "Event", "mdx-body"],
|
|
31
|
+
mockRenderPage,
|
|
32
|
+
);
|
|
@@ -75,6 +75,8 @@ describe("EventsPage — snapshot regression", () => {
|
|
|
75
75
|
it("empty state renders deterministic HTML", async () => {
|
|
76
76
|
const element = await EventsPage({
|
|
77
77
|
params: Promise.resolve({ locale: "en" }),
|
|
78
|
+
// No child pages: this snapshot pins the hub's own render (#10121).
|
|
79
|
+
getMdxSlugs: async () => [],
|
|
78
80
|
getMdxSource: getMdxSourceEmpty,
|
|
79
81
|
});
|
|
80
82
|
const { container } = render(element);
|
|
@@ -96,11 +98,14 @@ describe("EventsPage — snapshot regression", () => {
|
|
|
96
98
|
|
|
97
99
|
const element = await EventsPage({
|
|
98
100
|
params: Promise.resolve({ locale: "en" }),
|
|
101
|
+
// No child pages: this snapshot pins the hub's own render (#10121).
|
|
102
|
+
getMdxSlugs: async () => [],
|
|
99
103
|
getMdxSource: getMdxSourcePopulated,
|
|
100
104
|
});
|
|
101
105
|
const { container } = render(element);
|
|
102
106
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
103
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Events","description":"Upcoming and past events.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Event"}]}}</script><main id="main"><div><
|
|
107
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Events","description":"Upcoming and past events.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"Event"}]}}</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-rendered"># Events
|
|
108
|
+
|
|
104
109
|
Body.</div></article></div></div></div></main>"
|
|
105
110
|
`);
|
|
106
111
|
});
|
|
@@ -134,7 +139,8 @@ describe("EventSlugPage — snapshot regression", () => {
|
|
|
134
139
|
// This replaces the pre-migration hardcoded "event-description" testid
|
|
135
140
|
// with the slug-derived "annual-summit-description". Intentional per spec.
|
|
136
141
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
137
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Annual Summit","description":"Our annual developer summit.","mainEntity":{"@context":"https://schema.org","@type":"Event","name":"Annual Summit","description":"Our annual developer summit."}},{"@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":"Events","item":"http://localhost:3000/en/events"},{"@type":"ListItem","position":3,"name":"Annual Summit","item":"http://localhost:3000/en/events/annual-summit"}]}]}</script><main id="main"><div><
|
|
142
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"Annual Summit","description":"Our annual developer summit.","mainEntity":{"@context":"https://schema.org","@type":"Event","name":"Annual Summit","description":"Our annual developer summit."}},{"@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":"Events","item":"http://localhost:3000/en/events"},{"@type":"ListItem","position":3,"name":"Annual Summit","item":"http://localhost:3000/en/events/annual-summit"}]}]}</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-rendered"># Annual Summit
|
|
143
|
+
|
|
138
144
|
Body.</div></article></div></div></div></main>"
|
|
139
145
|
`);
|
|
140
146
|
});
|
|
@@ -1,15 +1,34 @@
|
|
|
1
|
-
// Events list page. Reads EventsList MDX via
|
|
1
|
+
// Events list page. Reads EventsList MDX via the content adapter and emits
|
|
2
2
|
// a JSON-LD CollectionPage via the dispatcher.
|
|
3
|
-
|
|
3
|
+
// It lists every Event page through one generated card section (#10121,
|
|
4
|
+
// apps/web/lib/hub-children.tsx — the /alternatives and /templates shape).
|
|
5
|
+
import type {
|
|
6
|
+
getMdxSlugs as defaultGetMdxSlugs,
|
|
7
|
+
getMdxSource as defaultGetMdxSource,
|
|
8
|
+
getPageSections as defaultGetPageSections,
|
|
9
|
+
} from "@working-theory/page-renderer";
|
|
4
10
|
import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
|
|
5
11
|
import type { Metadata } from "next";
|
|
6
12
|
import type { ReactElement } from "react";
|
|
7
13
|
|
|
14
|
+
import { type HubConfig, hubPageInput } from "../../../lib/hub-children";
|
|
15
|
+
|
|
8
16
|
interface Props {
|
|
9
17
|
params: Promise<{ locale: string }>;
|
|
10
18
|
getMdxSource?: typeof defaultGetMdxSource;
|
|
19
|
+
getPageSections?: typeof defaultGetPageSections;
|
|
20
|
+
getMdxSlugs?: typeof defaultGetMdxSlugs;
|
|
11
21
|
}
|
|
12
22
|
|
|
23
|
+
const HUB: HubConfig = {
|
|
24
|
+
listType: "EventsList",
|
|
25
|
+
slug: "events",
|
|
26
|
+
childType: "Event",
|
|
27
|
+
linkLabel: "View the event",
|
|
28
|
+
sectionId: "event-cards",
|
|
29
|
+
body: "mdx-body",
|
|
30
|
+
};
|
|
31
|
+
|
|
13
32
|
export async function generateMetadata({
|
|
14
33
|
params,
|
|
15
34
|
}: Pick<Props, "params">): Promise<Metadata> {
|
|
@@ -19,15 +38,12 @@ export async function generateMetadata({
|
|
|
19
38
|
|
|
20
39
|
export default async function Page({
|
|
21
40
|
params,
|
|
22
|
-
|
|
41
|
+
...loaders
|
|
23
42
|
}: Props): Promise<ReactElement> {
|
|
24
43
|
const { locale } = await params;
|
|
25
44
|
return renderPage({
|
|
26
45
|
type: "EventsList",
|
|
27
46
|
slug: "events",
|
|
28
|
-
locale,
|
|
29
|
-
translationsNamespace: "events",
|
|
30
|
-
bodyMode: "mdx-body",
|
|
31
|
-
...(getMdxSource !== undefined && { getMdxSource }),
|
|
47
|
+
...(await hubPageInput(HUB, locale, loaders)),
|
|
32
48
|
});
|
|
33
49
|
}
|