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.
Files changed (219) hide show
  1. package/README.md +84 -7
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/chrome-readers.d.ts +21 -5
  16. package/dist/lib/chrome-readers.js +128 -20
  17. package/dist/lib/declared-env.d.ts +6 -0
  18. package/dist/lib/declared-env.js +2 -0
  19. package/dist/lib/env-catalog.js +20 -0
  20. package/dist/lib/import-graph.d.ts +16 -0
  21. package/dist/lib/import-graph.js +53 -0
  22. package/dist/lib/site-modules.d.ts +8 -0
  23. package/dist/lib/site-modules.js +34 -0
  24. package/dist/module-architecture.js +23 -14
  25. package/dist/paid-sections.d.ts +17 -0
  26. package/dist/paid-sections.js +39 -0
  27. package/dist/programmatic.d.ts +16 -0
  28. package/dist/programmatic.js +73 -4
  29. package/dist/remove-module.d.ts +98 -0
  30. package/dist/remove-module.js +620 -0
  31. package/dist/seed-flows.d.ts +2 -2
  32. package/dist/seed-flows.js +16 -9
  33. package/dist/site-roster.js +4 -1
  34. package/dist/starter-grid.d.ts +26 -0
  35. package/dist/starter-grid.js +34 -0
  36. package/dist/surfaces.d.ts +26 -0
  37. package/dist/surfaces.js +105 -9
  38. package/dist/sync-routes.d.ts +7 -0
  39. package/dist/sync-routes.js +53 -6
  40. package/dist/template-pages.d.ts +36 -0
  41. package/dist/template-pages.js +129 -0
  42. package/dist/template-record.d.ts +13 -4
  43. package/dist/template-record.js +8 -4
  44. package/dist/topology.d.ts +2 -1
  45. package/dist/topology.js +4 -2
  46. package/dist/update.d.ts +34 -2
  47. package/dist/update.js +395 -48
  48. package/package.json +3 -3
  49. package/release-scope.json +2 -1
  50. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  51. package/templates/.env.example +5 -2
  52. package/templates/apps/api/.env.example +9 -5
  53. package/templates/apps/api/CLAUDE.md +14 -9
  54. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  55. package/templates/apps/api/src/vercel-handler.ts +8 -1
  56. package/templates/apps/mcp/CLAUDE.md +18 -10
  57. package/templates/apps/web/.env.example +25 -11
  58. package/templates/apps/web/CLAUDE.md +45 -4
  59. package/templates/apps/web/__tests__/setup.ts +7 -0
  60. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  61. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  65. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  66. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  67. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  68. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  69. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  70. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  72. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  73. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  74. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  76. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  77. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  78. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  79. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  80. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  81. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  82. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  83. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  84. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  85. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  86. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  87. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  88. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  89. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  90. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  91. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  92. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  93. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  105. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  107. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  108. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  109. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  110. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  111. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  113. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  114. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  115. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  116. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  117. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  118. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  119. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  120. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  121. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  122. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  124. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  126. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  127. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  128. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  129. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  130. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  131. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  132. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  133. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  134. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  135. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  136. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  137. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  138. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  139. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  140. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  141. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  142. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +224 -0
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  144. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  145. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  146. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  147. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  148. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  149. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  150. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  151. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  152. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +237 -0
  153. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  154. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  155. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  156. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  157. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  158. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  159. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  160. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  161. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  162. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  163. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  164. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  165. package/templates/apps/web/auth.ts +102 -50
  166. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  167. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  168. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  169. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  170. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  171. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  172. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +279 -0
  173. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  174. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  175. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  176. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  177. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  178. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  179. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  180. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  181. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  182. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  183. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  184. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  185. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  186. package/templates/apps/web/lib/auth-secret.ts +57 -0
  187. package/templates/apps/web/lib/bot-check.ts +138 -0
  188. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  189. package/templates/apps/web/lib/deck-page.ts +6 -3
  190. package/templates/apps/web/lib/docs-index.ts +100 -0
  191. package/templates/apps/web/lib/docs-path.ts +94 -0
  192. package/templates/apps/web/lib/hub-children.tsx +318 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  195. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  196. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  197. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  198. package/templates/apps/web/lib/page-chrome.ts +367 -0
  199. package/templates/apps/web/lib/posting-title.ts +26 -4
  200. package/templates/apps/web/lib/pricing-page.ts +430 -0
  201. package/templates/apps/web/lib/relay.ts +31 -106
  202. package/templates/apps/web/lib/route-family.ts +69 -0
  203. package/templates/apps/web/lib/service-listing.ts +89 -0
  204. package/templates/apps/web/lib/services-path.ts +97 -0
  205. package/templates/apps/web/lib/site-header.ts +94 -0
  206. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  207. package/templates/apps/web/lib/template-listing.ts +169 -0
  208. package/templates/apps/web/messages/en.json +86 -30
  209. package/templates/apps/web/middleware.ts +171 -11
  210. package/templates/apps/web/next.config.mjs +211 -2
  211. package/templates/apps/web/package.json +1 -1
  212. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  213. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  214. package/templates/apps/web/sentry.server.config.ts +30 -2
  215. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  216. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  217. package/templates/apps/web/src/lib/routes.ts +15 -1
  218. package/templates/tools/declared-env.ts +7 -0
  219. package/versions.json +48 -48
@@ -295,7 +295,13 @@
295
295
  "addonsHeadline": "Add-ons",
296
296
  "monthly": "{amount}/mo",
297
297
  "annual": "{amount}/yr",
298
- "oneTime": "{amount} one-time"
298
+ "oneTime": "{amount} one-time",
299
+ "withRequired": "{plan} + {required}",
300
+ "depositNote": "{deposit} deposit due today; the balance is invoiced."
301
+ },
302
+ "pricingPages": {
303
+ "heading": "Pricing by offering",
304
+ "viewPricing": "See pricing"
299
305
  },
300
306
  "purchasePolicy": {
301
307
  "heading": "Purchase Policy",
@@ -393,7 +399,9 @@
393
399
  },
394
400
  "services": {
395
401
  "heading": "Services",
396
- "emptyState": "Services listing coming soon."
402
+ "emptyState": "Services listing coming soon.",
403
+ "childrenHeading": "Services in this area",
404
+ "viewService": "View service"
397
405
  },
398
406
  "software": {
399
407
  "heading": "Software",
@@ -425,6 +433,17 @@
425
433
  "heading": "Alternatives",
426
434
  "emptyState": "Comparisons coming soon."
427
435
  },
436
+ "templates": {
437
+ "heading": "Templates",
438
+ "emptyState": "Templates coming soon.",
439
+ "getIt": "Get it",
440
+ "startFree": "Start free",
441
+ "viewDemo": "View demo",
442
+ "viewTemplate": "View template",
443
+ "included": "What's included",
444
+ "demoBadge": "Live demo",
445
+ "moreCategories": "+{count} more"
446
+ },
428
447
  "reports": {
429
448
  "heading": "Reports",
430
449
  "emptyState": "Reports coming soon.",
@@ -1256,26 +1275,46 @@
1256
1275
  "heading": "Integrations",
1257
1276
  "lead": "Connect the services your site works with.",
1258
1277
  "slack": {
1259
- "title": "Slack",
1260
- "credentialsHeading": "App credentials",
1278
+ "title": "Connection",
1261
1279
  "connectedBadge": "Connected",
1262
- "notConnectedBadge": "Not connected",
1263
- "lead": "Connect your Slack app so each booked prospect gets a shared channel.",
1264
- "botTokenLabel": "Bot token",
1265
- "signingSecretLabel": "Signing secret",
1280
+ "row": {
1281
+ "notConnectedTitle": "Enable team collaboration in Slack",
1282
+ "notConnectedLine": "Connect your Slack workspace so your team can work with this site in Slack.",
1283
+ "connectedTitle": "Site workspace",
1284
+ "connectedLine": "Enable team collaboration in Slack.",
1285
+ "unavailableTitle": "We couldn't check Slack.",
1286
+ "unavailableLine": "The Slack connection didn't load. Try again in a moment."
1287
+ },
1288
+ "connect": "Connect",
1289
+ "tryAgain": "Try again",
1290
+ "moreActions": "More Slack actions",
1291
+ "replace": "Replace…",
1292
+ "disconnect": "Disconnect…",
1293
+ "setup": {
1294
+ "region": "Set up Slack",
1295
+ "tokenStep": "Create a Slack app and paste its bot token",
1296
+ "tokenTip": "The app needs a paid Slack plan and these bot scopes:",
1297
+ "tipLabel": "More about this step",
1298
+ "openSlackApps": "Open Slack apps",
1299
+ "slackAppsHref": "https://api.slack.com/apps",
1300
+ "tokenPlaceholder": "xoxb-…",
1301
+ "tokenSaved": "Bot token saved",
1302
+ "urlStep": "Copy the events URL into Slack",
1303
+ "urlTip": "In your Slack app, open Event Subscriptions, turn on events, paste this as the Request URL, and subscribe to the bot events {events}.",
1304
+ "urlUnavailable": "The API's address isn't configured, so the events URL can't be shown.",
1305
+ "copy": "Copy",
1306
+ "copied": "Copied",
1307
+ "urlCopied": "Events URL copied",
1308
+ "secretStep": "Paste the app’s signing secret",
1309
+ "secretTip": "In your Slack app, under Basic Information → App Credentials.",
1310
+ "secretPlaceholder": "Signing secret",
1311
+ "save": "Save",
1312
+ "saving": "Saving…",
1313
+ "cancel": "Cancel",
1314
+ "done": "Done"
1315
+ },
1266
1316
  "botTokenInvalid": "A bot token starts with xoxb- and holds only letters, digits and hyphens.",
1267
1317
  "signingSecretInvalid": "A signing secret is 32 to 128 letters and digits.",
1268
- "scopesLead": "The Slack app needs these bot scopes:",
1269
- "connect": "Connect Slack",
1270
- "connecting": "Connecting…",
1271
- "cancel": "Cancel",
1272
- "replace": "Replace",
1273
- "disconnect": "Disconnect",
1274
- "workspaceName": "Slack workspace",
1275
- "connectedOn": "Connected",
1276
- "ownerOnly": "Only the site's owner can change the Slack connection.",
1277
- "askOwner": "Slack isn't connected. Ask the site's owner to connect it.",
1278
- "unavailable": "The Slack connection couldn't be read. Try again later.",
1279
1318
  "tokenRefused": {
1280
1319
  "tokenRefused": "Slack refused this token. Check it and try again.",
1281
1320
  "notBotToken": "This isn't a bot token. Copy the Bot User OAuth Token (xoxb-).",
@@ -1301,15 +1340,16 @@
1301
1340
  "prospectChannel": {
1302
1341
  "heading": "Prospect channels",
1303
1342
  "lead": "Open a shared Slack channel with each prospect who books a meeting.",
1304
- "enabled": "Open a channel for each booking",
1305
- "members": "Members",
1306
- "ownerAlwaysInvited": "The site's owner is always invited. Add anyone else by Slack member id.",
1307
- "addMemberLabel": "Slack member id",
1308
- "addMember": "Add",
1309
- "removeMember": "Remove {memberId}",
1310
- "welcomeTemplate": "Welcome message",
1311
- "welcomeDefaultNote": "Leave blank to send the default welcome.",
1312
- "mergeFieldsHeading": "Merge fields",
1343
+ "switchLabel": "Prospect channels",
1344
+ "showSettings": "Show prospect channel settings",
1345
+ "hideSettings": "Hide prospect channel settings",
1346
+ "members": "Your channel team members",
1347
+ "membersTipLabel": "About members",
1348
+ "membersTip": "Slack member ids of teammates to add to each new channel, separated by commas. The site's owner is always added.",
1349
+ "membersPlaceholder": "U012AB3CD, U045EF6GH",
1350
+ "welcomeTemplate": "Welcome message for prospects",
1351
+ "mergeFieldsLabel": "Merge fields",
1352
+ "mergeFieldsLead": "You can use",
1313
1353
  "mergeFields": {
1314
1354
  "firstName": "The prospect's first name",
1315
1355
  "hostName": "The host's name",
@@ -1318,7 +1358,11 @@
1318
1358
  "timeline": "The prospect's timeline",
1319
1359
  "meetingLink": "The meeting link"
1320
1360
  },
1321
- "channelPrefix": "Channel name prefix",
1361
+ "welcomePlaceholder": "Leave blank to send the default welcome.",
1362
+ "channelPrefix": "Channel prefix",
1363
+ "prefixTipLabel": "About the channel prefix",
1364
+ "prefixTip": "Each booking gets its own new Slack channel. Its name starts with this prefix, then a short random code, like {example}, so all prospect channels sort together in Slack.",
1365
+ "prefixExample": "prospect-k3x9m2qa",
1322
1366
  "archiveDays": "Archive after (days)",
1323
1367
  "save": "Save",
1324
1368
  "saving": "Saving…",
@@ -1327,7 +1371,7 @@
1327
1371
  "slackNotConnected": "Slack isn't connected, so these settings can't be changed.",
1328
1372
  "connectSlackLink": "Connect Slack",
1329
1373
  "errors": {
1330
- "memberIds": "Enter a new Slack member id, like U012AB3CD (up to 50).",
1374
+ "memberIds": "Enter Slack member ids like U012AB3CD, separated by commas, with no repeats (up to 50).",
1331
1375
  "welcomeTemplate": "Use only the merge fields listed, in up to 4,000 characters.",
1332
1376
  "channelPrefix": "Use lowercase letters, digits, - and _, starting with a letter or digit (up to 21).",
1333
1377
  "archiveDays": "Enter a whole number of days from 1 to 365."
@@ -1455,5 +1499,17 @@
1455
1499
  "already": "You were already unsubscribed.",
1456
1500
  "invalid": "This link isn't valid.",
1457
1501
  "unavailable": "Unsubscribing isn't available right now."
1502
+ },
1503
+ "hubConnect": {
1504
+ "metaTitle": "Sign in to a site",
1505
+ "title": "Sign in to {name}?",
1506
+ "lead": "{origin} will see your name and email address, {email}. We ask once for each site.",
1507
+ "continue": "Continue",
1508
+ "cancel": "Cancel",
1509
+ "unavailable": {
1510
+ "title": "This sign-in can't continue",
1511
+ "body": "The link may have expired. Go back to the site you came from and sign in again.",
1512
+ "back": "Back to your account"
1513
+ }
1458
1514
  }
1459
1515
  }
@@ -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 { SCHEDULING_PAGES } from "./lib/modules.generated";
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
- const requestWithPathname = new NextRequest(request.url, {
89
- headers: requestHeaders,
90
- method: request.method,
91
- body: request.body,
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
- const intlResponse = intlMiddleware(requestWithPathname) as NextResponse;
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
- SCHEDULING_PAGES && isBookingPath(request.nextUrl.pathname, enabledLocales)
103
- ? SCHEDULING_SECURITY_HEADERS
104
- : DEFAULT_SECURITY_HEADERS,
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
  }
@@ -1,10 +1,11 @@
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 { legacyRedirects } from "@working-theory/templates/routes";
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";
9
10
  import { withBotId } from "botid/next/config";
10
11
 
@@ -81,6 +82,203 @@ function readDefaultLocaleFromProjectYaml() {
81
82
  const enabledLocalesAtBuild = readLocalesFromProjectYaml();
82
83
  const defaultLocaleAtBuild = readDefaultLocaleFromProjectYaml();
83
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
+
84
282
  /** @type {import('next').NextConfig} */
85
283
  const nextConfig = {
86
284
  reactStrictMode: true,
@@ -105,6 +303,10 @@ const nextConfig = {
105
303
  // output shipped without them and postbuild's assert-content-bundled failed.
106
304
  outputFileTracingIncludes: {
107
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"],
108
310
  },
109
311
  // Workspace packages live outside this app's directory. Next.js must
110
312
  // transpile them from source so that "use client" / "use server" directives
@@ -161,6 +363,13 @@ const nextConfig = {
161
363
  // onto the hub's. Part of the source, not an operator setting: a build
162
364
  // that carries the relay always carries the flag, and no other build does.
163
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,
164
373
  },
165
374
  async headers() {
166
375
  if (!STEALTH) return [];
@@ -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",