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
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* - T2 — every Stripe string is emitted as a plain text field, with format
|
|
14
14
|
* characters removed; nothing is parsed as HTML or Markdown (D8).
|
|
15
15
|
* - T3 — every link is `OFFERINGS_CHECKOUT_PATH` plus a lookup key that
|
|
16
|
-
* `parseLookupKey` accepts
|
|
16
|
+
* `parseLookupKey` accepts — plus, for a bundled plan, `?with=` and the
|
|
17
|
+
* required product's key (#10028 D9); no href is built from any other
|
|
18
|
+
* field (D5).
|
|
17
19
|
* - §3.7 — `yearly` reads as annual and `once` as one-time at render, never
|
|
18
20
|
* in the key (D2).
|
|
19
21
|
*
|
|
@@ -49,6 +51,10 @@ export interface OfferingsPricingLabels {
|
|
|
49
51
|
monthly(amount: string): string;
|
|
50
52
|
annual(amount: string): string;
|
|
51
53
|
oneTime(amount: string): string;
|
|
54
|
+
/** A bundled plan's phrase: its one-time phrase plus its required product's recurring phrase (#10028 D9). */
|
|
55
|
+
withRequired(plan: string, required: string): string;
|
|
56
|
+
/** A deposit plan's footnote, from the formatted deposit (#10030 D10). */
|
|
57
|
+
depositNote(deposit: string): string;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
export interface OfferingsPricing {
|
|
@@ -69,6 +75,21 @@ export function offeringsCheckoutHref(lookupKey: string): string | null {
|
|
|
69
75
|
: null;
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The CTA target for a bundled plan (#10028 D9): the plan's key plus the
|
|
80
|
+
* required product's key as `?with=`, or `null` when either is outside the
|
|
81
|
+
* grammar. Both keys are `[a-z0-9_-]` only, so neither needs encoding.
|
|
82
|
+
*/
|
|
83
|
+
export function offeringsBundleHref(
|
|
84
|
+
planKey: string,
|
|
85
|
+
requiredKey: string,
|
|
86
|
+
): string | null {
|
|
87
|
+
const base = offeringsCheckoutHref(planKey);
|
|
88
|
+
return base && parseLookupKey(requiredKey)
|
|
89
|
+
? `${base}?with=${requiredKey}`
|
|
90
|
+
: null;
|
|
91
|
+
}
|
|
92
|
+
|
|
72
93
|
/**
|
|
73
94
|
* Whether `lookupKey` is a grammar key of a Price in a current, non-empty
|
|
74
95
|
* shown set — the keys `/pricing`'s cards are built from (#9051 D4).
|
|
@@ -194,6 +215,63 @@ function firstRecommended(products: OfferingsProduct[]): string | undefined {
|
|
|
194
215
|
return products.find((p) => p.recommended)?.plan;
|
|
195
216
|
}
|
|
196
217
|
|
|
218
|
+
/**
|
|
219
|
+
* A bundled plan's card (#10028 D9): the plan's one-time Price with its
|
|
220
|
+
* required product's recurring Prices — one link per shown recurring
|
|
221
|
+
* interval. With monthly and yearly, the monthly link is the CTA and the
|
|
222
|
+
* yearly one the annual link, each mode phrasing both amounts; with one,
|
|
223
|
+
* that link alone and the phrase as the custom price. No card when the plan's
|
|
224
|
+
* own one-time Price, or a recurring Price of the required product, is not
|
|
225
|
+
* shown.
|
|
226
|
+
*/
|
|
227
|
+
function bundleCard(
|
|
228
|
+
product: OfferingsProduct,
|
|
229
|
+
required: OfferingsProduct,
|
|
230
|
+
highlighted: boolean,
|
|
231
|
+
locale: string,
|
|
232
|
+
labels: OfferingsPricingLabels,
|
|
233
|
+
): PricingPlanCard | null {
|
|
234
|
+
const once = priceFor(product, PERIOD.once);
|
|
235
|
+
const monthly = priceFor(required, PERIOD.monthly);
|
|
236
|
+
const yearly = priceFor(required, PERIOD.yearly);
|
|
237
|
+
const first = monthly ?? yearly;
|
|
238
|
+
if (!once || !first) return null;
|
|
239
|
+
const href = offeringsBundleHref(once.lookupKey, first.lookupKey);
|
|
240
|
+
if (!href) return null;
|
|
241
|
+
const oneTime = phrase(once, locale, labels);
|
|
242
|
+
const combined = (recurring: OfferingsPrice) =>
|
|
243
|
+
labels.withRequired(oneTime, phrase(recurring, locale, labels));
|
|
244
|
+
const price: PricingPlanCard["price"] = {};
|
|
245
|
+
if (monthly && yearly) {
|
|
246
|
+
price.monthly = combined(monthly);
|
|
247
|
+
price.annual = combined(yearly);
|
|
248
|
+
} else {
|
|
249
|
+
price.custom = combined(first);
|
|
250
|
+
}
|
|
251
|
+
const card: PricingPlanCard = {
|
|
252
|
+
key: product.plan,
|
|
253
|
+
name: stripFormatCharacters(product.name),
|
|
254
|
+
price,
|
|
255
|
+
features: features(product),
|
|
256
|
+
cta: { label: labels.cta, href },
|
|
257
|
+
};
|
|
258
|
+
// The deposit note wins the footnote; the terms note shows only without one.
|
|
259
|
+
// NO_HAND_IMPLEMENT_OK: builder-assessors run for #10030 — the builder is the implementer.
|
|
260
|
+
if (product.deposit !== undefined)
|
|
261
|
+
card.priceFootnote = labels.depositNote(formatMinorUnits(product.deposit, once.currency, locale));
|
|
262
|
+
else if (product.termsNote)
|
|
263
|
+
card.priceFootnote = stripFormatCharacters(product.termsNote);
|
|
264
|
+
const annualHref =
|
|
265
|
+
monthly && yearly && offeringsBundleHref(once.lookupKey, yearly.lookupKey);
|
|
266
|
+
if (annualHref) card.annualCtaHref = annualHref;
|
|
267
|
+
if (highlighted) {
|
|
268
|
+
card.highlighted = true;
|
|
269
|
+
card.highlightStyle = "pill";
|
|
270
|
+
card.highlightLabel = labels.recommended;
|
|
271
|
+
}
|
|
272
|
+
return card;
|
|
273
|
+
}
|
|
274
|
+
|
|
197
275
|
function planCard(
|
|
198
276
|
product: OfferingsProduct,
|
|
199
277
|
highlighted: boolean,
|
|
@@ -227,6 +305,10 @@ function planCard(
|
|
|
227
305
|
if (product.note) card.priceSubhead = stripFormatCharacters(product.note);
|
|
228
306
|
if (product.termsNote)
|
|
229
307
|
card.priceFootnote = stripFormatCharacters(product.termsNote);
|
|
308
|
+
// A deposit plan's footnote (#10030 D10), in its one-time Price's currency;
|
|
309
|
+
// it takes the footnote over a terms note (the rarer field).
|
|
310
|
+
if (product.deposit !== undefined && once)
|
|
311
|
+
card.priceFootnote = labels.depositNote(formatMinorUnits(product.deposit, once.currency, locale));
|
|
230
312
|
// Annual mode shows the yearly Price only when a monthly one is beside it,
|
|
231
313
|
// so only then does the card need a second link (D5, D6).
|
|
232
314
|
const annualHref =
|
|
@@ -280,28 +362,75 @@ function isPresent<T>(value: T | null): value is T {
|
|
|
280
362
|
return value !== null;
|
|
281
363
|
}
|
|
282
364
|
|
|
365
|
+
/**
|
|
366
|
+
* The products whose `{plan}` is in `plans`, plus every product a kept one
|
|
367
|
+
* `requires`, in the snapshot's order (#10044 D4); `plans` absent → all.
|
|
368
|
+
* One level of `requires` is followed: a required product is sold only
|
|
369
|
+
* inside its bundle and requires nothing itself (#10028 decision 6).
|
|
370
|
+
*/
|
|
371
|
+
function narrowToPlans(
|
|
372
|
+
offerings: OfferingsProduct[],
|
|
373
|
+
plans: readonly string[] | undefined,
|
|
374
|
+
): OfferingsProduct[] {
|
|
375
|
+
if (plans === undefined) return offerings;
|
|
376
|
+
const listed = new Set(plans);
|
|
377
|
+
const kept = new Set(
|
|
378
|
+
offerings.filter((o) => listed.has(o.plan)).map((o) => o.plan),
|
|
379
|
+
);
|
|
380
|
+
for (const o of offerings) {
|
|
381
|
+
if (kept.has(o.plan) && o.requires !== undefined) kept.add(o.requires);
|
|
382
|
+
}
|
|
383
|
+
return offerings.filter((o) => kept.has(o.plan));
|
|
384
|
+
}
|
|
385
|
+
|
|
283
386
|
/**
|
|
284
387
|
* The shown set as Pricing section data, or `null` when there is nothing to
|
|
285
388
|
* show: a value the shown-set schema refuses, a snapshot that is not
|
|
286
389
|
* `current`, or no offerings. Each fragment is `null` when its group is empty.
|
|
390
|
+
*
|
|
391
|
+
* `plans` (#10044 D4) narrows the set to an offering page's own products:
|
|
392
|
+
* those whose `{plan}` it lists, plus every product a kept one `requires`, in
|
|
393
|
+
* the snapshot's order. Absent, every shown product is mapped, as before.
|
|
287
394
|
*/
|
|
288
395
|
export function offeringsPricing(
|
|
289
396
|
shownSet: unknown,
|
|
290
|
-
options: {
|
|
397
|
+
options: {
|
|
398
|
+
locale: string;
|
|
399
|
+
labels: OfferingsPricingLabels;
|
|
400
|
+
plans?: readonly string[] | undefined;
|
|
401
|
+
},
|
|
291
402
|
): OfferingsPricing | null {
|
|
292
403
|
const parsed = shownSetSchema.safeParse(shownSet);
|
|
293
404
|
if (!parsed.success) return null;
|
|
294
|
-
const { state, offerings } = parsed.data;
|
|
295
|
-
if (state !== "current" ||
|
|
405
|
+
const { state, offerings: shown } = parsed.data;
|
|
406
|
+
if (state !== "current" || shown.length === 0) return null;
|
|
296
407
|
const { locale, labels } = options;
|
|
297
408
|
|
|
298
|
-
|
|
409
|
+
// #10028 D9 — a product some shown plan requires is sold only inside that
|
|
410
|
+
// bundle (decision 6), so it gets no card of its own. Read over the whole
|
|
411
|
+
// shown set, so a page listing only that product still shows it no card.
|
|
412
|
+
const requiredPlans = new Set(
|
|
413
|
+
shown.map((o) => o.requires).filter((plan) => plan !== undefined),
|
|
414
|
+
);
|
|
415
|
+
const offerings = narrowToPlans(shown, options.plans);
|
|
416
|
+
const planProducts = offerings.filter(
|
|
417
|
+
(o) => o.ailkGroup === "plan" && !requiredPlans.has(o.plan),
|
|
418
|
+
);
|
|
299
419
|
const addonProducts = offerings.filter((o) => o.ailkGroup === "addon");
|
|
300
420
|
const recommendedPlan = firstRecommended(planProducts);
|
|
301
421
|
const recommendedAddon = firstRecommended(addonProducts);
|
|
302
422
|
|
|
303
423
|
const plans = planProducts
|
|
304
|
-
.map((p) =>
|
|
424
|
+
.map((p) => {
|
|
425
|
+
const required =
|
|
426
|
+
p.requires === undefined
|
|
427
|
+
? undefined
|
|
428
|
+
: offerings.find((o) => o.plan === p.requires);
|
|
429
|
+
if (p.requires !== undefined) {
|
|
430
|
+
return required ? bundleCard(p, required, p.plan === recommendedPlan, locale, labels) : null;
|
|
431
|
+
}
|
|
432
|
+
return planCard(p, p.plan === recommendedPlan, locale, labels);
|
|
433
|
+
})
|
|
305
434
|
.filter(isPresent);
|
|
306
435
|
const addons = addonProducts
|
|
307
436
|
.map((p) => addonTile(p, p.plan === recommendedAddon, locale, labels))
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-page chrome (#10049 D3) — which header and footer a page wears, and
|
|
3
|
+
* whether the home is a deck.
|
|
4
|
+
*
|
|
5
|
+
* Four levels, most specific first: the page's own frontmatter (`header:`,
|
|
6
|
+
* `footer:`, `layout:`), its route family's key in `_site.mdx` (`blog:`,
|
|
7
|
+
* `docs:`, …), the site defaults in `_site.mdx` (`header.style`,
|
|
8
|
+
* `footer.style`, `layout`), and last the design's own knobs in the
|
|
9
|
+
* generated `site-chrome.ts`. A site that sets nothing resolves to exactly
|
|
10
|
+
* the chrome the design gave it before this file existed.
|
|
11
|
+
*
|
|
12
|
+
* Blog posts and docs pages are served by the docs-blog slice, which this
|
|
13
|
+
* base file must never import, so they take their `blog:` / `docs:` family
|
|
14
|
+
* or the site default; their own frontmatter is not read here.
|
|
15
|
+
*
|
|
16
|
+
* `_site.mdx` is read locally rather than through
|
|
17
|
+
* `@working-theory/site-config/server`, as `site-header.ts` does (#1567).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
getMdxSource,
|
|
25
|
+
type SchemaOrgType,
|
|
26
|
+
} from "@working-theory/page-renderer";
|
|
27
|
+
import {
|
|
28
|
+
HeaderStyleSchema,
|
|
29
|
+
ROUTE_FAMILY_KEYS,
|
|
30
|
+
SiteConfigSchema,
|
|
31
|
+
type FooterStyle,
|
|
32
|
+
type HeaderStyle,
|
|
33
|
+
type RouteFamilyChrome,
|
|
34
|
+
type RouteFamilyKey,
|
|
35
|
+
type SiteLayout,
|
|
36
|
+
} from "@working-theory/site-config/schema";
|
|
37
|
+
import { ROUTES } from "@working-theory/templates/routes";
|
|
38
|
+
import { readLocales } from "@working-theory/web-i18n";
|
|
39
|
+
import { cache } from "react";
|
|
40
|
+
import { parse as parseYaml } from "yaml";
|
|
41
|
+
import type { ZodType } from "zod";
|
|
42
|
+
|
|
43
|
+
import { normalizeRoutePath } from "./closing-footer-path";
|
|
44
|
+
import {
|
|
45
|
+
isAlwaysScaffolded,
|
|
46
|
+
matchesRoute,
|
|
47
|
+
resolveTypeAndSlug,
|
|
48
|
+
} from "./deck-page";
|
|
49
|
+
import { toInternalDocsPath } from "./docs-path";
|
|
50
|
+
import { SERVICES_ROUTE, toInternalServicesPath } from "./services-path";
|
|
51
|
+
import { resolveContentRoot } from "./site-brand";
|
|
52
|
+
|
|
53
|
+
/** The chrome settings a site sets in `_site.mdx`. */
|
|
54
|
+
export interface SiteChromeSettings {
|
|
55
|
+
headerStyle?: HeaderStyle;
|
|
56
|
+
footerStyle?: FooterStyle;
|
|
57
|
+
layout?: SiteLayout;
|
|
58
|
+
families: Partial<Record<RouteFamilyKey, RouteFamilyChrome>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The chrome fields a page's own frontmatter sets. */
|
|
62
|
+
export interface PageChromeFields {
|
|
63
|
+
header?: HeaderStyle;
|
|
64
|
+
footer?: FooterStyle;
|
|
65
|
+
layout?: SiteLayout;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The design knobs the template level reads — a structural slice of the
|
|
70
|
+
* generated `SiteChrome`, so a scaffold generated before `innerPages` or
|
|
71
|
+
* `chromeStyle` existed still compiles.
|
|
72
|
+
*/
|
|
73
|
+
export interface ChromeKnobs {
|
|
74
|
+
home: SiteLayout;
|
|
75
|
+
footer: "columns" | "centered";
|
|
76
|
+
innerPages?: "bare";
|
|
77
|
+
chromeStyle?: "minimal";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
81
|
+
|
|
82
|
+
const warnedPaths = new Set<string>();
|
|
83
|
+
|
|
84
|
+
/** One warning per field path per process, naming the path and the Zod message — never the value. */
|
|
85
|
+
function warnOnce(path: string, message: string): void {
|
|
86
|
+
if (warnedPaths.has(path)) return;
|
|
87
|
+
warnedPaths.add(path);
|
|
88
|
+
console.warn(`[page-chrome] ignoring _site.mdx ${path}: ${message}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `value` parsed by `schema`, else `undefined` with a warning under `path`. */
|
|
92
|
+
function validated<T>(
|
|
93
|
+
schema: ZodType<T>,
|
|
94
|
+
value: unknown,
|
|
95
|
+
path: string,
|
|
96
|
+
): T | undefined {
|
|
97
|
+
if (value === undefined) return undefined;
|
|
98
|
+
const parsed = schema.safeParse(value);
|
|
99
|
+
if (parsed.success) return parsed.data;
|
|
100
|
+
const issue = parsed.error.issues[0];
|
|
101
|
+
warnOnce(
|
|
102
|
+
[path, ...(issue?.path ?? [])].join("."),
|
|
103
|
+
issue?.message ?? "invalid",
|
|
104
|
+
);
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `_site.mdx`'s chrome keys. Each key validates on its own, so one bad key
|
|
110
|
+
* drops only itself; an absent file, frontmatter or key is silent.
|
|
111
|
+
*/
|
|
112
|
+
export const readSiteChromeSettings = cache(
|
|
113
|
+
function readSiteChromeSettings(): SiteChromeSettings {
|
|
114
|
+
try {
|
|
115
|
+
const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
|
|
116
|
+
const match = FRONTMATTER_RE.exec(raw);
|
|
117
|
+
if (!match) return { families: {} };
|
|
118
|
+
const fm: unknown = parseYaml(match[1] ?? "");
|
|
119
|
+
if (!fm || typeof fm !== "object") return { families: {} };
|
|
120
|
+
const record = fm as Record<string, unknown>;
|
|
121
|
+
|
|
122
|
+
const settings: SiteChromeSettings = { families: {} };
|
|
123
|
+
const header = record["header"];
|
|
124
|
+
const headerStyle =
|
|
125
|
+
header && typeof header === "object"
|
|
126
|
+
? validated(
|
|
127
|
+
HeaderStyleSchema,
|
|
128
|
+
(header as Record<string, unknown>)["style"],
|
|
129
|
+
"header.style",
|
|
130
|
+
)
|
|
131
|
+
: undefined;
|
|
132
|
+
if (headerStyle !== undefined) settings.headerStyle = headerStyle;
|
|
133
|
+
const footerStyle = validated(
|
|
134
|
+
SiteConfigSchema.shape.footer,
|
|
135
|
+
record["footer"],
|
|
136
|
+
"footer",
|
|
137
|
+
)?.style;
|
|
138
|
+
if (footerStyle !== undefined) settings.footerStyle = footerStyle;
|
|
139
|
+
const layout = validated(
|
|
140
|
+
SiteConfigSchema.shape.layout,
|
|
141
|
+
record["layout"],
|
|
142
|
+
"layout",
|
|
143
|
+
);
|
|
144
|
+
if (layout !== undefined) settings.layout = layout;
|
|
145
|
+
for (const key of ROUTE_FAMILY_KEYS) {
|
|
146
|
+
const family = validated(SiteConfigSchema.shape[key], record[key], key);
|
|
147
|
+
if (family !== undefined) settings.families[key] = family;
|
|
148
|
+
}
|
|
149
|
+
return settings;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
// An absent file is silent; unreadable frontmatter is worth one warning.
|
|
152
|
+
if ((err as { code?: unknown }).code !== "ENOENT") {
|
|
153
|
+
warnOnce("frontmatter", "could not be read");
|
|
154
|
+
}
|
|
155
|
+
return { families: {} };
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
function camelCase(prefix: string): string {
|
|
161
|
+
return prefix
|
|
162
|
+
.replace(/^\//, "")
|
|
163
|
+
.replace(/-([a-z])/g, (_m, c: string) => c.toUpperCase());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Whether `segments` match a routes-map pattern, `:...slug` taking one or more segments. */
|
|
167
|
+
function matchesPattern(pattern: string, segments: readonly string[]): boolean {
|
|
168
|
+
const parts = pattern.split("/").filter((part) => part.length > 0);
|
|
169
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
170
|
+
const part = parts[i] ?? "";
|
|
171
|
+
if (part.startsWith(":...")) return segments.length > i;
|
|
172
|
+
if (i >= segments.length) return false;
|
|
173
|
+
if (!part.startsWith(":") && part !== segments[i]) return false;
|
|
174
|
+
}
|
|
175
|
+
return parts.length === segments.length;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The route family a pathname belongs to, else `null`: the first routes-map
|
|
180
|
+
* hub whose prefix is the path, prefixes it, or whose detail pattern matches
|
|
181
|
+
* it. A site's moved docs are mapped back onto `/docs` first, and its moved
|
|
182
|
+
* services onto `/services` (#10043 D9).
|
|
183
|
+
*/
|
|
184
|
+
export function routeFamilyOf(
|
|
185
|
+
pathname: string,
|
|
186
|
+
locales: readonly string[],
|
|
187
|
+
): RouteFamilyKey | null {
|
|
188
|
+
const bare = normalizeRoutePath(pathname, locales);
|
|
189
|
+
const path = toInternalDocsPath(bare) ?? toInternalServicesPath(bare) ?? bare;
|
|
190
|
+
const segments = path.split("/").filter((segment) => segment.length > 0);
|
|
191
|
+
for (const row of ROUTES) {
|
|
192
|
+
if (!row.hub) continue;
|
|
193
|
+
const { prefix, detailPattern } = row.hub;
|
|
194
|
+
if (
|
|
195
|
+
path === prefix ||
|
|
196
|
+
path.startsWith(`${prefix}/`) ||
|
|
197
|
+
matchesPattern(detailPattern, segments)
|
|
198
|
+
) {
|
|
199
|
+
const key = camelCase(prefix);
|
|
200
|
+
return (ROUTE_FAMILY_KEYS as readonly string[]).includes(key)
|
|
201
|
+
? (key as RouteFamilyKey)
|
|
202
|
+
: null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The MDX content pages that could serve `pathname`, in the order Next.js
|
|
210
|
+
* tries them — `deckCandidates`' walk over every content row rather than the
|
|
211
|
+
* deck routes. Blog posts and docs pages live outside `content/`, so they are
|
|
212
|
+
* never candidates.
|
|
213
|
+
*/
|
|
214
|
+
export function chromePageCandidates(
|
|
215
|
+
pathname: string,
|
|
216
|
+
locales: readonly string[],
|
|
217
|
+
): Array<{ type: SchemaOrgType; slug: string }> {
|
|
218
|
+
const normalized = normalizeRoutePath(pathname, locales);
|
|
219
|
+
// #10043 D9 — a site's moved services are mapped back onto `/services`.
|
|
220
|
+
const path = toInternalServicesPath(normalized) ?? normalized;
|
|
221
|
+
if (path === "/") return [{ type: "HomePage", slug: "home" }];
|
|
222
|
+
const segments = path.split("/").filter((segment) => segment.length > 0);
|
|
223
|
+
// A service under its category (`/services/<category>/<slug>`): the nested
|
|
224
|
+
// row names no content directory, so the walk below would never name it.
|
|
225
|
+
if (segments.length === 3 && segments[0] === SERVICES_ROUTE.slice(1)) {
|
|
226
|
+
return [{ type: "Service", slug: segments[2]! }];
|
|
227
|
+
}
|
|
228
|
+
const candidates: Array<{ type: SchemaOrgType; slug: string }> = [];
|
|
229
|
+
|
|
230
|
+
for (const row of ROUTES) {
|
|
231
|
+
if (!row.contentPath?.startsWith("content/")) continue;
|
|
232
|
+
if (!matchesRoute(row.route, segments)) continue;
|
|
233
|
+
const resolved = resolveTypeAndSlug(row, row.route, segments);
|
|
234
|
+
if (resolved) {
|
|
235
|
+
candidates.push(resolved);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (segments.length === 1 && !isAlwaysScaffolded(path)) {
|
|
241
|
+
const slug = segments[0];
|
|
242
|
+
if (slug !== undefined) {
|
|
243
|
+
candidates.push({ type: "LandingPage", slug });
|
|
244
|
+
candidates.push({ type: "BlogList", slug });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return candidates;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The page's own chrome frontmatter — the first candidate with a source —
|
|
252
|
+
* else `{}`. A read that throws answers `{}`: the page's own render surfaces
|
|
253
|
+
* the error. Per-request memoised.
|
|
254
|
+
*/
|
|
255
|
+
export const resolveChromePage = cache(async function resolveChromePage(
|
|
256
|
+
pathname: string,
|
|
257
|
+
locale: string,
|
|
258
|
+
): Promise<PageChromeFields> {
|
|
259
|
+
try {
|
|
260
|
+
for (const candidate of chromePageCandidates(pathname, readLocales())) {
|
|
261
|
+
const source = await getMdxSource(candidate.type, candidate.slug, locale);
|
|
262
|
+
if (!source) continue;
|
|
263
|
+
const { header, footer, layout } = source.frontmatter as PageChromeFields;
|
|
264
|
+
return {
|
|
265
|
+
...(header !== undefined ? { header } : {}),
|
|
266
|
+
...(footer !== undefined ? { footer } : {}),
|
|
267
|
+
...(layout !== undefined ? { layout } : {}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
return {};
|
|
272
|
+
}
|
|
273
|
+
return {};
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
/** The home's layout: its own frontmatter, else the site's, else the design's. */
|
|
277
|
+
export function resolveHomeLayout(
|
|
278
|
+
site: SiteChromeSettings,
|
|
279
|
+
home: PageChromeFields,
|
|
280
|
+
chrome: Pick<ChromeKnobs, "home">,
|
|
281
|
+
): SiteLayout {
|
|
282
|
+
return home.layout ?? site.layout ?? chrome.home;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface PageChromeInput {
|
|
286
|
+
isHome: boolean;
|
|
287
|
+
/** The resolved home layout is a deck. */
|
|
288
|
+
homeIsDeck: boolean;
|
|
289
|
+
family: RouteFamilyKey | null;
|
|
290
|
+
page: PageChromeFields;
|
|
291
|
+
site: SiteChromeSettings;
|
|
292
|
+
chrome: ChromeKnobs;
|
|
293
|
+
/** The page is listed in the design's `closingFooterPaths`. */
|
|
294
|
+
closingListed: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface ResolvedChrome {
|
|
298
|
+
header: HeaderStyle;
|
|
299
|
+
/** No page, family or site value set the header — the design's own treatment applies. */
|
|
300
|
+
headerFromTemplate: boolean;
|
|
301
|
+
/** `line` is the design's one-bar footer on a bare inner page; never a site value. */
|
|
302
|
+
footer: FooterStyle | "line";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The design's own header and footer for a page (#10049 D3's template table). */
|
|
306
|
+
function templateChrome(
|
|
307
|
+
input: PageChromeInput,
|
|
308
|
+
): Pick<ResolvedChrome, "header" | "footer"> {
|
|
309
|
+
const { chrome } = input;
|
|
310
|
+
if (input.isHome) {
|
|
311
|
+
if (input.homeIsDeck && chrome.home === "deck") {
|
|
312
|
+
return {
|
|
313
|
+
header: "bare",
|
|
314
|
+
footer: chrome.chromeStyle === "minimal" ? "closing" : chrome.footer,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return { header: "full", footer: chrome.footer };
|
|
318
|
+
}
|
|
319
|
+
if (input.closingListed) {
|
|
320
|
+
return {
|
|
321
|
+
header: chrome.innerPages === "bare" ? "bare" : "full",
|
|
322
|
+
footer: "closing",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (chrome.innerPages === "bare") return { header: "bare", footer: "line" };
|
|
326
|
+
return { header: "full", footer: chrome.footer };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The home's layout and chrome — the one resolution both the layout and the
|
|
331
|
+
* home page read, so the bar and the landing can never disagree.
|
|
332
|
+
*/
|
|
333
|
+
export async function resolveHomeChrome(
|
|
334
|
+
locale: string,
|
|
335
|
+
chrome: ChromeKnobs,
|
|
336
|
+
): Promise<{ layout: SiteLayout; chrome: ResolvedChrome; site: SiteChromeSettings }> {
|
|
337
|
+
const site = readSiteChromeSettings();
|
|
338
|
+
const page = await resolveChromePage("/", locale);
|
|
339
|
+
const layout = resolveHomeLayout(site, page, chrome);
|
|
340
|
+
return {
|
|
341
|
+
layout,
|
|
342
|
+
site,
|
|
343
|
+
chrome: resolvePageChrome({
|
|
344
|
+
isHome: true,
|
|
345
|
+
homeIsDeck: layout === "deck",
|
|
346
|
+
family: null,
|
|
347
|
+
page,
|
|
348
|
+
site,
|
|
349
|
+
chrome,
|
|
350
|
+
closingListed: false,
|
|
351
|
+
}),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Each axis takes the first value set: page → route family → site → design. */
|
|
356
|
+
export function resolvePageChrome(input: PageChromeInput): ResolvedChrome {
|
|
357
|
+
const template = templateChrome(input);
|
|
358
|
+
const family =
|
|
359
|
+
input.family !== null ? input.site.families[input.family] : undefined;
|
|
360
|
+
const header = input.page.header ?? family?.header ?? input.site.headerStyle;
|
|
361
|
+
const footer = input.page.footer ?? family?.footer ?? input.site.footerStyle;
|
|
362
|
+
return {
|
|
363
|
+
header: header ?? template.header,
|
|
364
|
+
headerFromTemplate: header === undefined,
|
|
365
|
+
footer: footer ?? template.footer,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
@@ -5,13 +5,35 @@
|
|
|
5
5
|
* is `/careers/<slug>`'s. In `lib/` (#8849 D9), not a route directory, so
|
|
6
6
|
* every scaffold carries it: the `signal-collectors` walk and the Free apply
|
|
7
7
|
* page (`careers/[slug]/[action]`) both read it.
|
|
8
|
+
*
|
|
9
|
+
* #10054 D2 — both pages read the posting through `postingForApply`, which
|
|
10
|
+
* also returns the magnet the posting names (`applicationFlow`, checked as a
|
|
11
|
+
* flow slug by the frontmatter schema); one read serves both.
|
|
8
12
|
*/
|
|
9
13
|
import { getMdxSource } from "@working-theory/page-renderer";
|
|
10
14
|
|
|
11
|
-
/**
|
|
12
|
-
export
|
|
15
|
+
/** What an apply page needs from its posting. */
|
|
16
|
+
export interface ApplyPosting {
|
|
17
|
+
title: string;
|
|
18
|
+
/** The magnet this posting's apply page runs; absent, the Job application. */
|
|
19
|
+
applicationFlow?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The posting in `locale`, or `null` when no posting has this slug or it has no title. */
|
|
23
|
+
export async function postingForApply(slug: string, locale: string): Promise<ApplyPosting | null> {
|
|
13
24
|
const source = await getMdxSource("JobPosting", slug, locale);
|
|
14
25
|
if (source === null) return null;
|
|
15
|
-
const title =
|
|
16
|
-
|
|
26
|
+
const { title, applicationFlow } = source.frontmatter as {
|
|
27
|
+
title?: unknown;
|
|
28
|
+
applicationFlow?: unknown;
|
|
29
|
+
};
|
|
30
|
+
if (typeof title !== "string" || title.trim() === "") return null;
|
|
31
|
+
return typeof applicationFlow === "string" && applicationFlow !== ""
|
|
32
|
+
? { title, applicationFlow }
|
|
33
|
+
: { title };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The posting's title in `locale`, or `null` when no posting has this slug. */
|
|
37
|
+
export async function postingTitle(slug: string, locale: string): Promise<string | null> {
|
|
38
|
+
return (await postingForApply(slug, locale))?.title ?? null;
|
|
17
39
|
}
|