create-ailk 0.21.0 → 0.22.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/component-catalog.json +37 -3
- package/component-catalog.md +6 -5
- package/dist/add-module.js +9 -5
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-package.js +7 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +19 -5
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/surfaces.js +19 -14
- package/dist/sync-routes.js +3 -2
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.js +5 -0
- package/package.json +3 -3
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/templates/apps/api/CLAUDE.md +15 -6
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +120 -9
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +41 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +438 -31
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +168 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +49 -17
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- 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 +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +5 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +34 -17
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +6 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +24 -7
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +38 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +34 -1
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +34 -3
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -10,13 +10,26 @@
|
|
|
10
10
|
* Canonical filename: contact.actions.ts (*.actions.ts per ADR 0013 / U0 spec
|
|
11
11
|
* parity-test regex `ACTION_FILE_RE = /\.actions\.ts$/`).
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* The honeypot is checked here, and so is a per-visitor limit (#8747): 30
|
|
14
|
+
* calls per 10 minutes per visitor address, before the write. From a server
|
|
15
|
+
* action the hub sees this site's egress address, so one visitor could
|
|
16
|
+
* otherwise drain the hub's per-IP budget for every visitor of the site. With
|
|
17
|
+
* no readable address the limit is skipped, never shared. The hub keeps its
|
|
18
|
+
* own per-IP limit on the route.
|
|
19
|
+
*
|
|
20
|
+
* The write (#8747): every lead goes through `writeLead`, the site's lead
|
|
21
|
+
* writer (`src/lib/lead-write.ts`), which marks it `payload.site =
|
|
22
|
+
* AILK_LEAD_SITE` and `payload.page = "contact"` (spread last, so no form field
|
|
23
|
+
* can override them) and sends no `siteId` — the API names the workspace
|
|
24
|
+
* itself, from the spoke key on a hub or its own site alias otherwise.
|
|
14
25
|
*
|
|
15
26
|
* Outcome (#6730 D8): the API's 503 `lead_capture_not_configured` — this
|
|
16
27
|
* deployment has nowhere to put a lead — maps to `{ kind: "unavailable" }`,
|
|
17
|
-
* which the shared `LeadCaptureForm` renders as the site's contact email.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
28
|
+
* which the shared `LeadCaptureForm` renders as the site's contact email. So
|
|
29
|
+
* does an API that cannot be reached (#8747): a thrown submit, a 502 or a 504
|
|
30
|
+
* — the message reached nobody, so the visitor is shown the email rather than
|
|
31
|
+
* a "try again". Every other non-2xx is the generic `error`; only a 2xx is
|
|
32
|
+
* ever `success`.
|
|
20
33
|
*
|
|
21
34
|
* Extra fields (#6730 D8a): a `contact-form` section can declare fields
|
|
22
35
|
* beyond name/email/message/phone (a `select`, say). Every other string
|
|
@@ -33,17 +46,25 @@
|
|
|
33
46
|
* second `*Action`-suffixed export, which would trip
|
|
34
47
|
* `apps/mcp/__tests__/parity.test.ts`'s Server-Action↔MCP-tool 1:1 count for
|
|
35
48
|
* a capability (lead capture) this repo already has an MCP twin for
|
|
36
|
-
* (`ailkCaptureLead`). Absent (the real `<form>` never sets it) →
|
|
37
|
-
*
|
|
49
|
+
* (`ailkCaptureLead`). Absent (the real `<form>` never sets it) → the
|
|
50
|
+
* built-in form's one standard source, `"contact-form"` (#8747; it was
|
|
51
|
+
* `"web"`, and the sites disagreed).
|
|
38
52
|
*
|
|
39
|
-
* Source allow-list and terms (#8661 D8): `/contact` stamps a
|
|
40
|
-
* drew from the Contact form magnet with a hidden `source` of
|
|
41
|
-
* `"flow:contact"`, so it lands as an Inbox submission of that magnet
|
|
42
|
-
*
|
|
43
|
-
* `
|
|
44
|
-
*
|
|
45
|
-
* intake
|
|
46
|
-
*
|
|
53
|
+
* Source allow-list and terms (#8661 D8, #8730): `/contact` stamps a
|
|
54
|
+
* submission it drew from the Contact form magnet with a hidden `source` of
|
|
55
|
+
* `"flow:contact"`, so it lands as an Inbox submission of that magnet (it
|
|
56
|
+
* reaches the Needs-you queue through the submissions read, not the message
|
|
57
|
+
* list — `flow:<slug>` is never a message source). The field is
|
|
58
|
+
* visitor-controlled, so only the contact sources (`CONTACT_LEAD_SOURCES`:
|
|
59
|
+
* `"contact-form"`, `"conversational-intake"`, `"flow:contact"`) are kept;
|
|
60
|
+
* anything else is sent as `"contact-form"`, a message source, so no
|
|
61
|
+
* visitor-set value drops a real message out of Needs-you. The source picks
|
|
62
|
+
* the stamp and never the gate (#8730 D2): EVERY post must carry
|
|
63
|
+
* `consent=true` — the form's terms box, or the intake's own terms turn — or
|
|
64
|
+
* it answers a `consent` field error, merged with the schema's, and nothing
|
|
65
|
+
* is sent. An accepted post tells the API so (`consent: true`, which the API
|
|
66
|
+
* requires of a contact lead and records on it); an API `400
|
|
67
|
+
* consent_required` is shown as the same field error.
|
|
47
68
|
*
|
|
48
69
|
* Visitor forwarding (#7394): on a spoke whose API is a hub, the call
|
|
49
70
|
* carries the visitor's IP and the spoke's `AILK_VISITOR_FORWARD_KEY`
|
|
@@ -52,20 +73,53 @@
|
|
|
52
73
|
* of the spoke host's. No key, no client address, or headers that cannot be
|
|
53
74
|
* read → the call is made with one argument, exactly as before; a failure to
|
|
54
75
|
* read headers never blocks a submission.
|
|
76
|
+
*
|
|
77
|
+
* The account link (#8757 D8): after a successful write, the API's 201 may
|
|
78
|
+
* carry `next` (D6). `{ completion: "invite" }` — the magnet invites the
|
|
79
|
+
* submitter to their account — answers `{ kind: "success", next: { href,
|
|
80
|
+
* label } }`, the href from `accountInviteHref` (the request's locale, the
|
|
81
|
+
* submitter's email, this site's sign-in provider) and the label the
|
|
82
|
+
* `contact.trackInAccount` message. Anything else — no `next`, a site with no
|
|
83
|
+
* sign-in, an email the hint schema refuses, a locale or message that cannot
|
|
84
|
+
* be read — answers exactly `{ kind: "success" }`: a written lead is never
|
|
85
|
+
* reported as a failure.
|
|
86
|
+
*
|
|
87
|
+
* Continue to checkout (#8757 D11): `{ completion: "checkout",
|
|
88
|
+
* checkoutOffering }` — the magnet sends the submitter on to the offering's
|
|
89
|
+
* standard checkout — calls `redirect("/api/checkout/<checkoutOffering>")`
|
|
90
|
+
* (a Server Action redirect, a 303). It runs only after the write resolved
|
|
91
|
+
* `ok`, and outside any `try`, since `redirect()` throws NEXT_REDIRECT by
|
|
92
|
+
* design (`checkout.actions.ts`'s rule). The key is re-checked with
|
|
93
|
+
* `flowCheckoutOfferingSchema` first; one that fails answers `{ kind:
|
|
94
|
+
* "success" }`, as `lead` does. The standard checkout is served on a spoke
|
|
95
|
+
* only (`TOPOLOGY`), so any other build answers `{ kind: "success" }` too,
|
|
96
|
+
* never a redirect to a 404. A failed write never redirects.
|
|
55
97
|
*/
|
|
56
98
|
|
|
57
|
-
import {
|
|
99
|
+
import { visitorForwardHeaders } from "@working-theory/api-client";
|
|
58
100
|
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
59
101
|
import {
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
CONTACT_LEAD_SOURCES,
|
|
103
|
+
flowCheckoutOfferingSchema,
|
|
104
|
+
leadSubmitNextSchema,
|
|
62
105
|
submitLeadInputSchema,
|
|
63
106
|
} from "@working-theory/validation";
|
|
64
107
|
import { cookies, headers } from "next/headers";
|
|
108
|
+
import { redirect } from "next/navigation";
|
|
109
|
+
import { getLocale, getTranslations } from "next-intl/server";
|
|
65
110
|
|
|
111
|
+
import { accountInviteHref } from "../../../lib/account-invite";
|
|
66
112
|
import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
|
|
113
|
+
import { TOPOLOGY } from "../../../lib/topology.generated";
|
|
114
|
+
import { writeLead } from "../../../src/lib/lead-write";
|
|
115
|
+
import { checkRateLimit, clientIpFromHeaders } from "../../../src/lib/rate-limit";
|
|
67
116
|
|
|
68
117
|
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
118
|
+
const RATE_LIMITED = "Too many messages from here. Please try again in a few minutes.";
|
|
119
|
+
|
|
120
|
+
/** The per-visitor budget (#8747): the consumer sites' own values. */
|
|
121
|
+
const RATE_LIMIT_MAX_CALLS = 30;
|
|
122
|
+
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
|
69
123
|
|
|
70
124
|
/** Field names the action reads itself — never copied into `payload`. */
|
|
71
125
|
const RESERVED_FIELDS = new Set([
|
|
@@ -79,13 +133,8 @@ const RESERVED_FIELDS = new Set([
|
|
|
79
133
|
]);
|
|
80
134
|
const MAX_EXTRA_FIELDS = 20;
|
|
81
135
|
|
|
82
|
-
/** The
|
|
83
|
-
const
|
|
84
|
-
"web",
|
|
85
|
-
"conversational-intake",
|
|
86
|
-
flowLeadSource(CONTACT_FORM_FLOW_SLUG),
|
|
87
|
-
]);
|
|
88
|
-
const INTAKE_SOURCE = "conversational-intake";
|
|
136
|
+
/** The built-in form's source (#8747), and the one an unlisted value becomes. */
|
|
137
|
+
const DEFAULT_SOURCE = "contact-form";
|
|
89
138
|
/** The server's terms-error text. The form block shows its own copy for this key. */
|
|
90
139
|
const CONSENT_REQUIRED = "Please accept the terms to send your message.";
|
|
91
140
|
const MAX_EXTRA_FIELD_LENGTH = 2000;
|
|
@@ -108,6 +157,16 @@ function extraFields(formData: FormData): Record<string, string> | null {
|
|
|
108
157
|
return extra;
|
|
109
158
|
}
|
|
110
159
|
|
|
160
|
+
/** A 400 whose body names `consent_required` (#8730). */
|
|
161
|
+
function isConsentRequired(res: { status: number; body: unknown }): boolean {
|
|
162
|
+
return (
|
|
163
|
+
res.status === 400 &&
|
|
164
|
+
typeof res.body === "object" &&
|
|
165
|
+
res.body !== null &&
|
|
166
|
+
(res.body as { error?: unknown }).error === "consent_required"
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
111
170
|
/** A 503 whose body names `lead_capture_not_configured`. */
|
|
112
171
|
function isNotConfigured(res: { status: number; body: unknown }): boolean {
|
|
113
172
|
return (
|
|
@@ -118,6 +177,51 @@ function isNotConfigured(res: { status: number; body: unknown }): boolean {
|
|
|
118
177
|
);
|
|
119
178
|
}
|
|
120
179
|
|
|
180
|
+
/**
|
|
181
|
+
* The API could not be reached (#8747): `writeLead`'s `status: 0` (a thrown
|
|
182
|
+
* submit) or a gateway failure. A 503 that is not `lead_capture_not_configured`
|
|
183
|
+
* stays the generic error — the API answered.
|
|
184
|
+
*/
|
|
185
|
+
function isApiUnreachable(res: { status: number }): boolean {
|
|
186
|
+
return res.status === 0 || res.status === 502 || res.status === 504;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* #8757 D11 — the offering a `checkout` answer names, when its key passes the
|
|
191
|
+
* schema and this build serves the standard checkout (a spoke); else `null`.
|
|
192
|
+
*/
|
|
193
|
+
function checkoutOfferingOf(next: unknown): string | null {
|
|
194
|
+
if (TOPOLOGY !== "spoke") return null;
|
|
195
|
+
const parsed = leadSubmitNextSchema.safeParse(next);
|
|
196
|
+
if (!parsed.success || parsed.data.completion !== "checkout") return null;
|
|
197
|
+
const key = flowCheckoutOfferingSchema.safeParse(parsed.data.checkoutOffering);
|
|
198
|
+
return key.success ? key.data : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* #8757 D8 — the success state for a written lead: with the account link when
|
|
203
|
+
* the API answered `next: { completion: "invite" }` and this site can build
|
|
204
|
+
* one, else exactly `{ kind: "success" }`. Never throws.
|
|
205
|
+
*/
|
|
206
|
+
async function successState(next: unknown, email: string): Promise<LeadCaptureFormState> {
|
|
207
|
+
const parsed = leadSubmitNextSchema.safeParse(next);
|
|
208
|
+
if (!parsed.success || parsed.data.completion !== "invite") return { kind: "success" };
|
|
209
|
+
let locale = "en";
|
|
210
|
+
try {
|
|
211
|
+
locale = await getLocale();
|
|
212
|
+
} catch {
|
|
213
|
+
// No request-scoped locale — the default locale's link.
|
|
214
|
+
}
|
|
215
|
+
const href = accountInviteHref(locale, email);
|
|
216
|
+
if (href === null) return { kind: "success" };
|
|
217
|
+
try {
|
|
218
|
+
const t = await getTranslations("contact");
|
|
219
|
+
return { kind: "success", next: { href, label: t("trackInAccount") } };
|
|
220
|
+
} catch {
|
|
221
|
+
return { kind: "success" };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
121
225
|
export async function captureLeadAction(
|
|
122
226
|
_prevState: LeadCaptureFormState,
|
|
123
227
|
formData: FormData,
|
|
@@ -137,10 +241,12 @@ export async function captureLeadAction(
|
|
|
137
241
|
|
|
138
242
|
const posted = formData.get("source");
|
|
139
243
|
const source =
|
|
140
|
-
typeof posted === "string" &&
|
|
244
|
+
typeof posted === "string" && CONTACT_LEAD_SOURCES.has(posted) ? posted : DEFAULT_SOURCE;
|
|
141
245
|
|
|
246
|
+
// #8730 D2 — every source, the intake's included: it asks for the terms
|
|
247
|
+
// itself and posts consent=true like the form does.
|
|
142
248
|
const fieldErrors: Record<string, string> = {};
|
|
143
|
-
if (
|
|
249
|
+
if (formData.get("consent") !== "true") {
|
|
144
250
|
fieldErrors.consent = CONSENT_REQUIRED;
|
|
145
251
|
}
|
|
146
252
|
|
|
@@ -161,28 +267,35 @@ export async function captureLeadAction(
|
|
|
161
267
|
if (extra === null) return { kind: "error", message: GENERIC_ERROR };
|
|
162
268
|
|
|
163
269
|
const { name, email, message, phone } = parsed.data;
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
payload: {
|
|
170
|
-
...extra,
|
|
171
|
-
name,
|
|
172
|
-
...(message !== undefined && { message }),
|
|
173
|
-
...(phone !== undefined && { phone }),
|
|
174
|
-
},
|
|
270
|
+
const payload = {
|
|
271
|
+
...extra,
|
|
272
|
+
name,
|
|
273
|
+
...(message !== undefined && { message }),
|
|
274
|
+
...(phone !== undefined && { phone }),
|
|
175
275
|
};
|
|
176
276
|
|
|
177
|
-
let
|
|
277
|
+
let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
|
|
178
278
|
try {
|
|
179
|
-
|
|
180
|
-
await headers(),
|
|
181
|
-
process.env.AILK_VISITOR_FORWARD_KEY,
|
|
182
|
-
);
|
|
183
|
-
if (forwarded) init = { headers: forwarded };
|
|
279
|
+
requestHeaders = await headers();
|
|
184
280
|
} catch {
|
|
185
|
-
// No request scope to read —
|
|
281
|
+
// No request scope to read — no forwarding, and the visitor is "unknown".
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// No readable address: skip the per-visitor limit rather than put every
|
|
285
|
+
// visitor in one shared bucket a single caller could fill. The hub's own
|
|
286
|
+
// per-IP limit still applies.
|
|
287
|
+
const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
|
|
288
|
+
if (
|
|
289
|
+
visitor !== "unknown" &&
|
|
290
|
+
!checkRateLimit(`contact:${visitor}`, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
|
|
291
|
+
) {
|
|
292
|
+
return { kind: "error", message: RATE_LIMITED };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let init: { headers: Record<string, string> } | undefined;
|
|
296
|
+
if (requestHeaders) {
|
|
297
|
+
const forwarded = visitorForwardHeaders(requestHeaders, process.env.AILK_VISITOR_FORWARD_KEY);
|
|
298
|
+
if (forwarded) init = { headers: forwarded };
|
|
186
299
|
}
|
|
187
300
|
|
|
188
301
|
// #8438 D12 — the banner's visitor id, so a later form submit links its
|
|
@@ -195,20 +308,18 @@ export async function captureLeadAction(
|
|
|
195
308
|
// No request scope to read — submit with no visitor-link header.
|
|
196
309
|
}
|
|
197
310
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
311
|
+
// writeLead never throws: a thrown submit is logged there by error name
|
|
312
|
+
// only, with no field values (#4042), and resolves as `status: 0`.
|
|
313
|
+
const res = await writeLead({ source, email, page: "contact", consent: true, payload }, init);
|
|
314
|
+
if (!res.ok) {
|
|
315
|
+
if (isConsentRequired(res)) {
|
|
316
|
+
return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
|
|
203
317
|
}
|
|
204
|
-
return { kind: "
|
|
205
|
-
} catch (err) {
|
|
206
|
-
// Log server-side for observability (#4042, sibling of flow.actions.ts's
|
|
207
|
-
// #3989 fix). The api-client's post() never attaches the request payload
|
|
208
|
-
// to a thrown error (packages/api-client/src/http.ts), so logging `err`
|
|
209
|
-
// itself is safe — no submitted form fields (name/email/message/phone)
|
|
210
|
-
// are included.
|
|
211
|
-
console.error("[captureLeadAction]", err);
|
|
318
|
+
if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
|
|
212
319
|
return { kind: "error", message: GENERIC_ERROR };
|
|
213
320
|
}
|
|
321
|
+
// #8757 D11 — after the write, outside any try: redirect() throws by design.
|
|
322
|
+
const checkoutOffering = checkoutOfferingOf(res.body.next);
|
|
323
|
+
if (checkoutOffering !== null) redirect(`/api/checkout/${checkoutOffering}`);
|
|
324
|
+
return successState(res.body.next, email);
|
|
214
325
|
}
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* ids ARE the submission adapter's field-mapping contract; renaming a
|
|
12
12
|
* question id here requires updating that mapping too.
|
|
13
13
|
*
|
|
14
|
+
* A fourth, closed question (`consent`, #8730 D2) asks for the terms before
|
|
15
|
+
* anything is sent. The engine records a tapped pill as its LABEL, so the
|
|
16
|
+
* wrapper treats the answer as acceptance only when it equals
|
|
17
|
+
* `copy.consentAccept` exactly; any other answer — the decline pill or typed
|
|
18
|
+
* text — sends nothing and shows the form, whose own terms box decides.
|
|
19
|
+
*
|
|
14
20
|
* Copy is translated (next-intl "contact" namespace, "intake.*" keys —
|
|
15
21
|
* see apps/web/messages/en.json) rather than hardcoded, consistent with
|
|
16
22
|
* ContactForm's own `useTranslations("contact")` usage. The wrapper resolves
|
|
@@ -31,10 +37,18 @@ export interface ContactIntakeCopy {
|
|
|
31
37
|
messagePrompt: string;
|
|
32
38
|
emailAck: string;
|
|
33
39
|
emailPrompt: string;
|
|
40
|
+
consentAck: string;
|
|
41
|
+
consentPrompt: string;
|
|
42
|
+
/** The accept pill's label — the one answer that is terms acceptance. */
|
|
43
|
+
consentAccept: string;
|
|
44
|
+
consentDecline: string;
|
|
34
45
|
skipMessage: string;
|
|
35
46
|
completionMessage: string;
|
|
36
47
|
}
|
|
37
48
|
|
|
49
|
+
/** The accept option's value — its pill's test id is `…-pill-accept`. */
|
|
50
|
+
const INTAKE_CONSENT_ACCEPT = "accept";
|
|
51
|
+
|
|
38
52
|
export function buildContactIntakeScript(
|
|
39
53
|
copy: ContactIntakeCopy,
|
|
40
54
|
): IntakeScript {
|
|
@@ -59,6 +73,18 @@ export function buildContactIntakeScript(
|
|
|
59
73
|
acknowledgement: copy.emailAck,
|
|
60
74
|
prompt: copy.emailPrompt,
|
|
61
75
|
},
|
|
76
|
+
// #8730 D2 — the intake asks for the terms itself. Only the accept
|
|
77
|
+
// pill's label is acceptance; the wrapper reads `consent` by that id.
|
|
78
|
+
{
|
|
79
|
+
id: "consent",
|
|
80
|
+
kind: "closed",
|
|
81
|
+
acknowledgement: copy.consentAck,
|
|
82
|
+
prompt: copy.consentPrompt,
|
|
83
|
+
options: [
|
|
84
|
+
{ value: INTAKE_CONSENT_ACCEPT, label: copy.consentAccept },
|
|
85
|
+
{ value: "decline", label: copy.consentDecline },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
62
88
|
],
|
|
63
89
|
skip: { message: copy.skipMessage },
|
|
64
90
|
completion: { message: copy.completionMessage },
|
|
@@ -19,8 +19,12 @@
|
|
|
19
19
|
// labels from the `contact` catalog. The submit label is the magnet's, else
|
|
20
20
|
// the section's; the success message is the section's. A magnet-driven
|
|
21
21
|
// post is stamped `source: "flow:contact"`, so it is an Inbox submission of
|
|
22
|
-
// the Contact form; the other two post as `
|
|
23
|
-
// every request,
|
|
22
|
+
// the Contact form; the other two post as `contact-form` (#8747). The page is rendered on
|
|
23
|
+
// every request, but its two hub reads (the magnet and the capture status) are
|
|
24
|
+
// shared across requests for HUB_READ_TTL_MS (#8731): a visitor view carries no
|
|
25
|
+
// forwarding headers, so on a spoke every view would otherwise count against
|
|
26
|
+
// the spoke server's one per-IP limit at the hub. An Admin edit shows within
|
|
27
|
+
// that window. The terms statement
|
|
24
28
|
// links Terms, Privacy Policy and Working Agreement from a page-built map —
|
|
25
29
|
// a magnet label can name a link key, never an href.
|
|
26
30
|
//
|
|
@@ -64,7 +68,7 @@ import { contactFormText } from "./contact-form-text";
|
|
|
64
68
|
import { captureLeadAction } from "./contact.actions";
|
|
65
69
|
import { readSiteContactEmail } from "./site-contact-email";
|
|
66
70
|
|
|
67
|
-
// #8661 D6 —
|
|
71
|
+
// #8661 D6 — rendered on every request; the magnet read behind it is shared for HUB_READ_TTL_MS (#8731).
|
|
68
72
|
export const dynamic = "force-dynamic";
|
|
69
73
|
|
|
70
74
|
interface Props {
|
|
@@ -113,14 +117,40 @@ function builtInFields(text: LeadCaptureFormText, locale: string): ContactFormFi
|
|
|
113
117
|
});
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
/**
|
|
121
|
+
* How long the two hub reads are shared across requests (#8731). Short, so an
|
|
122
|
+
* Admin edit to the magnet shows within seconds; long enough that a burst of
|
|
123
|
+
* views makes one read of each, not one per view.
|
|
124
|
+
*/
|
|
125
|
+
const HUB_READ_TTL_MS = 15_000;
|
|
126
|
+
|
|
127
|
+
const hubReads = new Map<string, { at: number; read: Promise<unknown> }>();
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `read()`'s result, shared per instance for HUB_READ_TTL_MS. Concurrent calls
|
|
131
|
+
* share one in-flight read. A read that settles to `null` (any failed read,
|
|
132
|
+
* see the two readers below) is shared the same way, so a burst against an
|
|
133
|
+
* unreachable hub is not a burst of retries.
|
|
134
|
+
*/
|
|
135
|
+
function sharedHubRead<T>(key: string, read: () => Promise<T>): Promise<T> {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
const hit = hubReads.get(key);
|
|
138
|
+
if (hit && now - hit.at < HUB_READ_TTL_MS) return hit.read as Promise<T>;
|
|
139
|
+
const fresh = read();
|
|
140
|
+
hubReads.set(key, { at: now, read: fresh });
|
|
141
|
+
return fresh;
|
|
142
|
+
}
|
|
143
|
+
|
|
116
144
|
/** The API's capture status, or `null` for any failed read (see header). */
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
145
|
+
function readCaptureStatus(): Promise<CaptureStatus | null> {
|
|
146
|
+
return sharedHubRead("capture-status", async () => {
|
|
147
|
+
try {
|
|
148
|
+
const res = await leads.captureStatus();
|
|
149
|
+
return res.ok && res.body.ok === true ? res.body.data : null;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
});
|
|
124
154
|
}
|
|
125
155
|
|
|
126
156
|
/**
|
|
@@ -129,13 +159,15 @@ async function readCaptureStatus(): Promise<CaptureStatus | null> {
|
|
|
129
159
|
* property access sits inside the `try`, so a client with no `flows` is a
|
|
130
160
|
* failed read too.
|
|
131
161
|
*/
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
162
|
+
function readContactMagnet(): Promise<FlowConfig | null> {
|
|
163
|
+
return sharedHubRead("contact-magnet", async () => {
|
|
164
|
+
try {
|
|
165
|
+
const res = await flows.get(CONTACT_FORM_FLOW_SLUG);
|
|
166
|
+
return res.ok ? res.body : null;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
139
171
|
}
|
|
140
172
|
|
|
141
173
|
export async function generateMetadata({
|
|
@@ -70,7 +70,7 @@ describe("courses/page — snapshot regression", () => {
|
|
|
70
70
|
});
|
|
71
71
|
const { container } = render(element);
|
|
72
72
|
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"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">Courses</h1><p data-testid="courses-description" class="mt-block-tight text-lead">Structured learning paths for teams adopting AILK.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><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 transition-
|
|
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"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">Courses</h1><p data-testid="courses-description" class="mt-block-tight text-lead">Structured learning paths for teams adopting AILK.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><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
74
|
|
|
75
75
|
Body content.</div></div></article></div></div></div></main>"
|
|
76
76
|
`);
|
|
@@ -99,7 +99,7 @@ describe("courses/[slug] — snapshot regression", () => {
|
|
|
99
99
|
});
|
|
100
100
|
const { container } = render(element);
|
|
101
101
|
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"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">AEO Fundamentals</h1><p data-testid="course-description" class="mt-block-tight text-lead">A practical course covering Layer 1 AEO rules.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><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 transition-
|
|
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"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">AEO Fundamentals</h1><p data-testid="course-description" class="mt-block-tight text-lead">A practical course covering Layer 1 AEO rules.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><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
103
|
|
|
104
104
|
Body content.</div></div></article></div></div></div></main>"
|
|
105
105
|
`);
|
package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx
CHANGED
|
@@ -86,7 +86,7 @@ describe("DownloadsPage — snapshot regression", () => {
|
|
|
86
86
|
});
|
|
87
87
|
const { container } = render(element);
|
|
88
88
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
89
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Downloads","description":"Downloadable assets and templates.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"DigitalDocument"}]}}</script><main id="main"><div><h1>Downloads</h1><p data-testid="downloads-description" class="mt-block-tight text-lead">Downloadable assets and templates.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><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 transition-
|
|
89
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Downloads","description":"Downloadable assets and templates.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"DigitalDocument"}]}}</script><main id="main"><div><h1>Downloads</h1><p data-testid="downloads-description" class="mt-block-tight text-lead">Downloadable assets and templates.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><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">
|
|
90
90
|
Body.</div></article></div></div></div></main>"
|
|
91
91
|
`);
|
|
92
92
|
});
|
|
@@ -100,7 +100,7 @@ describe("EventsPage — snapshot regression", () => {
|
|
|
100
100
|
});
|
|
101
101
|
const { container } = render(element);
|
|
102
102
|
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><h1>Events</h1><p data-testid="events-description" class="mt-block-tight text-lead">Upcoming and past events.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><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 transition-
|
|
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><h1>Events</h1><p data-testid="events-description" class="mt-block-tight text-lead">Upcoming and past events.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><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">
|
|
104
104
|
Body.</div></article></div></div></div></main>"
|
|
105
105
|
`);
|
|
106
106
|
});
|
|
@@ -134,7 +134,7 @@ describe("EventSlugPage — snapshot regression", () => {
|
|
|
134
134
|
// This replaces the pre-migration hardcoded "event-description" testid
|
|
135
135
|
// with the slug-derived "annual-summit-description". Intentional per spec.
|
|
136
136
|
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><h1>Annual Summit</h1><p data-testid="annual-summit-description" class="mt-block-tight text-lead">Our annual developer summit.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><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 transition-
|
|
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><h1>Annual Summit</h1><p data-testid="annual-summit-description" class="mt-block-tight text-lead">Our annual developer summit.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><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">
|
|
138
138
|
Body.</div></article></div></div></div></main>"
|
|
139
139
|
`);
|
|
140
140
|
});
|