create-ailk 0.27.0 → 0.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -7
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/chrome-readers.d.ts +21 -5
- package/dist/lib/chrome-readers.js +128 -20
- package/dist/lib/declared-env.d.ts +6 -0
- package/dist/lib/declared-env.js +2 -0
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/import-graph.d.ts +16 -0
- package/dist/lib/import-graph.js +53 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +23 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +105 -9
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/template-record.d.ts +13 -4
- package/dist/template-record.js +8 -4
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +34 -2
- package/dist/update.js +395 -48
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +5 -2
- package/templates/apps/api/.env.example +9 -5
- package/templates/apps/api/CLAUDE.md +14 -9
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +25 -11
- package/templates/apps/web/CLAUDE.md +45 -4
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +157 -139
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +224 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +237 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +279 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +31 -106
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +86 -30
- package/templates/apps/web/middleware.ts +171 -11
- package/templates/apps/web/next.config.mjs +211 -2
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/templates/tools/declared-env.ts +7 -0
- package/versions.json +48 -48
|
@@ -8,42 +8,44 @@
|
|
|
8
8
|
// HTML, so the card never flashes from the authored plan to the named one.
|
|
9
9
|
//
|
|
10
10
|
// The Offerings shown set (#8927, spec
|
|
11
|
-
// docs/specs/8927-pricing-renders-the-shown-set-spec.md).
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
11
|
+
// docs/specs/8927-pricing-renders-the-shown-set-spec.md). The page reads
|
|
12
|
+
// `GET /v1/content/shown-offerings` through the api-client, shared per server
|
|
13
|
+
// instance, and renders one of three pages — unconnected, a current shown
|
|
14
|
+
// set, or the contact email — through `pricingSections` in
|
|
15
|
+
// `lib/pricing-page.ts`, shared with every offering pricing page (#10044 D5).
|
|
16
|
+
// The page's own `plans` frontmatter narrows its Stripe cards; `plans: []`
|
|
17
|
+
// keeps its written sections and reads no Stripe data.
|
|
18
|
+
//
|
|
19
|
+
// The offering pricing pages (#10044 D6): every other `Pricing` file
|
|
20
|
+
// (`content/<locale>/Pricing/<offering>.mdx`, served at /pricing/<offering>)
|
|
21
|
+
// is listed after this page's own sections, before its closing CTA, as one
|
|
22
|
+
// generated `items` section — no hand-maintained list. A site with no
|
|
23
|
+
// offering page renders exactly as before.
|
|
24
|
+
//
|
|
21
25
|
// Stripe text reaches markup only as React text and JSON-LD only through
|
|
22
26
|
// renderPage's serializer (T4); the metadata stays the MDX page's (T8).
|
|
23
|
-
import { offerings } from "@working-theory/api-client";
|
|
24
27
|
import {
|
|
28
|
+
getMdxSource as loadMdxSource,
|
|
25
29
|
getPageSections as defaultGetPageSections,
|
|
26
30
|
renderPage,
|
|
27
31
|
renderPageMetadata,
|
|
28
32
|
} from "@working-theory/page-renderer";
|
|
29
33
|
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
30
|
-
import { ContactFallback } from "@working-theory/ui";
|
|
31
|
-
import {
|
|
32
|
-
LOOKUP_KEY_PLAN_PATTERN,
|
|
33
|
-
type PageSection,
|
|
34
|
-
} from "@working-theory/validation";
|
|
35
34
|
import type { Metadata } from "next";
|
|
36
35
|
import { getTranslations } from "next-intl/server";
|
|
37
36
|
import type { ReactElement } from "react";
|
|
38
37
|
|
|
39
|
-
import { browserApiOrigin } from "../../../lib/api-origin.generated";
|
|
40
38
|
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
offeringPricingPages,
|
|
40
|
+
pagePlans,
|
|
41
|
+
pricingPageCard,
|
|
42
|
+
pricingSections,
|
|
43
|
+
pricingSectionSlots,
|
|
44
|
+
} from "../../../lib/pricing-page";
|
|
45
|
+
import {
|
|
46
|
+
insertBeforeClosingCtas,
|
|
47
|
+
listDetailPages,
|
|
48
|
+
} from "../../../lib/route-family";
|
|
47
49
|
|
|
48
50
|
// #8927 D2 — never prerendered; the Offerings read behind it is shared for READ_TTL_MS.
|
|
49
51
|
export const dynamic = "force-dynamic";
|
|
@@ -53,275 +55,11 @@ interface Props {
|
|
|
53
55
|
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
54
56
|
getMdxSource?: typeof defaultGetMdxSource;
|
|
55
57
|
getPageSections?: typeof defaultGetPageSections;
|
|
58
|
+
/** The offering pages' reader (#10044 D6); a test's DI seam. */
|
|
59
|
+
listPages?: typeof listDetailPages;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
type
|
|
59
|
-
type PricingData = PricingSection["data"];
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Every `pricing` section with `recommended` set to `plan` (#8842). Each
|
|
63
|
-
* section matches the key against its own plans, so a second plan group
|
|
64
|
-
* keeps its own default unless it carries the same key.
|
|
65
|
-
*/
|
|
66
|
-
function withRecommendedPlan(
|
|
67
|
-
sections: PageSection[],
|
|
68
|
-
plan: string,
|
|
69
|
-
): PageSection[] {
|
|
70
|
-
return sections.map((section) =>
|
|
71
|
-
section.type === "pricing"
|
|
72
|
-
? {
|
|
73
|
-
...section,
|
|
74
|
-
// `recommended` is a render-time field the section reads, not an
|
|
75
|
-
// authored one, so it is not in the validated section schema.
|
|
76
|
-
data: { ...section.data, recommended: plan } as typeof section.data,
|
|
77
|
-
}
|
|
78
|
-
: section,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
type OfferingsRead =
|
|
83
|
-
| { kind: "unconnected" }
|
|
84
|
-
| { kind: "shown"; shownSet: unknown }
|
|
85
|
-
| { kind: "contact" };
|
|
86
|
-
|
|
87
|
-
const CONTACT: OfferingsRead = { kind: "contact" };
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* How long the Offerings read is shared across requests (D2): a view carries
|
|
91
|
-
* no visitor headers, so every view would otherwise count against this
|
|
92
|
-
* server's one per-IP limit at the API. A product switched off disappears
|
|
93
|
-
* within it. The contact page's window (#8731).
|
|
94
|
-
*/
|
|
95
|
-
const READ_TTL_MS = 15_000;
|
|
96
|
-
|
|
97
|
-
const reads = new Map<string, { at: number; read: Promise<OfferingsRead> }>();
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* `read()`'s result, shared per instance for READ_TTL_MS. Concurrent calls
|
|
101
|
-
* share one in-flight read, and a failed read is shared the same way, so a
|
|
102
|
-
* burst against an unreachable API is one call.
|
|
103
|
-
*/
|
|
104
|
-
function sharedRead(
|
|
105
|
-
key: string,
|
|
106
|
-
read: () => Promise<OfferingsRead>,
|
|
107
|
-
): Promise<OfferingsRead> {
|
|
108
|
-
const now = Date.now();
|
|
109
|
-
const hit = reads.get(key);
|
|
110
|
-
if (hit && now - hit.at < READ_TTL_MS) return hit.read;
|
|
111
|
-
const fresh = read();
|
|
112
|
-
reads.set(key, { at: now, read: fresh });
|
|
113
|
-
return fresh;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Whether an API origin is configured: the three names `getApiBase()` in
|
|
118
|
-
* packages/api-client/src/http.ts reads, in step with it on a hub. Without one
|
|
119
|
-
* that function answers `localhost:3001`. The browser-facing origin comes from
|
|
120
|
-
* the topology seam (#7874 D12), which a spoke renders with no env read, so a
|
|
121
|
-
* spoke passes its own `env:check` (#9480). A spoke diverges on purpose: the
|
|
122
|
-
* browser-facing name set on one at runtime counts as no origin here.
|
|
123
|
-
*/
|
|
124
|
-
function apiOriginConfigured(): boolean {
|
|
125
|
-
return Boolean(
|
|
126
|
-
process.env.AILK_API_URL ||
|
|
127
|
-
browserApiOrigin() ||
|
|
128
|
-
process.env.NEXT_PUBLIC_API_URL,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* The shown-offerings read (D2). A production deployment with no API has no
|
|
134
|
-
* connection to read, so it is unconnected without a call. The body is
|
|
135
|
-
* untrusted: anything but `unconnected` or `shown` — `empty`, an unknown
|
|
136
|
-
* status, a non-object body, a non-2xx, a throw — is the contact email.
|
|
137
|
-
*/
|
|
138
|
-
function readShownOfferings(): Promise<OfferingsRead> {
|
|
139
|
-
if (process.env.NODE_ENV === "production" && !apiOriginConfigured()) {
|
|
140
|
-
return Promise.resolve({ kind: "unconnected" });
|
|
141
|
-
}
|
|
142
|
-
return sharedRead("shown-offerings", async () => {
|
|
143
|
-
try {
|
|
144
|
-
const res = await offerings.shown();
|
|
145
|
-
if (!res.ok) return CONTACT;
|
|
146
|
-
const body: unknown = res.body;
|
|
147
|
-
if (body === null || typeof body !== "object") return CONTACT;
|
|
148
|
-
const { status, shownSet } = body as {
|
|
149
|
-
status?: unknown;
|
|
150
|
-
shownSet?: unknown;
|
|
151
|
-
};
|
|
152
|
-
if (status === "unconnected") return { kind: "unconnected" };
|
|
153
|
-
if (status === "shown") return { kind: "shown", shownSet };
|
|
154
|
-
return CONTACT;
|
|
155
|
-
} catch {
|
|
156
|
-
return CONTACT;
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Keeps the `{amount}` placeholder for `period` to fill (D6). */
|
|
162
|
-
const KEEP_AMOUNT_PLACEHOLDER = { amount: "{amount}" };
|
|
163
|
-
|
|
164
|
-
/** The mapper's labels and the plans section's recommendation label (D6). */
|
|
165
|
-
async function offeringsCopy(locale: string): Promise<{
|
|
166
|
-
labels: OfferingsPricingLabels;
|
|
167
|
-
recommendedLabel: string;
|
|
168
|
-
addonsHeadline: string;
|
|
169
|
-
}> {
|
|
170
|
-
const t = await getTranslations({ locale, namespace: "pricingOfferings" });
|
|
171
|
-
const period = (key: "monthly" | "annual" | "oneTime") => {
|
|
172
|
-
const phrase = t(key, KEEP_AMOUNT_PLACEHOLDER);
|
|
173
|
-
return (amount: string) => phrase.replace("{amount}", () => amount);
|
|
174
|
-
};
|
|
175
|
-
return {
|
|
176
|
-
labels: {
|
|
177
|
-
cta: t("cta"),
|
|
178
|
-
recommended: t("recommended"),
|
|
179
|
-
monthly: period("monthly"),
|
|
180
|
-
annual: period("annual"),
|
|
181
|
-
oneTime: period("oneTime"),
|
|
182
|
-
},
|
|
183
|
-
recommendedLabel: t("recommendedForYou"),
|
|
184
|
-
addonsHeadline: t("addonsHeadline"),
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** The authored section copy a Stripe section keeps — never a plan or price fact. */
|
|
189
|
-
const AUTHORED_COPY_FIELDS = [
|
|
190
|
-
"eyebrow",
|
|
191
|
-
"headline",
|
|
192
|
-
"description",
|
|
193
|
-
"header",
|
|
194
|
-
"headlineSize",
|
|
195
|
-
"toggleStyle",
|
|
196
|
-
"featureListStyle",
|
|
197
|
-
"gapAbove",
|
|
198
|
-
"separation",
|
|
199
|
-
"gap",
|
|
200
|
-
"surface",
|
|
201
|
-
"note",
|
|
202
|
-
"collapseFeaturesOnMobile",
|
|
203
|
-
] as const satisfies readonly (keyof PricingData)[];
|
|
204
|
-
|
|
205
|
-
function authoredCopy(
|
|
206
|
-
section: PricingSection | undefined,
|
|
207
|
-
extra: readonly (keyof PricingData)[] = [],
|
|
208
|
-
): Partial<PricingData> {
|
|
209
|
-
const copy: Partial<Record<keyof PricingData, unknown>> = {};
|
|
210
|
-
if (!section) return copy as Partial<PricingData>;
|
|
211
|
-
for (const field of [...AUTHORED_COPY_FIELDS, ...extra]) {
|
|
212
|
-
if (section.data[field] !== undefined) copy[field] = section.data[field];
|
|
213
|
-
}
|
|
214
|
-
return copy as Partial<PricingData>;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function isPricing(section: PageSection): section is PricingSection {
|
|
218
|
-
return section.type === "pricing";
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Where a section with no slot goes: after a leading hero, else first. */
|
|
222
|
-
function insertionIndex(sections: PageSection[]): number {
|
|
223
|
-
return sections[0]?.type === "hero" ? 1 : 0;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* The page's sections with the Stripe fragments in place of its authored
|
|
228
|
-
* `pricing` sections (D4). The first non-`addons` pricing section is the
|
|
229
|
-
* plans slot and the first `addons` one the add-ons slot; every other pricing
|
|
230
|
-
* section, and a slot whose fragment is `null`, is removed. A fragment with no
|
|
231
|
-
* slot is inserted after a leading hero (the add-ons directly after the
|
|
232
|
-
* plans). No other section moves.
|
|
233
|
-
*/
|
|
234
|
-
function withOfferings(
|
|
235
|
-
sections: PageSection[],
|
|
236
|
-
pricing: OfferingsPricing,
|
|
237
|
-
plan: string | undefined,
|
|
238
|
-
recommendedLabel: string,
|
|
239
|
-
addonsHeadline: string,
|
|
240
|
-
): PageSection[] {
|
|
241
|
-
const plansSlot = sections.find(
|
|
242
|
-
(s): s is PricingSection => isPricing(s) && s.data.variant !== "addons",
|
|
243
|
-
);
|
|
244
|
-
const addonsSlot = sections.find(
|
|
245
|
-
(s): s is PricingSection => isPricing(s) && s.data.variant === "addons",
|
|
246
|
-
);
|
|
247
|
-
const recommend =
|
|
248
|
-
plan !== undefined && LOOKUP_KEY_PLAN_PATTERN.test(plan) ? plan : undefined;
|
|
249
|
-
const plansSection: PricingSection | null = pricing.plans && {
|
|
250
|
-
id: plansSlot?.id ?? "offerings-plans",
|
|
251
|
-
type: "pricing",
|
|
252
|
-
data: {
|
|
253
|
-
...authoredCopy(plansSlot),
|
|
254
|
-
...pricing.plans,
|
|
255
|
-
// A `?plan=` recommendation moves the mapper's highlight onto the named
|
|
256
|
-
// plan and wears `recommendedLabel` (#8842); the mapper's own
|
|
257
|
-
// "Recommended" pill label would ride along as a second label, so it is
|
|
258
|
-
// dropped from every card when a recommendation is in play.
|
|
259
|
-
...(recommend !== undefined &&
|
|
260
|
-
pricing.plans.plans.some(
|
|
261
|
-
(card) => card.key?.toLowerCase() === recommend.toLowerCase(),
|
|
262
|
-
)
|
|
263
|
-
? {
|
|
264
|
-
plans: pricing.plans.plans.map(({ highlightLabel: _, ...card }) => card),
|
|
265
|
-
}
|
|
266
|
-
: {}),
|
|
267
|
-
// Render-time fields the section reads (#8842), not authored ones.
|
|
268
|
-
recommendedLabel,
|
|
269
|
-
...(recommend !== undefined ? { recommended: recommend } : {}),
|
|
270
|
-
} as PricingData,
|
|
271
|
-
};
|
|
272
|
-
const addonsSection: PricingSection | null = pricing.addons && {
|
|
273
|
-
id: addonsSlot?.id ?? "offerings-addons",
|
|
274
|
-
type: "pricing",
|
|
275
|
-
data: {
|
|
276
|
-
// An inserted add-ons section (no authored slot) sits tight under the
|
|
277
|
-
// plans and carries its own headline, so a lone tile is never an
|
|
278
|
-
// unlabeled band (design review B2).
|
|
279
|
-
...(addonsSlot ? {} : { headline: addonsHeadline, gapAbove: "tight" }),
|
|
280
|
-
...authoredCopy(addonsSlot, ["addonLayout"]),
|
|
281
|
-
...pricing.addons,
|
|
282
|
-
} as PricingData,
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
const placed: PageSection[] = [];
|
|
286
|
-
for (const section of sections) {
|
|
287
|
-
if (!isPricing(section)) placed.push(section);
|
|
288
|
-
else if (section === plansSlot && plansSection) placed.push(plansSection);
|
|
289
|
-
else if (section === addonsSlot && addonsSection)
|
|
290
|
-
placed.push(addonsSection);
|
|
291
|
-
}
|
|
292
|
-
if (plansSection && !plansSlot) {
|
|
293
|
-
placed.splice(insertionIndex(placed), 0, plansSection);
|
|
294
|
-
}
|
|
295
|
-
if (addonsSection && !addonsSlot) {
|
|
296
|
-
const plansAt = plansSection ? placed.indexOf(plansSection) : -1;
|
|
297
|
-
placed.splice(
|
|
298
|
-
plansAt >= 0 ? plansAt + 1 : insertionIndex(placed),
|
|
299
|
-
0,
|
|
300
|
-
addonsSection,
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
return placed;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** The section the contact email is slotted into (D5). */
|
|
307
|
-
const CONTACT_SECTION_ID = "offerings-contact";
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* The page's sections with every `pricing` section removed and one
|
|
311
|
-
* `contact-form` section in the first one's place, else after a leading hero
|
|
312
|
-
* (D5). It contributes no JSON-LD, so the page publishes no `Offer`.
|
|
313
|
-
*/
|
|
314
|
-
function withContactFallback(sections: PageSection[]): PageSection[] {
|
|
315
|
-
const first = sections.findIndex(isPricing);
|
|
316
|
-
const kept: PageSection[] = sections.filter((s) => !isPricing(s));
|
|
317
|
-
const contact = {
|
|
318
|
-
id: CONTACT_SECTION_ID,
|
|
319
|
-
type: "contact-form",
|
|
320
|
-
data: { fields: [] },
|
|
321
|
-
} as PageSection;
|
|
322
|
-
kept.splice(first >= 0 ? first : insertionIndex(kept), 0, contact);
|
|
323
|
-
return kept;
|
|
324
|
-
}
|
|
62
|
+
type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
|
|
325
63
|
|
|
326
64
|
export async function generateMetadata({
|
|
327
65
|
params,
|
|
@@ -335,71 +73,48 @@ export default async function Page({
|
|
|
335
73
|
searchParams,
|
|
336
74
|
getMdxSource,
|
|
337
75
|
getPageSections = defaultGetPageSections,
|
|
76
|
+
listPages = listDetailPages,
|
|
338
77
|
}: Props): Promise<ReactElement> {
|
|
339
78
|
const { locale } = await params;
|
|
340
79
|
const search = (await searchParams) ?? {};
|
|
341
80
|
const plan = Array.isArray(search.plan) ? search.plan[0] : search.plan;
|
|
342
|
-
const read = await readShownOfferings();
|
|
343
81
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
82
|
+
/**
|
|
83
|
+
* The page's sections in its Offerings state, then the offering pricing
|
|
84
|
+
* pages list. Runs only when the hub's source exists.
|
|
85
|
+
*/
|
|
86
|
+
async function withPricing(frontmatter: unknown): Promise<Section[]> {
|
|
87
|
+
const sections = await pricingSections(await getPageSections(frontmatter), {
|
|
88
|
+
plans: pagePlans(frontmatter),
|
|
89
|
+
plan,
|
|
348
90
|
locale,
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
91
|
+
});
|
|
92
|
+
const pages = offeringPricingPages(
|
|
93
|
+
await listPages("Pricing", locale, getMdxSource ?? loadMdxSource),
|
|
94
|
+
);
|
|
95
|
+
if (pages.length === 0) return sections;
|
|
96
|
+
const t = await getTranslations({ locale, namespace: "pricingPages" });
|
|
97
|
+
const labels = { viewPricing: t("viewPricing") };
|
|
98
|
+
return insertBeforeClosingCtas(sections, {
|
|
99
|
+
type: "items",
|
|
100
|
+
id: "pricing-pages",
|
|
101
|
+
data: {
|
|
102
|
+
headline: t("heading"),
|
|
103
|
+
// A tile is one click target per card (the /templates hub's rule).
|
|
104
|
+
variant: "tile",
|
|
105
|
+
tileHover: "outline",
|
|
106
|
+
items: pages.map((page) => pricingPageCard(page, labels)),
|
|
107
|
+
},
|
|
355
108
|
});
|
|
356
109
|
}
|
|
357
110
|
|
|
358
|
-
if (read.kind === "shown") {
|
|
359
|
-
const { labels, recommendedLabel, addonsHeadline } =
|
|
360
|
-
await offeringsCopy(locale);
|
|
361
|
-
const pricing = offeringsPricing(read.shownSet, { locale, labels });
|
|
362
|
-
if (pricing && (pricing.plans || pricing.addons)) {
|
|
363
|
-
return renderPage({
|
|
364
|
-
type: "Pricing",
|
|
365
|
-
slug: "pricing",
|
|
366
|
-
locale,
|
|
367
|
-
translationsNamespace: "pricing",
|
|
368
|
-
...(getMdxSource !== undefined && { getMdxSource }),
|
|
369
|
-
getPageSections: async (frontmatter) =>
|
|
370
|
-
withOfferings(
|
|
371
|
-
await getPageSections(frontmatter),
|
|
372
|
-
pricing,
|
|
373
|
-
plan,
|
|
374
|
-
recommendedLabel,
|
|
375
|
-
addonsHeadline,
|
|
376
|
-
),
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const text = contactFormText(
|
|
382
|
-
await getTranslations({ locale, namespace: "contact" }),
|
|
383
|
-
);
|
|
384
111
|
return renderPage({
|
|
385
112
|
type: "Pricing",
|
|
386
113
|
slug: "pricing",
|
|
387
114
|
locale,
|
|
388
115
|
translationsNamespace: "pricing",
|
|
389
116
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
390
|
-
getPageSections:
|
|
391
|
-
|
|
392
|
-
sectionSlots: {
|
|
393
|
-
[CONTACT_SECTION_ID]: (
|
|
394
|
-
<ContactFallback
|
|
395
|
-
email={readSiteContactEmail()}
|
|
396
|
-
text={{
|
|
397
|
-
heading: text.unavailableHeading,
|
|
398
|
-
body: text.unavailableBody,
|
|
399
|
-
noEmail: text.unavailableNoEmail,
|
|
400
|
-
}}
|
|
401
|
-
/>
|
|
402
|
-
),
|
|
403
|
-
},
|
|
117
|
+
getPageSections: withPricing,
|
|
118
|
+
sectionSlots: await pricingSectionSlots(locale),
|
|
404
119
|
});
|
|
405
120
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Product detail page. Reads the Product MDX for the given slug, renders via
|
|
2
|
-
//
|
|
2
|
+
// @working-theory/page-renderer, and emits a JSON-LD <script>. Unknown slugs 404 through renderPage.
|
|
3
3
|
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getMdxSource as loadMdxSource,
|
|
6
|
+
renderPage,
|
|
7
|
+
renderPageMetadata,
|
|
8
|
+
} from "@working-theory/page-renderer";
|
|
5
9
|
import type { Metadata } from "next";
|
|
6
10
|
import type { ReactElement } from "react";
|
|
7
11
|
|
|
@@ -22,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)("Product", 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: "Product",
|
|
27
40
|
slug,
|
|
28
41
|
locale,
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
...(composed
|
|
43
|
+
? {}
|
|
44
|
+
: {
|
|
45
|
+
bodyMode: "mdx-body" as const,
|
|
46
|
+
populatedDescriptionTestId: "product-description",
|
|
47
|
+
}),
|
|
31
48
|
...(getMdxSource !== undefined && { getMdxSource }),
|
|
32
49
|
});
|
|
33
50
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub child cards (#10121) for /products. The shared cases live in
|
|
3
|
+
* `lib/__tests__/hub-children.fixtures.ts`; this file sits beside the route so
|
|
4
|
+
* a scaffold without /products 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 ProductsPage from "../page";
|
|
24
|
+
import {
|
|
25
|
+
describeHub,
|
|
26
|
+
type HubPage,
|
|
27
|
+
} from "../../../../lib/__tests__/hub-children.fixtures";
|
|
28
|
+
|
|
29
|
+
describeHub(
|
|
30
|
+
["products", ProductsPage as HubPage, "ProductsList", "Product", "mdx-body"],
|
|
31
|
+
mockRenderPage,
|
|
32
|
+
);
|
|
@@ -70,6 +70,8 @@ describe("ProductsPage — snapshot regression", () => {
|
|
|
70
70
|
const getMdxSourceEmpty = jest.fn().mockResolvedValue(null);
|
|
71
71
|
const element = await ProductsPage({
|
|
72
72
|
params: Promise.resolve({ locale: "en" }),
|
|
73
|
+
// No child pages: this snapshot pins the hub's own render (#10121).
|
|
74
|
+
getMdxSlugs: async () => [],
|
|
73
75
|
getMdxSource: getMdxSourceEmpty,
|
|
74
76
|
});
|
|
75
77
|
const { container } = render(element);
|
|
@@ -83,11 +85,14 @@ describe("ProductsPage — snapshot regression", () => {
|
|
|
83
85
|
const getMdxSourcePopulated = jest.fn().mockResolvedValue(source);
|
|
84
86
|
const element = await ProductsPage({
|
|
85
87
|
params: Promise.resolve({ locale: "en" }),
|
|
88
|
+
// No child pages: this snapshot pins the hub's own render (#10121).
|
|
89
|
+
getMdxSlugs: async () => [],
|
|
86
90
|
getMdxSource: getMdxSourcePopulated,
|
|
87
91
|
});
|
|
88
92
|
const { container } = render(element);
|
|
89
93
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
90
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Products","description":"All products.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"SoftwareApplication"}]}}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><
|
|
94
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@type":"CollectionPage","inLanguage":"en","name":"Products","description":"All products.","mainEntity":{"@type":"ItemList","itemListElement":[{"@type":"SoftwareApplication"}]}}</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-remote" data-source="# Products
|
|
95
|
+
|
|
91
96
|
Body."></div></div></article></div></div></div></main>"
|
|
92
97
|
`);
|
|
93
98
|
});
|
|
@@ -110,7 +115,8 @@ describe("ProductSlugPage — snapshot regression", () => {
|
|
|
110
115
|
});
|
|
111
116
|
const { container } = render(element);
|
|
112
117
|
expect(container.innerHTML).toMatchInlineSnapshot(`
|
|
113
|
-
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AI Launch Kit","description":"The AX-first OSS template.","mainEntity":{"@context":"https://schema.org","@type":"SoftwareApplication","name":"AI Launch Kit","description":"The AX-first OSS template."}},{"@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":"Products","item":"http://localhost:3000/en/products"},{"@type":"ListItem","position":3,"name":"AI Launch Kit","item":"http://localhost:3000/en/products/launch-kit"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><
|
|
118
|
+
"<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AI Launch Kit","description":"The AX-first OSS template.","mainEntity":{"@context":"https://schema.org","@type":"SoftwareApplication","name":"AI Launch Kit","description":"The AX-first OSS template."}},{"@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":"Products","item":"http://localhost:3000/en/products"},{"@type":"ListItem","position":3,"name":"AI Launch Kit","item":"http://localhost:3000/en/products/launch-kit"}]}]}</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-remote" data-source="# AI Launch Kit
|
|
119
|
+
|
|
114
120
|
Body."></div></div></article></div></div></div></main>"
|
|
115
121
|
`);
|
|
116
122
|
});
|