create-ailk 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +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/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +38 -4
- 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 +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +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 +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +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/versions.json +48 -48
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The services listing (#10043 D8) — a service's public URL under the site's
|
|
3
|
+
* services base and its category, the list a category page shows of its
|
|
4
|
+
* services, and the hub's own section links re-pointed onto the base. Pure,
|
|
5
|
+
* no I/O: the routes load the pages and the translated labels and pass them
|
|
6
|
+
* in, so no user-facing copy lives here (customer-facing-i18n, ADR 0019).
|
|
7
|
+
*
|
|
8
|
+
* Base tree: no paid import, no `oss: false`.
|
|
9
|
+
*/
|
|
10
|
+
import { pagePath } from "@working-theory/templates/routes";
|
|
11
|
+
import type { PageSection } from "@working-theory/validation";
|
|
12
|
+
|
|
13
|
+
import type { DetailPage } from "./route-family";
|
|
14
|
+
import {
|
|
15
|
+
SERVICES_PATH,
|
|
16
|
+
SERVICES_ROUTE,
|
|
17
|
+
toPublicServicesHref,
|
|
18
|
+
} from "./services-path";
|
|
19
|
+
|
|
20
|
+
type ItemsSection = Extract<PageSection, { type: "items" }>;
|
|
21
|
+
|
|
22
|
+
/** The `services` messages namespace labels a category's list reads (#10043 D11). */
|
|
23
|
+
export interface ServiceListingLabels {
|
|
24
|
+
childrenHeading: string;
|
|
25
|
+
viewService: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A service's public path: `<base>[/<category>]/<slug>`. */
|
|
29
|
+
export function servicePublicPath(slug: string, parent?: string): string {
|
|
30
|
+
return pagePath({ type: "Service", slug, parent }, SERVICES_PATH);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The services whose `parent` is `category`, from a slug-sorted page list. */
|
|
34
|
+
export function childServices(
|
|
35
|
+
pages: DetailPage[],
|
|
36
|
+
category: string,
|
|
37
|
+
): DetailPage[] {
|
|
38
|
+
return pages.filter((page) => page.frontmatter.parent === category);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The list a category page renders after its body: one card per child service. */
|
|
42
|
+
export function serviceChildrenSection(
|
|
43
|
+
children: DetailPage[],
|
|
44
|
+
labels: ServiceListingLabels,
|
|
45
|
+
): ItemsSection {
|
|
46
|
+
const text = (value: unknown): string | undefined =>
|
|
47
|
+
typeof value === "string" && value.length > 0 ? value : undefined;
|
|
48
|
+
return {
|
|
49
|
+
type: "items",
|
|
50
|
+
id: "service-children",
|
|
51
|
+
data: {
|
|
52
|
+
headline: labels.childrenHeading,
|
|
53
|
+
items: children.map((child) => ({
|
|
54
|
+
title: text(child.frontmatter.title) ?? child.slug,
|
|
55
|
+
description:
|
|
56
|
+
text(child.frontmatter.summary) ??
|
|
57
|
+
text(child.frontmatter.description) ??
|
|
58
|
+
"",
|
|
59
|
+
link: {
|
|
60
|
+
label: labels.viewService,
|
|
61
|
+
href: servicePublicPath(child.slug, text(child.frontmatter.parent)),
|
|
62
|
+
},
|
|
63
|
+
})),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mapHrefs(value: unknown): unknown {
|
|
69
|
+
if (Array.isArray(value)) return value.map(mapHrefs);
|
|
70
|
+
if (value === null || typeof value !== "object") return value;
|
|
71
|
+
return Object.fromEntries(
|
|
72
|
+
Object.entries(value).map(([key, entry]) => [
|
|
73
|
+
key,
|
|
74
|
+
key === "href" && typeof entry === "string"
|
|
75
|
+
? toPublicServicesHref(entry)
|
|
76
|
+
: mapHrefs(entry),
|
|
77
|
+
]),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The hub's sections with every site `href` starting `/services` re-pointed
|
|
83
|
+
* onto the base, at any depth; the same array on a site with no
|
|
84
|
+
* `services.path`.
|
|
85
|
+
*/
|
|
86
|
+
export function withPublicServicesSections<S>(sections: S[]): S[] {
|
|
87
|
+
if (SERVICES_PATH === SERVICES_ROUTE) return sections;
|
|
88
|
+
return sections.map((section) => mapHrefs(section) as S);
|
|
89
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// services-path.ts — where the services live (#10043 D5).
|
|
2
|
+
//
|
|
3
|
+
// A site sets `services: { path: /capabilities }` in `content/_site.mdx`;
|
|
4
|
+
// `next.config.mjs` parses it at build and bakes it into
|
|
5
|
+
// `NEXT_PUBLIC_AILK_SERVICES_PATH`. The services routes never move — they stay
|
|
6
|
+
// at `app/[locale]/services/` (`SERVICES_ROUTE`) — and the middleware maps the
|
|
7
|
+
// public path onto them and answers the old `/services` URLs with a 308. Every
|
|
8
|
+
// surface that names a services URL reads `SERVICES_PATH` here.
|
|
9
|
+
//
|
|
10
|
+
// Edge-safe and client-safe: this file imports nothing at runtime (the
|
|
11
|
+
// `NavItem` import is type-only), so the middleware and the client signed-in
|
|
12
|
+
// nav can both read it. The shape is `docs-path.ts`'s (#10041).
|
|
13
|
+
import type { NavItem } from "@working-theory/ui";
|
|
14
|
+
|
|
15
|
+
/** The internal services route — the directory under `app/[locale]/`. Always `/services`. */
|
|
16
|
+
export const SERVICES_ROUTE = "/services";
|
|
17
|
+
|
|
18
|
+
/** The services' public base: the site's `services.path`, or `/services` when it sets none. */
|
|
19
|
+
export const SERVICES_PATH: string =
|
|
20
|
+
process.env.NEXT_PUBLIC_AILK_SERVICES_PATH || SERVICES_ROUTE;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `href` at the internal services route — `/services`, or starting
|
|
24
|
+
* `/services/`, `/services?` or `/services#` — with that prefix replaced by
|
|
25
|
+
* `SERVICES_PATH`. Anything else (absolute URLs, `/servicesx`, a
|
|
26
|
+
* locale-prefixed path) is returned unchanged. The identity on a site with no
|
|
27
|
+
* `services.path`.
|
|
28
|
+
*/
|
|
29
|
+
export function toPublicServicesHref(href: string): string {
|
|
30
|
+
if (SERVICES_PATH === SERVICES_ROUTE) return href;
|
|
31
|
+
const rest = href.slice(SERVICES_ROUTE.length);
|
|
32
|
+
if (href.startsWith(SERVICES_ROUTE) && (rest === "" || /^[/?#]/.test(rest))) {
|
|
33
|
+
return SERVICES_PATH + rest;
|
|
34
|
+
}
|
|
35
|
+
return href;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A locale-less pathname at or under `SERVICES_PATH`, mapped onto the internal
|
|
40
|
+
* route (`/services` + the rest); `null` for anything else, and always `null`
|
|
41
|
+
* on a site with no `services.path`.
|
|
42
|
+
*/
|
|
43
|
+
export function toInternalServicesPath(bare: string): string | null {
|
|
44
|
+
if (SERVICES_PATH === SERVICES_ROUTE) return null;
|
|
45
|
+
if (bare === SERVICES_PATH) return SERVICES_ROUTE;
|
|
46
|
+
if (bare.startsWith(`${SERVICES_PATH}/`)) {
|
|
47
|
+
return SERVICES_ROUTE + bare.slice(SERVICES_PATH.length);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type NavFlyout = NonNullable<NavItem["flyout"]>;
|
|
53
|
+
|
|
54
|
+
function mapPath<T extends { path: string }>(entry: T): T {
|
|
55
|
+
return { ...entry, path: toPublicServicesHref(entry.path) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mapFlyout(flyout: NavFlyout): NavFlyout {
|
|
59
|
+
return {
|
|
60
|
+
...flyout,
|
|
61
|
+
features: flyout.features.map(mapPath),
|
|
62
|
+
...(flyout.groups
|
|
63
|
+
? {
|
|
64
|
+
groups: flyout.groups.map((group) => ({
|
|
65
|
+
...group,
|
|
66
|
+
items: group.items.map(mapPath),
|
|
67
|
+
})),
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
actions: flyout.actions.map(mapPath),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Copies of the nav items with every services path mapped onto
|
|
76
|
+
* `SERVICES_PATH` — the item's own `path` and each flyout feature, group item
|
|
77
|
+
* and action. Every other field is carried by reference.
|
|
78
|
+
*/
|
|
79
|
+
export function withPublicServicesPaths(items: NavItem[]): NavItem[] {
|
|
80
|
+
return items.map((item) => ({
|
|
81
|
+
...item,
|
|
82
|
+
...(item.path !== undefined
|
|
83
|
+
? { path: toPublicServicesHref(item.path) }
|
|
84
|
+
: {}),
|
|
85
|
+
...(item.flyout ? { flyout: mapFlyout(item.flyout) } : {}),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Copies of the footer columns with every item's services path mapped onto `SERVICES_PATH`. */
|
|
90
|
+
export function withPublicServicesColumns<
|
|
91
|
+
C extends { items: { path: string }[] },
|
|
92
|
+
>(columns: C[]): C[] {
|
|
93
|
+
return columns.map((column) => ({
|
|
94
|
+
...column,
|
|
95
|
+
items: column.items.map(mapPath),
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The site's header settings (#10046) — `_site.mdx`'s top-level `header`.
|
|
3
|
+
*
|
|
4
|
+
* `header.cta` replaces the signed-out Sign in / Sign up pair with the site's
|
|
5
|
+
* own button; `header.signIn` keeps Sign in beside it as a quieter text link
|
|
6
|
+
* (`link`, the default) or hides it. Signed in, the header is unchanged.
|
|
7
|
+
*
|
|
8
|
+
* A local frontmatter read rather than `@working-theory/site-config/server`,
|
|
9
|
+
* for the same reason as `app/[locale]/contact/site-contact-email.ts`:
|
|
10
|
+
* `architecture.yaml` keeps `node:fs` out of a tree that also carries edge
|
|
11
|
+
* routes (#1567). The value is validated against the canonical schema from
|
|
12
|
+
* the client-safe `/schema` entry.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
SiteConfigSchema,
|
|
20
|
+
type SiteHeader,
|
|
21
|
+
} from "@working-theory/site-config/schema";
|
|
22
|
+
import type { NavAction } from "@working-theory/ui/chrome";
|
|
23
|
+
import { parse as parseYaml } from "yaml";
|
|
24
|
+
|
|
25
|
+
import { resolveContentRoot } from "./site-brand";
|
|
26
|
+
|
|
27
|
+
/** The `labelKey` the CTA action carries; its test id is `nav-action-headerCta`. */
|
|
28
|
+
export const HEADER_CTA_LABEL_KEY = "headerCta";
|
|
29
|
+
|
|
30
|
+
type LinkAction = Extract<NavAction, { path: string }>;
|
|
31
|
+
|
|
32
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
33
|
+
|
|
34
|
+
const HeaderSchema = SiteConfigSchema.shape.header.unwrap();
|
|
35
|
+
|
|
36
|
+
let warned = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The validated `header`, else `undefined`. An absent file, frontmatter or
|
|
40
|
+
* key is silent; an invalid `header` warns once per process, naming the field
|
|
41
|
+
* path and never the value.
|
|
42
|
+
*/
|
|
43
|
+
export function readSiteHeader(): SiteHeader | undefined {
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
|
|
46
|
+
const match = FRONTMATTER_RE.exec(raw);
|
|
47
|
+
if (!match) return undefined;
|
|
48
|
+
const fm: unknown = parseYaml(match[1] ?? "");
|
|
49
|
+
if (!fm || typeof fm !== "object") return undefined;
|
|
50
|
+
|
|
51
|
+
const { header } = fm as { header?: unknown };
|
|
52
|
+
if (header === undefined) return undefined;
|
|
53
|
+
|
|
54
|
+
// #10049 — `style` is page-chrome.ts's to validate and warn about; set it
|
|
55
|
+
// aside so a bad style never drops the button.
|
|
56
|
+
const parsed = HeaderSchema.safeParse(
|
|
57
|
+
header !== null && typeof header === "object" ? { ...header, style: undefined } : header,
|
|
58
|
+
);
|
|
59
|
+
if (parsed.success) return parsed.data;
|
|
60
|
+
if (!warned) {
|
|
61
|
+
warned = true;
|
|
62
|
+
const issue = parsed.error.issues[0];
|
|
63
|
+
console.warn(
|
|
64
|
+
`[site-header] ignoring _site.mdx header: ${["header", ...(issue?.path ?? [])].join(".")} ${issue?.message ?? ""}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The signed-out header actions. No `cta` → the auth pair as given. A `cta`
|
|
75
|
+
* → Sign in restyled as a text link (unless `signIn: hidden`), then the CTA
|
|
76
|
+
* as the primary button; Sign up is dropped. Never mutates `authActions`.
|
|
77
|
+
*/
|
|
78
|
+
export function signedOutHeaderActions(
|
|
79
|
+
header: SiteHeader | undefined,
|
|
80
|
+
authActions: readonly LinkAction[],
|
|
81
|
+
): LinkAction[] {
|
|
82
|
+
const cta = header?.cta;
|
|
83
|
+
if (!cta) return [...authActions];
|
|
84
|
+
|
|
85
|
+
const signIn = authActions.find((action) => action.labelKey === "signIn");
|
|
86
|
+
const signInPart: LinkAction[] =
|
|
87
|
+
signIn && header.signIn !== "hidden"
|
|
88
|
+
? [{ ...signIn, variant: "ghost" }]
|
|
89
|
+
: [];
|
|
90
|
+
return [
|
|
91
|
+
...signInPart,
|
|
92
|
+
{ labelKey: HEADER_CTA_LABEL_KEY, path: cta.href, variant: "primary" },
|
|
93
|
+
];
|
|
94
|
+
}
|
|
@@ -13,10 +13,45 @@
|
|
|
13
13
|
// carries the submitter's address as `/sign-in?email=…` and the hub's
|
|
14
14
|
// authorize step as `login_hint=…` — personal data GA and Sentry must not get.
|
|
15
15
|
//
|
|
16
|
+
// `ref` joined by #9966 (D2, SEC-9966-7): the hub's consent page,
|
|
17
|
+
// `/account/connect?ref=…`, carries a sealed authorize continuation.
|
|
18
|
+
//
|
|
16
19
|
// Mirrored (duplicated, not shared) in packages/analytics/src/scrub.ts,
|
|
17
20
|
// since apps/* must not import @working-theory/* packages — keep both lists
|
|
18
21
|
// in sync (#5613).
|
|
19
|
-
export const TOKEN_QUERY_PARAMS = [
|
|
22
|
+
export const TOKEN_QUERY_PARAMS = [
|
|
23
|
+
"g",
|
|
24
|
+
"session_id",
|
|
25
|
+
"email",
|
|
26
|
+
"login_hint",
|
|
27
|
+
"ref",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// The same grant id rides a PATH too: `deliverables.openGrant` fetches
|
|
31
|
+
// `<hub>/v1/deliverables/<grantId>` server-side, so a sampled server trace or
|
|
32
|
+
// a fetch breadcrumb would carry it (#9162, PR #9152's security review). The
|
|
33
|
+
// id segment is replaced anywhere in the string, so a span description
|
|
34
|
+
// (`GET <url>`) is redacted too when a caller passes one. `/v1/deliverables/open/<slug>` is the ungated
|
|
35
|
+
// read; a slug is not a credential. apps/web only: the analytics mirror sees
|
|
36
|
+
// page URLs, never this server-side fetch.
|
|
37
|
+
const GRANT_PATH = /(\/v1\/deliverables\/)(?!open(?:[/?#\s]|$))[^/?#\s]+/g;
|
|
38
|
+
const GRANT_PATH_REDACTED = "$1:grantId";
|
|
39
|
+
|
|
40
|
+
// #9969 D9 (#8329) — the Stripe SDK's own request URLs carry a customer id:
|
|
41
|
+
// `customers.retrieve` in the path (`/v1/customers/cus_…`) and
|
|
42
|
+
// `subscriptions.list` in the query (`?customer=cus_…`), and server-side
|
|
43
|
+
// Sentry records those outgoing requests. A changeset once stated this as a
|
|
44
|
+
// precondition for turning server telemetry on; it now lives here, with its
|
|
45
|
+
// enforcement. Server-side only, so not mirrored into packages/analytics:
|
|
46
|
+
// GA sees page URLs, never a Stripe API URL.
|
|
47
|
+
const STRIPE_ID_QUERY_PARAMS = ["customer"];
|
|
48
|
+
const STRIPE_CUSTOMER_SEGMENT = /\/cus_[A-Za-z0-9]+(?=[/?#\s]|$)/g;
|
|
49
|
+
const STRIPE_CUSTOMER_SEGMENT_REDACTED = "/cus_redacted";
|
|
50
|
+
|
|
51
|
+
const SCRUBBED_QUERY_PARAMS = [
|
|
52
|
+
...TOKEN_QUERY_PARAMS,
|
|
53
|
+
...STRIPE_ID_QUERY_PARAMS,
|
|
54
|
+
];
|
|
20
55
|
|
|
21
56
|
// Sentry navigation breadcrumbs (`data.to`/`data.from`) are relative
|
|
22
57
|
// (`path?search`, no origin) rather than the absolute URLs GA and
|
|
@@ -29,7 +64,10 @@ function hasScheme(url: string): boolean {
|
|
|
29
64
|
return /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(url) || url.startsWith("//");
|
|
30
65
|
}
|
|
31
66
|
|
|
32
|
-
export function scrubTelemetryUrl(
|
|
67
|
+
export function scrubTelemetryUrl(input: string): string {
|
|
68
|
+
const url = input
|
|
69
|
+
.replace(GRANT_PATH, GRANT_PATH_REDACTED)
|
|
70
|
+
.replace(STRIPE_CUSTOMER_SEGMENT, STRIPE_CUSTOMER_SEGMENT_REDACTED);
|
|
33
71
|
const relative = !hasScheme(url);
|
|
34
72
|
let parsed: URL;
|
|
35
73
|
try {
|
|
@@ -39,7 +77,7 @@ export function scrubTelemetryUrl(url: string): string {
|
|
|
39
77
|
}
|
|
40
78
|
|
|
41
79
|
let changed = false;
|
|
42
|
-
for (const param of
|
|
80
|
+
for (const param of SCRUBBED_QUERY_PARAMS) {
|
|
43
81
|
if (parsed.searchParams.has(param)) {
|
|
44
82
|
parsed.searchParams.delete(param);
|
|
45
83
|
changed = true;
|
|
@@ -52,3 +90,30 @@ export function scrubTelemetryUrl(url: string): string {
|
|
|
52
90
|
const result = parsed.toString();
|
|
53
91
|
return relative ? result.slice(RELATIVE_URL_BASE.length) : result;
|
|
54
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A bare query string (`?a=1&b=2`, with or without the `?`) with the same
|
|
96
|
+
* params dropped — the SDK records an outgoing request's query under
|
|
97
|
+
* `http.query` apart from its URL (#9969 D9).
|
|
98
|
+
*/
|
|
99
|
+
export function scrubTelemetryQuery(query: string): string {
|
|
100
|
+
const params = new URLSearchParams(query);
|
|
101
|
+
let changed = false;
|
|
102
|
+
for (const param of SCRUBBED_QUERY_PARAMS) {
|
|
103
|
+
if (params.has(param)) {
|
|
104
|
+
params.delete(param);
|
|
105
|
+
changed = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!changed) return query;
|
|
109
|
+
const rest = params.toString();
|
|
110
|
+
return rest === "" ? "" : `${query.startsWith("?") ? "?" : ""}${rest}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** An HTTP client span's `<METHOD> <url>` with its URL scrubbed (#9969 D9). */
|
|
114
|
+
export function scrubSpanDescription(description: string): string {
|
|
115
|
+
const match = /^([A-Z]+) (\S+)$/.exec(description);
|
|
116
|
+
return match
|
|
117
|
+
? `${match[1]} ${scrubTelemetryUrl(match[2]!)}`
|
|
118
|
+
: scrubTelemetryUrl(description);
|
|
119
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template-listing mapping (#10051 D6) — a TemplatePage's frontmatter to
|
|
3
|
+
* the /templates hub's card and to the generated sections of its own
|
|
4
|
+
* /templates/<slug> page. Pure, no I/O: the routes load the pages and the
|
|
5
|
+
* translated labels and pass them in, so no user-facing copy lives here
|
|
6
|
+
* (customer-facing-i18n, ADR 0019).
|
|
7
|
+
*
|
|
8
|
+
* Base tree: no paid import, no `oss: false`.
|
|
9
|
+
*/
|
|
10
|
+
import { SITE_CATEGORIES } from "@working-theory/schema/categories";
|
|
11
|
+
import type { PageSection } from "@working-theory/validation";
|
|
12
|
+
|
|
13
|
+
/** The `templates` messages namespace, read by the routes (#10051 D8). */
|
|
14
|
+
export interface TemplateLabels {
|
|
15
|
+
getIt: string;
|
|
16
|
+
startFree: string;
|
|
17
|
+
viewDemo: string;
|
|
18
|
+
viewTemplate: string;
|
|
19
|
+
included: string;
|
|
20
|
+
demoBadge: string;
|
|
21
|
+
/** The tail of a category label cut at {@link MAX_CATEGORY_NAMES}: "+9 more". */
|
|
22
|
+
moreCategories: (count: number) => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Read the labels from the `templates` namespace's translator. */
|
|
26
|
+
export function templateLabels(
|
|
27
|
+
t: (key: keyof TemplateLabels, values?: { count: number }) => string,
|
|
28
|
+
): TemplateLabels {
|
|
29
|
+
return {
|
|
30
|
+
getIt: t("getIt"),
|
|
31
|
+
startFree: t("startFree"),
|
|
32
|
+
viewDemo: t("viewDemo"),
|
|
33
|
+
viewTemplate: t("viewTemplate"),
|
|
34
|
+
included: t("included"),
|
|
35
|
+
demoBadge: t("demoBadge"),
|
|
36
|
+
moreCategories: (count) => t("moreCategories", { count }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* At most this many category names show in a card or hero label; the rest
|
|
42
|
+
* collapse into "+N more", so a template listed under twelve categories keeps
|
|
43
|
+
* a one-line label and the card titles in a row stay aligned (design review
|
|
44
|
+
* round 1, B2).
|
|
45
|
+
*/
|
|
46
|
+
export const MAX_CATEGORY_NAMES = 3;
|
|
47
|
+
|
|
48
|
+
/** The TemplatePage frontmatter fields this mapping reads. */
|
|
49
|
+
interface TemplateFrontmatter {
|
|
50
|
+
title?: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
summary?: string;
|
|
53
|
+
thumbnail?: string;
|
|
54
|
+
categories?: readonly string[];
|
|
55
|
+
demoUrl?: string;
|
|
56
|
+
getItUrl?: string;
|
|
57
|
+
price?: number;
|
|
58
|
+
includes?: readonly { title: string; description: string }[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type CardItem = Extract<
|
|
62
|
+
PageSection,
|
|
63
|
+
{ type: "items" }
|
|
64
|
+
>["data"]["items"][number];
|
|
65
|
+
|
|
66
|
+
function fields(frontmatter: unknown): TemplateFrontmatter {
|
|
67
|
+
return (frontmatter ?? {}) as TemplateFrontmatter;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The known category display names, the first {@link MAX_CATEGORY_NAMES}
|
|
72
|
+
* joined and the rest counted; undefined when none is known.
|
|
73
|
+
*/
|
|
74
|
+
function categoryNames(
|
|
75
|
+
categories: readonly string[] | undefined,
|
|
76
|
+
labels: TemplateLabels,
|
|
77
|
+
): string | undefined {
|
|
78
|
+
const names = (categories ?? []).flatMap((slug) => {
|
|
79
|
+
const found = SITE_CATEGORIES.find((category) => category.slug === slug);
|
|
80
|
+
return found === undefined ? [] : [found.name];
|
|
81
|
+
});
|
|
82
|
+
if (names.length === 0) return undefined;
|
|
83
|
+
const shown = names.slice(0, MAX_CATEGORY_NAMES);
|
|
84
|
+
const hidden = names.length - shown.length;
|
|
85
|
+
return hidden > 0
|
|
86
|
+
? [...shown, labels.moreCategories(hidden)].join(" · ")
|
|
87
|
+
: shown.join(" · ");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One /templates card for a TemplatePage. */
|
|
91
|
+
export function templateCard(
|
|
92
|
+
page: { slug: string; frontmatter: unknown },
|
|
93
|
+
labels: TemplateLabels,
|
|
94
|
+
): CardItem {
|
|
95
|
+
const fm = fields(page.frontmatter);
|
|
96
|
+
const title = fm.title ?? page.slug;
|
|
97
|
+
const eyebrow = categoryNames(fm.categories, labels);
|
|
98
|
+
return {
|
|
99
|
+
title,
|
|
100
|
+
description: fm.summary ?? fm.description ?? "",
|
|
101
|
+
...(fm.thumbnail !== undefined && {
|
|
102
|
+
image: { src: fm.thumbnail, alt: title },
|
|
103
|
+
}),
|
|
104
|
+
...(eyebrow !== undefined && { eyebrow }),
|
|
105
|
+
...(fm.demoUrl !== undefined && { badge: labels.demoBadge }),
|
|
106
|
+
link: { label: labels.viewTemplate, href: `/templates/${page.slug}` },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** "Start free" for a template priced at exactly 0, else "Get it". */
|
|
111
|
+
export function templateActionLabel(
|
|
112
|
+
price: number | undefined,
|
|
113
|
+
labels: TemplateLabels,
|
|
114
|
+
): string {
|
|
115
|
+
return price === 0 ? labels.startFree : labels.getIt;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The generated top of a /templates/<slug> page: a hero carrying the name,
|
|
120
|
+
* summary, thumbnail, categories, the Get it / Start free action and the
|
|
121
|
+
* View demo link, then the "What's included" list.
|
|
122
|
+
*/
|
|
123
|
+
export function templateDetailSections(
|
|
124
|
+
frontmatter: unknown,
|
|
125
|
+
labels: TemplateLabels,
|
|
126
|
+
): [PageSection, PageSection] {
|
|
127
|
+
const fm = fields(frontmatter);
|
|
128
|
+
const title = fm.title ?? "";
|
|
129
|
+
const subheadline = fm.summary ?? fm.description;
|
|
130
|
+
const eyebrow = categoryNames(fm.categories, labels);
|
|
131
|
+
const hero: PageSection = {
|
|
132
|
+
type: "hero",
|
|
133
|
+
id: "template-hero",
|
|
134
|
+
data: {
|
|
135
|
+
headline: title,
|
|
136
|
+
...(subheadline !== undefined && { subheadline }),
|
|
137
|
+
...(eyebrow !== undefined && { eyebrow }),
|
|
138
|
+
...(fm.thumbnail !== undefined && {
|
|
139
|
+
image: { src: fm.thumbnail, alt: title },
|
|
140
|
+
}),
|
|
141
|
+
...(fm.getItUrl !== undefined && {
|
|
142
|
+
primaryCta: {
|
|
143
|
+
label: templateActionLabel(fm.price, labels),
|
|
144
|
+
href: fm.getItUrl,
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
...(fm.demoUrl !== undefined && {
|
|
148
|
+
secondaryCta: {
|
|
149
|
+
label: labels.viewDemo,
|
|
150
|
+
href: fm.demoUrl,
|
|
151
|
+
variant: "outline" as const,
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
const included: PageSection = {
|
|
157
|
+
type: "items",
|
|
158
|
+
id: "template-included",
|
|
159
|
+
data: {
|
|
160
|
+
headline: labels.included,
|
|
161
|
+
variant: "minimal",
|
|
162
|
+
items: (fm.includes ?? []).map(({ title: rowTitle, description }) => ({
|
|
163
|
+
title: rowTitle,
|
|
164
|
+
description,
|
|
165
|
+
})),
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
return [hero, included];
|
|
169
|
+
}
|