create-ailk 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -9
- package/dist/add-module-core.js +2 -1
- package/dist/add-module.d.ts +2 -1
- package/dist/add-module.js +9 -4
- package/dist/add-route.js +46 -19
- package/dist/body-heading.d.ts +56 -0
- package/dist/body-heading.js +394 -0
- package/dist/cli.js +28 -1
- package/dist/content-payloads.js +2 -2
- package/dist/derive-architecture.js +8 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/lib/auth-secret-file.d.ts +6 -2
- package/dist/lib/auth-secret-file.js +46 -20
- package/dist/lib/env-catalog.js +20 -0
- package/dist/lib/site-modules.d.ts +8 -0
- package/dist/lib/site-modules.js +34 -0
- package/dist/module-architecture.js +25 -14
- package/dist/paid-sections.d.ts +17 -0
- package/dist/paid-sections.js +39 -0
- package/dist/programmatic.d.ts +16 -0
- package/dist/programmatic.js +73 -4
- package/dist/remove-module.d.ts +98 -0
- package/dist/remove-module.js +620 -0
- package/dist/seed-flows.d.ts +2 -2
- package/dist/seed-flows.js +16 -9
- package/dist/site-roster.js +4 -1
- package/dist/starter-grid.d.ts +26 -0
- package/dist/starter-grid.js +34 -0
- package/dist/surfaces.d.ts +26 -0
- package/dist/surfaces.js +112 -11
- package/dist/sync-routes.d.ts +7 -0
- package/dist/sync-routes.js +53 -6
- package/dist/template-pages.d.ts +36 -0
- package/dist/template-pages.js +129 -0
- package/dist/topology.d.ts +2 -1
- package/dist/topology.js +4 -2
- package/dist/update.d.ts +3 -1
- package/dist/update.js +90 -6
- package/package.json +3 -3
- package/release-scope.json +2 -1
- package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
- package/templates/.env.example +12 -2
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +16 -5
- package/templates/apps/api/CLAUDE.md +32 -11
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
- package/templates/apps/api/src/vercel-handler.ts +8 -1
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +18 -10
- package/templates/apps/web/.env.example +27 -0
- package/templates/apps/web/CLAUDE.md +56 -8
- package/templates/apps/web/__tests__/setup.ts +7 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
- package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
- package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
- package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
- package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
- package/templates/apps/web/app/[locale]/layout.tsx +161 -128
- package/templates/apps/web/app/[locale]/page.tsx +51 -3
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
- package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
- package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
- package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
- package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
- package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
- package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
- package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
- package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
- package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
- package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
- package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
- package/templates/apps/web/app/llms.txt/route.ts +41 -3
- package/templates/apps/web/app/robots.txt/route.ts +5 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
- package/templates/apps/web/auth.ts +102 -50
- package/templates/apps/web/components/signed-in-nav.tsx +8 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
- package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
- package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
- package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
- package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
- package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
- package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
- package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
- package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
- package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
- package/templates/apps/web/lib/auth-secret.ts +57 -0
- package/templates/apps/web/lib/bot-check.ts +138 -0
- package/templates/apps/web/lib/consent-visitor.ts +81 -7
- package/templates/apps/web/lib/deck-page.ts +6 -3
- package/templates/apps/web/lib/docs-index.ts +100 -0
- package/templates/apps/web/lib/docs-path.ts +94 -0
- package/templates/apps/web/lib/hub-children.tsx +318 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/nav-label-check.ts +61 -0
- package/templates/apps/web/lib/nav-label-keys.ts +78 -0
- package/templates/apps/web/lib/newsletter.actions.ts +31 -6
- package/templates/apps/web/lib/offerings-pricing.ts +135 -6
- package/templates/apps/web/lib/page-chrome.ts +367 -0
- package/templates/apps/web/lib/posting-title.ts +26 -4
- package/templates/apps/web/lib/pricing-page.ts +430 -0
- package/templates/apps/web/lib/relay.ts +44 -4
- package/templates/apps/web/lib/route-family.ts +69 -0
- package/templates/apps/web/lib/service-listing.ts +89 -0
- package/templates/apps/web/lib/services-path.ts +97 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
- package/templates/apps/web/lib/site-header.ts +94 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
- package/templates/apps/web/lib/template-listing.ts +169 -0
- package/templates/apps/web/messages/en.json +144 -3
- package/templates/apps/web/middleware.ts +179 -12
- package/templates/apps/web/next.config.mjs +220 -3
- package/templates/apps/web/package.json +3 -2
- package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
- package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
- package/templates/apps/web/sentry.server.config.ts +30 -2
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
- package/templates/apps/web/src/lib/rate-limit.ts +68 -27
- package/templates/apps/web/src/lib/routes.ts +15 -1
- package/versions.json +48 -48
|
@@ -19,6 +19,29 @@
|
|
|
19
19
|
// here rather than in the matcher, because this middleware is the only place
|
|
20
20
|
// the security headers are stamped.
|
|
21
21
|
//
|
|
22
|
+
// The spoke file routes (#9162) — `/reports/<slug>/file`,
|
|
23
|
+
// `/account/inbox/message/<id>/file` and
|
|
24
|
+
// `/account/artifacts/<pieceId>/versions/<version>/file` — get every baseline
|
|
25
|
+
// header but the CSP. Their handlers set the hub's sandbox policy, and this
|
|
26
|
+
// middleware runs before them: Next puts its headers on the response first and
|
|
27
|
+
// adds a handler header only where none is set yet, so a site CSP stamped here
|
|
28
|
+
// would replace the sandbox one on the wire.
|
|
29
|
+
//
|
|
30
|
+
// Docs path (#10041 D3). A site that sets `docsPath` in `_site.mdx` serves
|
|
31
|
+
// its docs there: a request at or under the public path (bare or after an
|
|
32
|
+
// enabled-locale prefix) is handed to next-intl as the internal `/docs` route,
|
|
33
|
+
// so its rewrite lands on `app/[locale]/docs/` while the browser keeps the
|
|
34
|
+
// public URL; a redirect's `Location` and every `Link` hreflang entry are
|
|
35
|
+
// mapped back onto the public path. The internal `/docs` itself answers with
|
|
36
|
+
// the site's 404, before next-intl, so no locale redirect intervenes. A site
|
|
37
|
+
// with no `docsPath` takes none of these branches.
|
|
38
|
+
//
|
|
39
|
+
// Services path (#10043 D5). The same mapping for a site that sets
|
|
40
|
+
// `services.path` in `_site.mdx`, except that the internal `/services` (and
|
|
41
|
+
// everything under it) answers a 308 onto the same rest under the public
|
|
42
|
+
// base, so old links keep working. A site with no `services.path` takes none
|
|
43
|
+
// of these branches.
|
|
44
|
+
//
|
|
22
45
|
// S16 spec: docs/internal/specs/ailk-pre-oss-security-phase-3-s16-security-headers-spec.md
|
|
23
46
|
// S2 spec: docs/internal/specs/i18n-phase1-s2-next-intl-foundation-spec.md
|
|
24
47
|
// P2 S1 spec: docs/internal/specs/i18n-phase2-s1-hreflang-metadata-spec.md §3
|
|
@@ -27,7 +50,19 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
27
50
|
import createMiddleware from "next-intl/middleware";
|
|
28
51
|
|
|
29
52
|
import { isBookingPath } from "./lib/auth-routes";
|
|
30
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
DOCS_PATH,
|
|
55
|
+
DOCS_ROUTE,
|
|
56
|
+
toInternalDocsPath,
|
|
57
|
+
toPublicDocsHref,
|
|
58
|
+
} from "./lib/docs-path";
|
|
59
|
+
import { PUBLIC_PAGES, SCHEDULING_PAGES } from "./lib/modules.generated";
|
|
60
|
+
import {
|
|
61
|
+
SERVICES_PATH,
|
|
62
|
+
SERVICES_ROUTE,
|
|
63
|
+
toInternalServicesPath,
|
|
64
|
+
toPublicServicesHref,
|
|
65
|
+
} from "./lib/services-path";
|
|
31
66
|
import {
|
|
32
67
|
DEFAULT_SECURITY_HEADERS,
|
|
33
68
|
SCHEDULING_SECURITY_HEADERS,
|
|
@@ -70,6 +105,76 @@ export function isNonLocaleRoute(pathname: string): boolean {
|
|
|
70
105
|
);
|
|
71
106
|
}
|
|
72
107
|
|
|
108
|
+
// The file routes' paths after an enabled-locale prefix; a route group adds no
|
|
109
|
+
// segment, so `(authed)` is not in them. Kept in step with every route handler
|
|
110
|
+
// that sets its own CSP by the drift test in
|
|
111
|
+
// __tests__/middleware-file-routes.test.ts.
|
|
112
|
+
const FILE_ROUTE_PATHS = [
|
|
113
|
+
/^\/reports\/[^/]+\/file$/,
|
|
114
|
+
/^\/account\/inbox\/message\/[^/]+\/file$/,
|
|
115
|
+
/^\/account\/artifacts\/[^/]+\/versions\/[^/]+\/file$/,
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const LOCALE_PREFIX = new RegExp(`^/(${enabledLocales.join("|")})(?=/)`);
|
|
119
|
+
|
|
120
|
+
export function isFileRoutePath(pathname: string): boolean {
|
|
121
|
+
const bare = pathname.replace(LOCALE_PREFIX, "");
|
|
122
|
+
return FILE_ROUTE_PATHS.some((path) => path.test(bare));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The path the hidden docs route is rewritten to. Its first segment is no
|
|
126
|
+
// enabled locale, so the locale layout's `assertEnabledLocale` answers it with
|
|
127
|
+
// the site's not-found page (#10041 D3).
|
|
128
|
+
const DOCS_UNMOUNTED_PATH = "/_ailk-docs-unmounted";
|
|
129
|
+
|
|
130
|
+
function splitLocalePrefix(pathname: string): { prefix: string; bare: string } {
|
|
131
|
+
const prefix = LOCALE_PREFIX.exec(pathname)?.[0] ?? "";
|
|
132
|
+
return { prefix, bare: pathname.slice(prefix.length) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `url` (absolute) with its locale-less pathname mapped back onto a public
|
|
137
|
+
* path by `toPublicHref` — `toPublicDocsHref` or `toPublicServicesHref`.
|
|
138
|
+
*/
|
|
139
|
+
function toPublicUrl(url: string, toPublicHref: (bare: string) => string): string {
|
|
140
|
+
const parsed = new URL(url);
|
|
141
|
+
const { prefix, bare } = splitLocalePrefix(parsed.pathname);
|
|
142
|
+
parsed.pathname = prefix + toPublicHref(bare);
|
|
143
|
+
return parsed.toString();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* next-intl's response for a mapped docs or services request, with the
|
|
148
|
+
* internal path it was handed mapped back onto the public one: a redirect's
|
|
149
|
+
* `Location` and each `<url>` of the hreflang `Link` header. A rewrite is
|
|
150
|
+
* left as is.
|
|
151
|
+
*/
|
|
152
|
+
function withPublicUrls(
|
|
153
|
+
response: NextResponse,
|
|
154
|
+
toPublicHref: (bare: string) => string,
|
|
155
|
+
): NextResponse {
|
|
156
|
+
const location = response.headers.get("location");
|
|
157
|
+
if (location) response.headers.set("location", toPublicUrl(location, toPublicHref));
|
|
158
|
+
const link = response.headers.get("Link");
|
|
159
|
+
if (link) {
|
|
160
|
+
response.headers.set(
|
|
161
|
+
"Link",
|
|
162
|
+
link.replace(/<([^>]+)>/g, (_entry, url: string) => `<${toPublicUrl(url, toPublicHref)}>`),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return response;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The baseline map without its CSP, for the file routes. */
|
|
169
|
+
const FILE_ROUTE_SECURITY_HEADERS: Readonly<Record<string, string>> =
|
|
170
|
+
Object.freeze(
|
|
171
|
+
Object.fromEntries(
|
|
172
|
+
Object.entries(DEFAULT_SECURITY_HEADERS).filter(
|
|
173
|
+
([name]) => name !== "Content-Security-Policy",
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
|
|
73
178
|
export function middleware(request: NextRequest): NextResponse {
|
|
74
179
|
// Step 1: Forward x-pathname to server components so generateMetadata can
|
|
75
180
|
// build path-aware hreflang alternates (P2 S1). Clone the request headers
|
|
@@ -82,27 +187,82 @@ export function middleware(request: NextRequest): NextResponse {
|
|
|
82
187
|
if (isNonLocaleRoute(request.nextUrl.pathname)) {
|
|
83
188
|
const res = NextResponse.next({ request: { headers: requestHeaders } });
|
|
84
189
|
applyHeaders(res.headers, DEFAULT_SECURITY_HEADERS);
|
|
190
|
+
if (!PUBLIC_PAGES) res.headers.set("X-Robots-Tag", "noindex");
|
|
85
191
|
return res;
|
|
86
192
|
}
|
|
87
193
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
194
|
+
// #10041 D3 — on a site that moved its docs, map the public docs path onto
|
|
195
|
+
// the internal route, and answer the internal route itself with the 404.
|
|
196
|
+
let docsInternalUrl: URL | null = null;
|
|
197
|
+
if (DOCS_PATH !== DOCS_ROUTE) {
|
|
198
|
+
const { prefix, bare } = splitLocalePrefix(request.nextUrl.pathname);
|
|
199
|
+
const internal = toInternalDocsPath(bare);
|
|
200
|
+
if (internal !== null) {
|
|
201
|
+
docsInternalUrl = request.nextUrl.clone();
|
|
202
|
+
docsInternalUrl.pathname = prefix + internal;
|
|
203
|
+
} else if (bare === DOCS_ROUTE || bare.startsWith(`${DOCS_ROUTE}/`)) {
|
|
204
|
+
const res = NextResponse.rewrite(new URL(DOCS_UNMOUNTED_PATH, request.url), {
|
|
205
|
+
request: { headers: requestHeaders },
|
|
206
|
+
});
|
|
207
|
+
applyHeaders(res.headers, DEFAULT_SECURITY_HEADERS);
|
|
208
|
+
if (!PUBLIC_PAGES) res.headers.set("X-Robots-Tag", "noindex");
|
|
209
|
+
return res;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// #10043 D5 — on a site that moved its services, map the public base onto
|
|
214
|
+
// the internal route, and answer the internal route itself with a 308 onto
|
|
215
|
+
// the base (same origin, same locale prefix and query, so it cannot leave
|
|
216
|
+
// the site).
|
|
217
|
+
let servicesInternalUrl: URL | null = null;
|
|
218
|
+
if (docsInternalUrl === null && SERVICES_PATH !== SERVICES_ROUTE) {
|
|
219
|
+
const { prefix, bare } = splitLocalePrefix(request.nextUrl.pathname);
|
|
220
|
+
const internal = toInternalServicesPath(bare);
|
|
221
|
+
if (internal !== null) {
|
|
222
|
+
servicesInternalUrl = request.nextUrl.clone();
|
|
223
|
+
servicesInternalUrl.pathname = prefix + internal;
|
|
224
|
+
} else if (bare === SERVICES_ROUTE || bare.startsWith(`${SERVICES_ROUTE}/`)) {
|
|
225
|
+
const target = request.nextUrl.clone();
|
|
226
|
+
target.pathname = prefix + toPublicServicesHref(bare);
|
|
227
|
+
const res = NextResponse.redirect(target, 308);
|
|
228
|
+
applyHeaders(res.headers, DEFAULT_SECURITY_HEADERS);
|
|
229
|
+
if (!PUBLIC_PAGES) res.headers.set("X-Robots-Tag", "noindex");
|
|
230
|
+
return res;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const requestWithPathname = new NextRequest(
|
|
235
|
+
docsInternalUrl ?? servicesInternalUrl ?? request.url,
|
|
236
|
+
{
|
|
237
|
+
headers: requestHeaders,
|
|
238
|
+
method: request.method,
|
|
239
|
+
body: request.body,
|
|
240
|
+
},
|
|
241
|
+
);
|
|
93
242
|
|
|
94
243
|
// Step 2: run next-intl middleware (locale detection + rewrite/redirect)
|
|
95
|
-
|
|
244
|
+
let intlResponse = intlMiddleware(requestWithPathname) as NextResponse;
|
|
245
|
+
if (docsInternalUrl) intlResponse = withPublicUrls(intlResponse, toPublicDocsHref);
|
|
246
|
+
if (servicesInternalUrl) {
|
|
247
|
+
intlResponse = withPublicUrls(intlResponse, toPublicServicesHref);
|
|
248
|
+
}
|
|
96
249
|
|
|
97
250
|
// Step 3: apply the 7 baseline security headers to the response. The booking
|
|
98
251
|
// pages of a site that carries the scheduling module get the same map with
|
|
99
|
-
// Cal.com's origin added to the CSP (#8843 D12); no other page does.
|
|
252
|
+
// Cal.com's origin added to the CSP (#8843 D12); no other page does. The
|
|
253
|
+
// file routes get the six others and keep their own sandbox CSP (#9162).
|
|
100
254
|
applyHeaders(
|
|
101
255
|
intlResponse.headers,
|
|
102
|
-
|
|
103
|
-
?
|
|
104
|
-
:
|
|
256
|
+
isFileRoutePath(request.nextUrl.pathname)
|
|
257
|
+
? FILE_ROUTE_SECURITY_HEADERS
|
|
258
|
+
: SCHEDULING_PAGES &&
|
|
259
|
+
isBookingPath(request.nextUrl.pathname, enabledLocales)
|
|
260
|
+
? SCHEDULING_SECURITY_HEADERS
|
|
261
|
+
: DEFAULT_SECURITY_HEADERS,
|
|
105
262
|
);
|
|
263
|
+
// #10040 decision 7 — a site with neither marketing nor docs-blog (a hub)
|
|
264
|
+
// asks every crawler to keep out of every page, read from the module list.
|
|
265
|
+
if (!PUBLIC_PAGES) intlResponse.headers.set("X-Robots-Tag", "noindex");
|
|
106
266
|
|
|
107
267
|
return intlResponse;
|
|
108
268
|
}
|
|
@@ -118,6 +278,13 @@ export function middleware(request: NextRequest): NextResponse {
|
|
|
118
278
|
// og-default.png — and each 404'd (#4090 item 4). robots.txt and sitemap.xml are
|
|
119
279
|
// route handlers rather than public/ files, but they carry extensions too and
|
|
120
280
|
// are equally not locale-scoped, so the same rule covers them.
|
|
281
|
+
//
|
|
282
|
+
// BotID's proxy prefix (`withBotId`, `botid/next/config`, #9889 D7) is
|
|
283
|
+
// excluded too, exactly that two-segment prefix and nothing wider, so
|
|
284
|
+
// next-intl never rewrites it and the security headers never stamp
|
|
285
|
+
// `frame-ancestors 'none'` on it; `withBotId` sets that prefix's own
|
|
286
|
+
// `X-Frame-Options: SAMEORIGIN` and `frame-ancestors 'self'`.
|
|
121
287
|
export const config = {
|
|
122
|
-
matcher:
|
|
288
|
+
matcher:
|
|
289
|
+
"/((?!_next/static|_next/image|_next/server|149e9513-01fa-4fb0-aad4-566afd725d1b/2d206a39-8ed7-437e-a3be-862e0f06eea3/|.*\\..*).*)",
|
|
123
290
|
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parse as parseYaml } from "yaml";
|
|
4
4
|
import bundleAnalyzer from "@next/bundle-analyzer";
|
|
5
5
|
import { withSentryConfig } from "@sentry/nextjs";
|
|
6
6
|
import createNextIntlPlugin from "next-intl/plugin";
|
|
7
|
-
import {
|
|
7
|
+
import { DocsPathSchema, ServicesPathSchema } from "@working-theory/site-config/schema";
|
|
8
|
+
import { legacyRedirects, ROUTES, SERVICES_ROUTE } from "@working-theory/templates/routes";
|
|
8
9
|
import { parseStealth } from "@working-theory/web-i18n/stealth";
|
|
10
|
+
import { withBotId } from "botid/next/config";
|
|
9
11
|
|
|
10
12
|
// @next/bundle-analyzer — enabled when ANALYZE=true (set by ci-bundle.yml).
|
|
11
13
|
// analyzerMode 'json' writes .next/analyze/client.json (and nodejs.json,
|
|
@@ -80,6 +82,203 @@ function readDefaultLocaleFromProjectYaml() {
|
|
|
80
82
|
const enabledLocalesAtBuild = readLocalesFromProjectYaml();
|
|
81
83
|
const defaultLocaleAtBuild = readDefaultLocaleFromProjectYaml();
|
|
82
84
|
|
|
85
|
+
// #10041 D4 — where the docs live. A site sets `docsPath` in
|
|
86
|
+
// content/_site.mdx (its own file, which `create-ailk update` never rewrites);
|
|
87
|
+
// it is read here, refused by name when it cannot be served, and baked into
|
|
88
|
+
// NEXT_PUBLIC_AILK_DOCS_PATH for the middleware and every docs-URL reader
|
|
89
|
+
// (lib/docs-path.ts). Absent — the file, its frontmatter or the key — is
|
|
90
|
+
// `/docs`, today's path. A refusal throws, so `next build` fails before a page
|
|
91
|
+
// renders (the parseStealth posture).
|
|
92
|
+
const DOCS_ROUTE = "/docs";
|
|
93
|
+
|
|
94
|
+
function refuseDocsPath(value, reason) {
|
|
95
|
+
throw new Error(`content/_site.mdx docsPath ${value}: ${reason}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The mount checks a public path takes before it is served (#10041 D4, shared
|
|
100
|
+
* with `services.path` by #10043 D4), each refused through `refuse`:
|
|
101
|
+
*
|
|
102
|
+
* 1. A locale code as the first segment would be read as the locale.
|
|
103
|
+
* 2. A path that shadows another page, or sits under a catch-all. A route is
|
|
104
|
+
* shadowed when the path matches its leading segments, a `:param` segment
|
|
105
|
+
* matching any one segment (`/products/docs` hides the `/products/:slug`
|
|
106
|
+
* page whose slug is `docs`). `ownRoutes` are the setting's own rows.
|
|
107
|
+
*/
|
|
108
|
+
function refuseUnmountablePath(value, ownRoutes, refuse) {
|
|
109
|
+
const firstSegment = value.split("/")[1];
|
|
110
|
+
const locales = enabledLocalesAtBuild.split(",").map((l) => l.toLowerCase());
|
|
111
|
+
if (locales.includes(firstSegment)) {
|
|
112
|
+
refuse(value, `its first segment is the enabled locale ${firstSegment}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const valueSegments = value.split("/").slice(1);
|
|
116
|
+
for (const { route } of ROUTES) {
|
|
117
|
+
if (ownRoutes.includes(route)) continue;
|
|
118
|
+
const catchAll = /^(.*)\/:\.\.\.[^/]+$/.exec(route);
|
|
119
|
+
if (catchAll) {
|
|
120
|
+
if (value.startsWith(`${catchAll[1]}/`)) {
|
|
121
|
+
refuse(value, `it is under the ${catchAll[1]} catch-all route`);
|
|
122
|
+
}
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const routeSegments = route.split("/").slice(1);
|
|
126
|
+
const shadows =
|
|
127
|
+
valueSegments.length <= routeSegments.length &&
|
|
128
|
+
valueSegments.every(
|
|
129
|
+
(segment, i) =>
|
|
130
|
+
routeSegments[i].startsWith(":") || routeSegments[i] === segment,
|
|
131
|
+
);
|
|
132
|
+
if (shadows) refuse(value, `it shadows the ${route} route`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const contentRootAtBuild =
|
|
137
|
+
process.env.AILK_CONTENT_ROOT || join(process.cwd(), "../../content");
|
|
138
|
+
|
|
139
|
+
/** A file's YAML frontmatter, parsed; `undefined` when absent or unreadable. */
|
|
140
|
+
function readFrontmatter(path) {
|
|
141
|
+
let raw;
|
|
142
|
+
try {
|
|
143
|
+
raw = readFileSync(path, "utf8");
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
// The frontmatter is the text between the first two `---` lines.
|
|
148
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
|
|
149
|
+
return match ? parseYaml(match[1]) : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const siteFrontmatterAtBuild = readFrontmatter(join(contentRootAtBuild, "_site.mdx"));
|
|
153
|
+
|
|
154
|
+
function readDocsPathFromSiteMdx() {
|
|
155
|
+
const value = siteFrontmatterAtBuild?.docsPath;
|
|
156
|
+
if (value === undefined || value === null) return DOCS_ROUTE;
|
|
157
|
+
|
|
158
|
+
const parsed = DocsPathSchema.safeParse(value);
|
|
159
|
+
if (!parsed.success) {
|
|
160
|
+
refuseDocsPath(
|
|
161
|
+
value,
|
|
162
|
+
"must be one to four lowercase kebab segments with a leading slash and no trailing slash, not starting with api, og or v1, and not under /docs",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const docsPath = parsed.data;
|
|
166
|
+
refuseUnmountablePath(
|
|
167
|
+
docsPath,
|
|
168
|
+
[DOCS_ROUTE, `${DOCS_ROUTE}/:...slug`],
|
|
169
|
+
refuseDocsPath,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// 3. The site's docs-blog files must follow the setting: its docs source
|
|
173
|
+
// must take `DOCS_PATH` as its base.
|
|
174
|
+
if (docsPath !== DOCS_ROUTE) {
|
|
175
|
+
const sourcePath = join(process.cwd(), "lib/source.ts");
|
|
176
|
+
if (!existsSync(sourcePath)) {
|
|
177
|
+
refuseDocsPath(docsPath, "the site carries no docs-blog module");
|
|
178
|
+
}
|
|
179
|
+
if (!/\bbaseUrl:\s*DOCS_PATH\b/.test(readFileSync(sourcePath, "utf8"))) {
|
|
180
|
+
refuseDocsPath(
|
|
181
|
+
docsPath,
|
|
182
|
+
"this site's docs-blog files predate docsPath; re-install the docs-blog module's files",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return docsPath;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const docsPathAtBuild = readDocsPathFromSiteMdx();
|
|
190
|
+
|
|
191
|
+
// #10043 D4 — where the services live, and their categories. A site sets
|
|
192
|
+
// `services: { path: /capabilities }` in content/_site.mdx; it is read here,
|
|
193
|
+
// refused by name when it cannot be served, and baked into
|
|
194
|
+
// NEXT_PUBLIC_AILK_SERVICES_PATH for the middleware, the page renderer and
|
|
195
|
+
// every services-URL reader (lib/services-path.ts). Absent is `/services`.
|
|
196
|
+
// A Service's `parent` (its category) is checked here too, for the default
|
|
197
|
+
// locale's MDX files: it must name a Service with no parent of its own, and
|
|
198
|
+
// the site must carry the nested route.
|
|
199
|
+
|
|
200
|
+
function refuseServicesPath(value, reason) {
|
|
201
|
+
throw new Error(`content/_site.mdx services.path ${value}: ${reason}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function readServicesPathFromSiteMdx() {
|
|
205
|
+
const value = siteFrontmatterAtBuild?.services?.path;
|
|
206
|
+
if (value === undefined || value === null) return SERVICES_ROUTE;
|
|
207
|
+
|
|
208
|
+
const parsed = ServicesPathSchema.safeParse(value);
|
|
209
|
+
if (!parsed.success) {
|
|
210
|
+
refuseServicesPath(
|
|
211
|
+
value,
|
|
212
|
+
"must be one to four lowercase kebab segments with a leading slash and no trailing slash, not starting with api, og or v1, and not under /services",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const servicesPath = parsed.data;
|
|
216
|
+
refuseUnmountablePath(
|
|
217
|
+
servicesPath,
|
|
218
|
+
[SERVICES_ROUTE, `${SERVICES_ROUTE}/:slug`, `${SERVICES_ROUTE}/:slug/:child`],
|
|
219
|
+
refuseServicesPath,
|
|
220
|
+
);
|
|
221
|
+
// Two moved mounts must not overlap, in either direction.
|
|
222
|
+
const overlaps = (a, b) => a === b || a.startsWith(`${b}/`);
|
|
223
|
+
if (overlaps(servicesPath, docsPathAtBuild) || overlaps(docsPathAtBuild, servicesPath)) {
|
|
224
|
+
refuseServicesPath(servicesPath, `it overlaps docsPath ${docsPathAtBuild}`);
|
|
225
|
+
}
|
|
226
|
+
return servicesPath;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function refuseServiceParent(file, value, reason) {
|
|
230
|
+
throw new Error(`${file} parent ${value}: ${reason}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readServiceParents() {
|
|
234
|
+
// Under the database adapter the pages are not on disk; the nested route
|
|
235
|
+
// answers 404 for a bad parent at request time instead.
|
|
236
|
+
if (process.env.CONTENT_ADAPTER === "database") return;
|
|
237
|
+
const dirs = [`${defaultLocaleAtBuild}/Service`, "Service"];
|
|
238
|
+
const nestedRoute = join(process.cwd(), "app/[locale]/services/[slug]/[child]/page.tsx");
|
|
239
|
+
for (const dir of dirs) {
|
|
240
|
+
let files;
|
|
241
|
+
try {
|
|
242
|
+
files = readdirSync(join(contentRootAtBuild, dir)).filter((f) => f.endsWith(".mdx"));
|
|
243
|
+
} catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const pages = files.map((file) => {
|
|
247
|
+
const frontmatter = readFrontmatter(join(contentRootAtBuild, dir, file)) ?? {};
|
|
248
|
+
return {
|
|
249
|
+
file: `content/${dir}/${file}`,
|
|
250
|
+
slug: frontmatter.slug ?? file.replace(/\.mdx$/, ""),
|
|
251
|
+
parent: frontmatter.parent,
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
const bySlug = new Map(pages.map((page) => [page.slug, page]));
|
|
255
|
+
for (const page of pages) {
|
|
256
|
+
if (page.parent === undefined || page.parent === null) continue;
|
|
257
|
+
const category = bySlug.get(page.parent);
|
|
258
|
+
if (category === undefined) {
|
|
259
|
+
refuseServiceParent(page.file, page.parent, `it names no Service in content/${dir}`);
|
|
260
|
+
}
|
|
261
|
+
if (category.parent !== undefined && category.parent !== null) {
|
|
262
|
+
refuseServiceParent(
|
|
263
|
+
page.file,
|
|
264
|
+
page.parent,
|
|
265
|
+
"that Service has its own parent; services take one category level",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (!existsSync(nestedRoute)) {
|
|
269
|
+
refuseServiceParent(
|
|
270
|
+
page.file,
|
|
271
|
+
page.parent,
|
|
272
|
+
"this site's services routes predate service categories; run create-ailk sync-routes",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const servicesPathAtBuild = readServicesPathFromSiteMdx();
|
|
280
|
+
readServiceParents();
|
|
281
|
+
|
|
83
282
|
/** @type {import('next').NextConfig} */
|
|
84
283
|
const nextConfig = {
|
|
85
284
|
reactStrictMode: true,
|
|
@@ -104,6 +303,10 @@ const nextConfig = {
|
|
|
104
303
|
// output shipped without them and postbuild's assert-content-bundled failed.
|
|
105
304
|
outputFileTracingIncludes: {
|
|
106
305
|
"/**": ["../../content/**/*.mdx"],
|
|
306
|
+
// #10041 D8 — /sitemap.xml and /llms.txt list the docs by reading this
|
|
307
|
+
// app's own content/<locale>/docs/ at request time.
|
|
308
|
+
"/sitemap.xml": ["./content/**/*.mdx"],
|
|
309
|
+
"/llms.txt": ["./content/**/*.mdx"],
|
|
107
310
|
},
|
|
108
311
|
// Workspace packages live outside this app's directory. Next.js must
|
|
109
312
|
// transpile them from source so that "use client" / "use server" directives
|
|
@@ -160,6 +363,13 @@ const nextConfig = {
|
|
|
160
363
|
// onto the hub's. Part of the source, not an operator setting: a build
|
|
161
364
|
// that carries the relay always carries the flag, and no other build does.
|
|
162
365
|
NEXT_PUBLIC_AILK_SAME_ORIGIN_RELAY: "1",
|
|
366
|
+
// The docs' public path (#10041), from content/_site.mdx's docsPath, or
|
|
367
|
+
// `/docs`. Read by middleware.ts and lib/docs-path.ts.
|
|
368
|
+
NEXT_PUBLIC_AILK_DOCS_PATH: docsPathAtBuild,
|
|
369
|
+
// The services' public base (#10043), from content/_site.mdx's
|
|
370
|
+
// services.path, or `/services`. Read by middleware.ts,
|
|
371
|
+
// lib/services-path.ts and @working-theory/page-renderer.
|
|
372
|
+
NEXT_PUBLIC_AILK_SERVICES_PATH: servicesPathAtBuild,
|
|
163
373
|
},
|
|
164
374
|
async headers() {
|
|
165
375
|
if (!STEALTH) return [];
|
|
@@ -197,7 +407,14 @@ const sentryBuildOptions = {
|
|
|
197
407
|
: {}),
|
|
198
408
|
};
|
|
199
409
|
|
|
410
|
+
// #9889 D6 — `withBotId` adds BotID's two rewrites (the challenge script and
|
|
411
|
+
// its proxy) and one header rule on its own proxy prefix, which middleware.ts
|
|
412
|
+
// keeps out of its matcher. It is not gated on `AILK_BOOKING_BOT_CHECK`: this
|
|
413
|
+
// config is read at build time, the switch at run time, and an unused rewrite
|
|
414
|
+
// answers nothing. It sits directly inside Sentry so the substrings
|
|
415
|
+
// (the MDX wrap and the next-intl wrap, quoted nowhere else) that create-ailk
|
|
416
|
+
// edits stay intact.
|
|
200
417
|
export default withSentryConfig(
|
|
201
|
-
withBundleAnalyzer(withNextIntl(nextConfig)),
|
|
418
|
+
withBotId(withBundleAnalyzer(withNextIntl(nextConfig))),
|
|
202
419
|
sentryBuildOptions,
|
|
203
420
|
);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"dev": "next dev --webpack",
|
|
9
|
-
"build": "next build --webpack",
|
|
9
|
+
"build": "tsx scripts/check-nav-labels.ts && next build --webpack",
|
|
10
10
|
"postbuild": "tsx scripts/assert-content-bundled.ts",
|
|
11
11
|
"start": "next start",
|
|
12
12
|
"type-check": "tsc --noEmit",
|
|
@@ -37,8 +37,9 @@
|
|
|
37
37
|
"@working-theory/validation": "workspace:*",
|
|
38
38
|
"@working-theory/web-i18n": "workspace:*",
|
|
39
39
|
"better-auth": "1.6.22",
|
|
40
|
+
"botid": "1.5.11",
|
|
40
41
|
"lucide-react": "^0.577.0",
|
|
41
|
-
"next": "^16.3
|
|
42
|
+
"next": "^16.4.3",
|
|
42
43
|
"next-intl": "^3.26.3",
|
|
43
44
|
"next-mdx-remote": "^6.0.0",
|
|
44
45
|
"pg": "^8.22.0",
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* See: docs/runbooks/page-renderer-deploy-verification.md §1 Cause 2
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync } from "node:fs";
|
|
12
|
-
import { join, resolve } from "node:path";
|
|
11
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
12
|
+
import { join, relative, resolve } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
|
|
15
15
|
// apps/web/ is one level up from scripts/
|
|
@@ -18,10 +18,32 @@ const scriptDir = resolve(fileURLToPath(import.meta.url), "..");
|
|
|
18
18
|
const appWebDir = resolve(scriptDir, "..");
|
|
19
19
|
const standaloneDir = join(appWebDir, ".next", "standalone");
|
|
20
20
|
|
|
21
|
-
// Sentinel file
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
21
|
+
// Sentinel: the first MDX file under <repo-root>/content on disk — one traced
|
|
22
|
+
// file proves the tracing; it does not count the others. A site that
|
|
23
|
+
// dropped the marketing module (#10040) has no HomePage — only its legal
|
|
24
|
+
// pages — so the sentinel is whichever page this site carries, traced from
|
|
25
|
+
// ../../content/<locale>/<Type>/<page>.mdx (relative to apps/web) and placed
|
|
26
|
+
// at standaloneDir/content/... by Next.js file tracing.
|
|
27
|
+
const contentDir = resolve(appWebDir, "..", "..", "content");
|
|
28
|
+
function firstMdx(dir: string): string | undefined {
|
|
29
|
+
if (!existsSync(dir)) return undefined;
|
|
30
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
31
|
+
const abs = join(dir, entry.name);
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
const found = firstMdx(abs);
|
|
34
|
+
if (found !== undefined) return found;
|
|
35
|
+
} else if (entry.name.endsWith(".mdx")) {
|
|
36
|
+
return abs;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const sentinelAbs = firstMdx(contentDir);
|
|
42
|
+
if (sentinelAbs === undefined) {
|
|
43
|
+
console.error("[assert-content-bundled] ERROR: no .mdx file under content/ to trace.");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const sentinelRelative = join("content", relative(contentDir, sentinelAbs));
|
|
25
47
|
const sentinelPath = join(standaloneDir, sentinelRelative);
|
|
26
48
|
|
|
27
49
|
if (!existsSync(standaloneDir)) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail the build when a nav key has no text (#10045 D2).
|
|
3
|
+
*
|
|
4
|
+
* Every `labelKey` / `headingKey` / `descKey` in `lib/nav-config.ts`, and the
|
|
5
|
+
* keys the layout resolves by name, must exist in the `nav` namespace of every
|
|
6
|
+
* `messages/<locale>.json` (or its site overlay). A missing key would render
|
|
7
|
+
* as raw text in the header or footer; this check names the key and the file
|
|
8
|
+
* instead. Runs as the first step of `pnpm build`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
findMissingNavLabels,
|
|
16
|
+
formatMissingNavLabels,
|
|
17
|
+
} from "../lib/nav-label-check";
|
|
18
|
+
import { usedNavLabelKeys } from "../lib/nav-label-keys";
|
|
19
|
+
|
|
20
|
+
const appWebDir = resolve(fileURLToPath(import.meta.url), "..", "..");
|
|
21
|
+
const messagesDir = resolve(appWebDir, "messages");
|
|
22
|
+
|
|
23
|
+
const keys = usedNavLabelKeys();
|
|
24
|
+
const missing = findMissingNavLabels(messagesDir, keys);
|
|
25
|
+
|
|
26
|
+
if (missing.length > 0) {
|
|
27
|
+
console.error(
|
|
28
|
+
`[check-nav-labels] ERROR:\n${formatMissingNavLabels(missing)}`,
|
|
29
|
+
);
|
|
30
|
+
console.error(
|
|
31
|
+
"[check-nav-labels] Add each key's text to the `nav` namespace of that file " +
|
|
32
|
+
"(or messages/site/<locale>.json).",
|
|
33
|
+
);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(
|
|
38
|
+
`[check-nav-labels] ✓ ${keys.size} nav keys resolve in every locale`,
|
|
39
|
+
);
|
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
import * as Sentry from "@sentry/nextjs";
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
scrubSpanDescription,
|
|
17
|
+
scrubTelemetryQuery,
|
|
18
|
+
scrubTelemetryUrl,
|
|
19
|
+
} from "./lib/telemetry-scrub";
|
|
16
20
|
|
|
17
21
|
// #5613 / security review PR #5639: the report-grant token (?g=<id> on
|
|
18
22
|
// /reports/<slug>, #5562) is a bearer credential for a private customer
|
|
@@ -21,13 +25,29 @@ import { scrubTelemetryUrl } from "./lib/telemetry-scrub";
|
|
|
21
25
|
// header, the transaction name, navigation breadcrumbs, and any URL
|
|
22
26
|
// captured as span/trace data (e.g. `url.full`,
|
|
23
27
|
// `http.request.header.referer`) here too.
|
|
28
|
+
//
|
|
29
|
+
// #9969 D9 (#8329): the Stripe SDK's outgoing requests carry a customer id in
|
|
30
|
+
// their URL and query, so a span's `description` (`<METHOD> <url>`), its raw
|
|
31
|
+
// `url`/`url.path`/`http.target`, and the separate query keys (`http.query`,
|
|
32
|
+
// `url.query`) on spans, the trace and breadcrumbs are scrubbed as well.
|
|
33
|
+
const QUERY_KEYS = ["http.query", "url.query"];
|
|
34
|
+
|
|
35
|
+
function scrubQueryData(data: Record<string, unknown>): void {
|
|
36
|
+
for (const key of QUERY_KEYS) {
|
|
37
|
+
const value = data[key];
|
|
38
|
+
if (typeof value === "string") {
|
|
39
|
+
data[key] = scrubTelemetryQuery(value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
function scrubSentryEvent<
|
|
25
45
|
T extends {
|
|
26
46
|
request?: { url?: string; headers?: Record<string, string> };
|
|
27
47
|
transaction?: string;
|
|
28
48
|
breadcrumbs?: { category?: string; data?: Record<string, unknown> }[];
|
|
29
49
|
contexts?: { trace?: { data?: Record<string, unknown> } };
|
|
30
|
-
spans?: { data?: Record<string, unknown> }[];
|
|
50
|
+
spans?: { description?: string; data?: Record<string, unknown> }[];
|
|
31
51
|
},
|
|
32
52
|
>(event: T): T {
|
|
33
53
|
if (event.request?.url) {
|
|
@@ -60,6 +80,7 @@ function scrubSentryEvent<
|
|
|
60
80
|
if (typeof url === "string") {
|
|
61
81
|
crumb.data["url"] = scrubTelemetryUrl(url);
|
|
62
82
|
}
|
|
83
|
+
scrubQueryData(crumb.data);
|
|
63
84
|
}
|
|
64
85
|
const scrubSpanData = (data?: Record<string, unknown>): void => {
|
|
65
86
|
if (!data) return;
|
|
@@ -67,15 +88,22 @@ function scrubSentryEvent<
|
|
|
67
88
|
"url.full",
|
|
68
89
|
"http.url",
|
|
69
90
|
"http.request.header.referer",
|
|
91
|
+
"url",
|
|
92
|
+
"url.path",
|
|
93
|
+
"http.target",
|
|
70
94
|
]) {
|
|
71
95
|
const value = data[key];
|
|
72
96
|
if (typeof value === "string") {
|
|
73
97
|
data[key] = scrubTelemetryUrl(value);
|
|
74
98
|
}
|
|
75
99
|
}
|
|
100
|
+
scrubQueryData(data);
|
|
76
101
|
};
|
|
77
102
|
scrubSpanData(event.contexts?.trace?.data);
|
|
78
103
|
for (const span of event.spans ?? []) {
|
|
104
|
+
if (span.description) {
|
|
105
|
+
span.description = scrubSpanDescription(span.description);
|
|
106
|
+
}
|
|
79
107
|
scrubSpanData(span.data);
|
|
80
108
|
}
|
|
81
109
|
return event;
|