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.
Files changed (224) hide show
  1. package/README.md +86 -9
  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/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +25 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +112 -11
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +90 -6
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +12 -2
  44. package/templates/CLAUDE.md +20 -20
  45. package/templates/README.md +19 -19
  46. package/templates/apps/api/.env.example +16 -5
  47. package/templates/apps/api/CLAUDE.md +32 -11
  48. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  49. package/templates/apps/api/src/vercel-handler.ts +8 -1
  50. package/templates/apps/api/vercel.json +7 -1
  51. package/templates/apps/mcp/CLAUDE.md +18 -10
  52. package/templates/apps/web/.env.example +27 -0
  53. package/templates/apps/web/CLAUDE.md +56 -8
  54. package/templates/apps/web/__tests__/setup.ts +7 -0
  55. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
  58. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  59. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
  60. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
  61. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  63. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  66. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  68. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  69. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  70. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  72. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  73. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  74. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  76. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  77. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  78. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  79. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  80. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  81. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  82. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  83. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  84. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  85. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  86. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  87. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  88. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  89. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  90. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  91. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  92. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  93. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  105. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  107. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  108. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  109. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  110. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  114. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  115. package/templates/apps/web/app/[locale]/layout.tsx +161 -128
  116. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  117. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  118. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  119. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  120. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  121. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  122. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  124. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  126. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  127. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  128. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  129. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  131. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  132. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  133. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  134. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  135. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  136. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  137. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  138. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  139. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  140. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  141. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  142. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  144. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  145. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  146. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  148. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  149. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  150. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  151. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  152. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  153. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  154. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  155. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  156. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  157. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
  160. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  161. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  162. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  163. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  164. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  165. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  166. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  167. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  168. package/templates/apps/web/auth.ts +102 -50
  169. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  170. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  171. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  172. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  173. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  174. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  175. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  176. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  177. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  178. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  179. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  180. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  181. package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
  182. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  183. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  184. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  185. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  187. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  188. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  189. package/templates/apps/web/lib/auth-secret.ts +57 -0
  190. package/templates/apps/web/lib/bot-check.ts +138 -0
  191. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  192. package/templates/apps/web/lib/deck-page.ts +6 -3
  193. package/templates/apps/web/lib/docs-index.ts +100 -0
  194. package/templates/apps/web/lib/docs-path.ts +94 -0
  195. package/templates/apps/web/lib/hub-children.tsx +318 -0
  196. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
  197. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
  198. package/templates/apps/web/lib/modules.generated.ts +9 -0
  199. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  200. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  201. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  202. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  203. package/templates/apps/web/lib/page-chrome.ts +367 -0
  204. package/templates/apps/web/lib/posting-title.ts +26 -4
  205. package/templates/apps/web/lib/pricing-page.ts +430 -0
  206. package/templates/apps/web/lib/relay.ts +44 -4
  207. package/templates/apps/web/lib/route-family.ts +69 -0
  208. package/templates/apps/web/lib/service-listing.ts +89 -0
  209. package/templates/apps/web/lib/services-path.ts +97 -0
  210. package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
  211. package/templates/apps/web/lib/site-header.ts +94 -0
  212. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  213. package/templates/apps/web/lib/template-listing.ts +169 -0
  214. package/templates/apps/web/messages/en.json +144 -3
  215. package/templates/apps/web/middleware.ts +179 -12
  216. package/templates/apps/web/next.config.mjs +220 -3
  217. package/templates/apps/web/package.json +3 -2
  218. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  219. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  220. package/templates/apps/web/sentry.server.config.ts +30 -2
  221. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  222. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  223. package/templates/apps/web/src/lib/routes.ts +15 -1
  224. package/versions.json +48 -48
@@ -0,0 +1,97 @@
1
+ // services-path.ts — where the services live (#10043 D5).
2
+ //
3
+ // A site sets `services: { path: /capabilities }` in `content/_site.mdx`;
4
+ // `next.config.mjs` parses it at build and bakes it into
5
+ // `NEXT_PUBLIC_AILK_SERVICES_PATH`. The services routes never move — they stay
6
+ // at `app/[locale]/services/` (`SERVICES_ROUTE`) — and the middleware maps the
7
+ // public path onto them and answers the old `/services` URLs with a 308. Every
8
+ // surface that names a services URL reads `SERVICES_PATH` here.
9
+ //
10
+ // Edge-safe and client-safe: this file imports nothing at runtime (the
11
+ // `NavItem` import is type-only), so the middleware and the client signed-in
12
+ // nav can both read it. The shape is `docs-path.ts`'s (#10041).
13
+ import type { NavItem } from "@working-theory/ui";
14
+
15
+ /** The internal services route — the directory under `app/[locale]/`. Always `/services`. */
16
+ export const SERVICES_ROUTE = "/services";
17
+
18
+ /** The services' public base: the site's `services.path`, or `/services` when it sets none. */
19
+ export const SERVICES_PATH: string =
20
+ process.env.NEXT_PUBLIC_AILK_SERVICES_PATH || SERVICES_ROUTE;
21
+
22
+ /**
23
+ * `href` at the internal services route — `/services`, or starting
24
+ * `/services/`, `/services?` or `/services#` — with that prefix replaced by
25
+ * `SERVICES_PATH`. Anything else (absolute URLs, `/servicesx`, a
26
+ * locale-prefixed path) is returned unchanged. The identity on a site with no
27
+ * `services.path`.
28
+ */
29
+ export function toPublicServicesHref(href: string): string {
30
+ if (SERVICES_PATH === SERVICES_ROUTE) return href;
31
+ const rest = href.slice(SERVICES_ROUTE.length);
32
+ if (href.startsWith(SERVICES_ROUTE) && (rest === "" || /^[/?#]/.test(rest))) {
33
+ return SERVICES_PATH + rest;
34
+ }
35
+ return href;
36
+ }
37
+
38
+ /**
39
+ * A locale-less pathname at or under `SERVICES_PATH`, mapped onto the internal
40
+ * route (`/services` + the rest); `null` for anything else, and always `null`
41
+ * on a site with no `services.path`.
42
+ */
43
+ export function toInternalServicesPath(bare: string): string | null {
44
+ if (SERVICES_PATH === SERVICES_ROUTE) return null;
45
+ if (bare === SERVICES_PATH) return SERVICES_ROUTE;
46
+ if (bare.startsWith(`${SERVICES_PATH}/`)) {
47
+ return SERVICES_ROUTE + bare.slice(SERVICES_PATH.length);
48
+ }
49
+ return null;
50
+ }
51
+
52
+ type NavFlyout = NonNullable<NavItem["flyout"]>;
53
+
54
+ function mapPath<T extends { path: string }>(entry: T): T {
55
+ return { ...entry, path: toPublicServicesHref(entry.path) };
56
+ }
57
+
58
+ function mapFlyout(flyout: NavFlyout): NavFlyout {
59
+ return {
60
+ ...flyout,
61
+ features: flyout.features.map(mapPath),
62
+ ...(flyout.groups
63
+ ? {
64
+ groups: flyout.groups.map((group) => ({
65
+ ...group,
66
+ items: group.items.map(mapPath),
67
+ })),
68
+ }
69
+ : {}),
70
+ actions: flyout.actions.map(mapPath),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Copies of the nav items with every services path mapped onto
76
+ * `SERVICES_PATH` — the item's own `path` and each flyout feature, group item
77
+ * and action. Every other field is carried by reference.
78
+ */
79
+ export function withPublicServicesPaths(items: NavItem[]): NavItem[] {
80
+ return items.map((item) => ({
81
+ ...item,
82
+ ...(item.path !== undefined
83
+ ? { path: toPublicServicesHref(item.path) }
84
+ : {}),
85
+ ...(item.flyout ? { flyout: mapFlyout(item.flyout) } : {}),
86
+ }));
87
+ }
88
+
89
+ /** Copies of the footer columns with every item's services path mapped onto `SERVICES_PATH`. */
90
+ export function withPublicServicesColumns<
91
+ C extends { items: { path: string }[] },
92
+ >(columns: C[]): C[] {
93
+ return columns.map((column) => ({
94
+ ...column,
95
+ items: column.items.map(mapPath),
96
+ }));
97
+ }
@@ -517,8 +517,13 @@ type HookContext = Parameters<
517
517
  /**
518
518
  * A refused magic-link verify: the redirect the plugin's own
519
519
  * `redirectWithError` builds — the request's `errorCallbackURL`, else its
520
- * `callbackURL`, else `/`, against the base URL, with `error=<code>`. The
521
- * plugin's origin check has already vetted both URLs.
520
+ * `callbackURL`, else `/`, against the base URL, with `error=<code>`.
521
+ *
522
+ * The query value is used as the router decoded it, never decoded again
523
+ * (#9896): a second decode turns `/%2F%2Fevil.com` into a URL on another
524
+ * host, and a malformed `%` into a throw. Whatever the plugin's origin check
525
+ * allowed, a value that does not resolve on the base URL's origin — or does
526
+ * not parse — is replaced by `/`.
522
527
  */
523
528
  function verifyRefusal(
524
529
  context: NonNullable<HookContext>,
@@ -527,10 +532,16 @@ function verifyRefusal(
527
532
  const raw =
528
533
  bodyField(context.query, "errorCallbackURL") ??
529
534
  bodyField(context.query, "callbackURL");
530
- const url = new URL(
531
- typeof raw === "string" ? decodeURIComponent(raw) : "/",
532
- context.context.baseURL,
533
- );
535
+ const { baseURL } = context.context;
536
+ let url = new URL("/", baseURL);
537
+ if (typeof raw === "string") {
538
+ try {
539
+ const resolved = new URL(raw, baseURL);
540
+ if (resolved.origin === url.origin) url = resolved;
541
+ } catch {
542
+ // Unparseable: stays `/`.
543
+ }
544
+ }
534
545
  url.searchParams.set("error", code);
535
546
  return context.redirect(url.toString());
536
547
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The site's header settings (#10046) — `_site.mdx`'s top-level `header`.
3
+ *
4
+ * `header.cta` replaces the signed-out Sign in / Sign up pair with the site's
5
+ * own button; `header.signIn` keeps Sign in beside it as a quieter text link
6
+ * (`link`, the default) or hides it. Signed in, the header is unchanged.
7
+ *
8
+ * A local frontmatter read rather than `@working-theory/site-config/server`,
9
+ * for the same reason as `app/[locale]/contact/site-contact-email.ts`:
10
+ * `architecture.yaml` keeps `node:fs` out of a tree that also carries edge
11
+ * routes (#1567). The value is validated against the canonical schema from
12
+ * the client-safe `/schema` entry.
13
+ */
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ import {
19
+ SiteConfigSchema,
20
+ type SiteHeader,
21
+ } from "@working-theory/site-config/schema";
22
+ import type { NavAction } from "@working-theory/ui/chrome";
23
+ import { parse as parseYaml } from "yaml";
24
+
25
+ import { resolveContentRoot } from "./site-brand";
26
+
27
+ /** The `labelKey` the CTA action carries; its test id is `nav-action-headerCta`. */
28
+ export const HEADER_CTA_LABEL_KEY = "headerCta";
29
+
30
+ type LinkAction = Extract<NavAction, { path: string }>;
31
+
32
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
33
+
34
+ const HeaderSchema = SiteConfigSchema.shape.header.unwrap();
35
+
36
+ let warned = false;
37
+
38
+ /**
39
+ * The validated `header`, else `undefined`. An absent file, frontmatter or
40
+ * key is silent; an invalid `header` warns once per process, naming the field
41
+ * path and never the value.
42
+ */
43
+ export function readSiteHeader(): SiteHeader | undefined {
44
+ try {
45
+ const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
46
+ const match = FRONTMATTER_RE.exec(raw);
47
+ if (!match) return undefined;
48
+ const fm: unknown = parseYaml(match[1] ?? "");
49
+ if (!fm || typeof fm !== "object") return undefined;
50
+
51
+ const { header } = fm as { header?: unknown };
52
+ if (header === undefined) return undefined;
53
+
54
+ // #10049 — `style` is page-chrome.ts's to validate and warn about; set it
55
+ // aside so a bad style never drops the button.
56
+ const parsed = HeaderSchema.safeParse(
57
+ header !== null && typeof header === "object" ? { ...header, style: undefined } : header,
58
+ );
59
+ if (parsed.success) return parsed.data;
60
+ if (!warned) {
61
+ warned = true;
62
+ const issue = parsed.error.issues[0];
63
+ console.warn(
64
+ `[site-header] ignoring _site.mdx header: ${["header", ...(issue?.path ?? [])].join(".")} ${issue?.message ?? ""}`,
65
+ );
66
+ }
67
+ return undefined;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The signed-out header actions. No `cta` → the auth pair as given. A `cta`
75
+ * → Sign in restyled as a text link (unless `signIn: hidden`), then the CTA
76
+ * as the primary button; Sign up is dropped. Never mutates `authActions`.
77
+ */
78
+ export function signedOutHeaderActions(
79
+ header: SiteHeader | undefined,
80
+ authActions: readonly LinkAction[],
81
+ ): LinkAction[] {
82
+ const cta = header?.cta;
83
+ if (!cta) return [...authActions];
84
+
85
+ const signIn = authActions.find((action) => action.labelKey === "signIn");
86
+ const signInPart: LinkAction[] =
87
+ signIn && header.signIn !== "hidden"
88
+ ? [{ ...signIn, variant: "ghost" }]
89
+ : [];
90
+ return [
91
+ ...signInPart,
92
+ { labelKey: HEADER_CTA_LABEL_KEY, path: cta.href, variant: "primary" },
93
+ ];
94
+ }
@@ -13,10 +13,45 @@
13
13
  // carries the submitter's address as `/sign-in?email=…` and the hub's
14
14
  // authorize step as `login_hint=…` — personal data GA and Sentry must not get.
15
15
  //
16
+ // `ref` joined by #9966 (D2, SEC-9966-7): the hub's consent page,
17
+ // `/account/connect?ref=…`, carries a sealed authorize continuation.
18
+ //
16
19
  // Mirrored (duplicated, not shared) in packages/analytics/src/scrub.ts,
17
20
  // since apps/* must not import @working-theory/* packages — keep both lists
18
21
  // in sync (#5613).
19
- export const TOKEN_QUERY_PARAMS = ["g", "session_id", "email", "login_hint"];
22
+ export const TOKEN_QUERY_PARAMS = [
23
+ "g",
24
+ "session_id",
25
+ "email",
26
+ "login_hint",
27
+ "ref",
28
+ ];
29
+
30
+ // The same grant id rides a PATH too: `deliverables.openGrant` fetches
31
+ // `<hub>/v1/deliverables/<grantId>` server-side, so a sampled server trace or
32
+ // a fetch breadcrumb would carry it (#9162, PR #9152's security review). The
33
+ // id segment is replaced anywhere in the string, so a span description
34
+ // (`GET <url>`) is redacted too when a caller passes one. `/v1/deliverables/open/<slug>` is the ungated
35
+ // read; a slug is not a credential. apps/web only: the analytics mirror sees
36
+ // page URLs, never this server-side fetch.
37
+ const GRANT_PATH = /(\/v1\/deliverables\/)(?!open(?:[/?#\s]|$))[^/?#\s]+/g;
38
+ const GRANT_PATH_REDACTED = "$1:grantId";
39
+
40
+ // #9969 D9 (#8329) — the Stripe SDK's own request URLs carry a customer id:
41
+ // `customers.retrieve` in the path (`/v1/customers/cus_…`) and
42
+ // `subscriptions.list` in the query (`?customer=cus_…`), and server-side
43
+ // Sentry records those outgoing requests. A changeset once stated this as a
44
+ // precondition for turning server telemetry on; it now lives here, with its
45
+ // enforcement. Server-side only, so not mirrored into packages/analytics:
46
+ // GA sees page URLs, never a Stripe API URL.
47
+ const STRIPE_ID_QUERY_PARAMS = ["customer"];
48
+ const STRIPE_CUSTOMER_SEGMENT = /\/cus_[A-Za-z0-9]+(?=[/?#\s]|$)/g;
49
+ const STRIPE_CUSTOMER_SEGMENT_REDACTED = "/cus_redacted";
50
+
51
+ const SCRUBBED_QUERY_PARAMS = [
52
+ ...TOKEN_QUERY_PARAMS,
53
+ ...STRIPE_ID_QUERY_PARAMS,
54
+ ];
20
55
 
21
56
  // Sentry navigation breadcrumbs (`data.to`/`data.from`) are relative
22
57
  // (`path?search`, no origin) rather than the absolute URLs GA and
@@ -29,7 +64,10 @@ function hasScheme(url: string): boolean {
29
64
  return /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(url) || url.startsWith("//");
30
65
  }
31
66
 
32
- export function scrubTelemetryUrl(url: string): string {
67
+ export function scrubTelemetryUrl(input: string): string {
68
+ const url = input
69
+ .replace(GRANT_PATH, GRANT_PATH_REDACTED)
70
+ .replace(STRIPE_CUSTOMER_SEGMENT, STRIPE_CUSTOMER_SEGMENT_REDACTED);
33
71
  const relative = !hasScheme(url);
34
72
  let parsed: URL;
35
73
  try {
@@ -39,7 +77,7 @@ export function scrubTelemetryUrl(url: string): string {
39
77
  }
40
78
 
41
79
  let changed = false;
42
- for (const param of TOKEN_QUERY_PARAMS) {
80
+ for (const param of SCRUBBED_QUERY_PARAMS) {
43
81
  if (parsed.searchParams.has(param)) {
44
82
  parsed.searchParams.delete(param);
45
83
  changed = true;
@@ -52,3 +90,30 @@ export function scrubTelemetryUrl(url: string): string {
52
90
  const result = parsed.toString();
53
91
  return relative ? result.slice(RELATIVE_URL_BASE.length) : result;
54
92
  }
93
+
94
+ /**
95
+ * A bare query string (`?a=1&b=2`, with or without the `?`) with the same
96
+ * params dropped — the SDK records an outgoing request's query under
97
+ * `http.query` apart from its URL (#9969 D9).
98
+ */
99
+ export function scrubTelemetryQuery(query: string): string {
100
+ const params = new URLSearchParams(query);
101
+ let changed = false;
102
+ for (const param of SCRUBBED_QUERY_PARAMS) {
103
+ if (params.has(param)) {
104
+ params.delete(param);
105
+ changed = true;
106
+ }
107
+ }
108
+ if (!changed) return query;
109
+ const rest = params.toString();
110
+ return rest === "" ? "" : `${query.startsWith("?") ? "?" : ""}${rest}`;
111
+ }
112
+
113
+ /** An HTTP client span's `<METHOD> <url>` with its URL scrubbed (#9969 D9). */
114
+ export function scrubSpanDescription(description: string): string {
115
+ const match = /^([A-Z]+) (\S+)$/.exec(description);
116
+ return match
117
+ ? `${match[1]} ${scrubTelemetryUrl(match[2]!)}`
118
+ : scrubTelemetryUrl(description);
119
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The template-listing mapping (#10051 D6) — a TemplatePage's frontmatter to
3
+ * the /templates hub's card and to the generated sections of its own
4
+ * /templates/<slug> page. Pure, no I/O: the routes load the pages and the
5
+ * translated labels and pass them in, so no user-facing copy lives here
6
+ * (customer-facing-i18n, ADR 0019).
7
+ *
8
+ * Base tree: no paid import, no `oss: false`.
9
+ */
10
+ import { SITE_CATEGORIES } from "@working-theory/schema/categories";
11
+ import type { PageSection } from "@working-theory/validation";
12
+
13
+ /** The `templates` messages namespace, read by the routes (#10051 D8). */
14
+ export interface TemplateLabels {
15
+ getIt: string;
16
+ startFree: string;
17
+ viewDemo: string;
18
+ viewTemplate: string;
19
+ included: string;
20
+ demoBadge: string;
21
+ /** The tail of a category label cut at {@link MAX_CATEGORY_NAMES}: "+9 more". */
22
+ moreCategories: (count: number) => string;
23
+ }
24
+
25
+ /** Read the labels from the `templates` namespace's translator. */
26
+ export function templateLabels(
27
+ t: (key: keyof TemplateLabels, values?: { count: number }) => string,
28
+ ): TemplateLabels {
29
+ return {
30
+ getIt: t("getIt"),
31
+ startFree: t("startFree"),
32
+ viewDemo: t("viewDemo"),
33
+ viewTemplate: t("viewTemplate"),
34
+ included: t("included"),
35
+ demoBadge: t("demoBadge"),
36
+ moreCategories: (count) => t("moreCategories", { count }),
37
+ };
38
+ }
39
+
40
+ /**
41
+ * At most this many category names show in a card or hero label; the rest
42
+ * collapse into "+N more", so a template listed under twelve categories keeps
43
+ * a one-line label and the card titles in a row stay aligned (design review
44
+ * round 1, B2).
45
+ */
46
+ export const MAX_CATEGORY_NAMES = 3;
47
+
48
+ /** The TemplatePage frontmatter fields this mapping reads. */
49
+ interface TemplateFrontmatter {
50
+ title?: string;
51
+ description?: string;
52
+ summary?: string;
53
+ thumbnail?: string;
54
+ categories?: readonly string[];
55
+ demoUrl?: string;
56
+ getItUrl?: string;
57
+ price?: number;
58
+ includes?: readonly { title: string; description: string }[];
59
+ }
60
+
61
+ type CardItem = Extract<
62
+ PageSection,
63
+ { type: "items" }
64
+ >["data"]["items"][number];
65
+
66
+ function fields(frontmatter: unknown): TemplateFrontmatter {
67
+ return (frontmatter ?? {}) as TemplateFrontmatter;
68
+ }
69
+
70
+ /**
71
+ * The known category display names, the first {@link MAX_CATEGORY_NAMES}
72
+ * joined and the rest counted; undefined when none is known.
73
+ */
74
+ function categoryNames(
75
+ categories: readonly string[] | undefined,
76
+ labels: TemplateLabels,
77
+ ): string | undefined {
78
+ const names = (categories ?? []).flatMap((slug) => {
79
+ const found = SITE_CATEGORIES.find((category) => category.slug === slug);
80
+ return found === undefined ? [] : [found.name];
81
+ });
82
+ if (names.length === 0) return undefined;
83
+ const shown = names.slice(0, MAX_CATEGORY_NAMES);
84
+ const hidden = names.length - shown.length;
85
+ return hidden > 0
86
+ ? [...shown, labels.moreCategories(hidden)].join(" · ")
87
+ : shown.join(" · ");
88
+ }
89
+
90
+ /** One /templates card for a TemplatePage. */
91
+ export function templateCard(
92
+ page: { slug: string; frontmatter: unknown },
93
+ labels: TemplateLabels,
94
+ ): CardItem {
95
+ const fm = fields(page.frontmatter);
96
+ const title = fm.title ?? page.slug;
97
+ const eyebrow = categoryNames(fm.categories, labels);
98
+ return {
99
+ title,
100
+ description: fm.summary ?? fm.description ?? "",
101
+ ...(fm.thumbnail !== undefined && {
102
+ image: { src: fm.thumbnail, alt: title },
103
+ }),
104
+ ...(eyebrow !== undefined && { eyebrow }),
105
+ ...(fm.demoUrl !== undefined && { badge: labels.demoBadge }),
106
+ link: { label: labels.viewTemplate, href: `/templates/${page.slug}` },
107
+ };
108
+ }
109
+
110
+ /** "Start free" for a template priced at exactly 0, else "Get it". */
111
+ export function templateActionLabel(
112
+ price: number | undefined,
113
+ labels: TemplateLabels,
114
+ ): string {
115
+ return price === 0 ? labels.startFree : labels.getIt;
116
+ }
117
+
118
+ /**
119
+ * The generated top of a /templates/<slug> page: a hero carrying the name,
120
+ * summary, thumbnail, categories, the Get it / Start free action and the
121
+ * View demo link, then the "What's included" list.
122
+ */
123
+ export function templateDetailSections(
124
+ frontmatter: unknown,
125
+ labels: TemplateLabels,
126
+ ): [PageSection, PageSection] {
127
+ const fm = fields(frontmatter);
128
+ const title = fm.title ?? "";
129
+ const subheadline = fm.summary ?? fm.description;
130
+ const eyebrow = categoryNames(fm.categories, labels);
131
+ const hero: PageSection = {
132
+ type: "hero",
133
+ id: "template-hero",
134
+ data: {
135
+ headline: title,
136
+ ...(subheadline !== undefined && { subheadline }),
137
+ ...(eyebrow !== undefined && { eyebrow }),
138
+ ...(fm.thumbnail !== undefined && {
139
+ image: { src: fm.thumbnail, alt: title },
140
+ }),
141
+ ...(fm.getItUrl !== undefined && {
142
+ primaryCta: {
143
+ label: templateActionLabel(fm.price, labels),
144
+ href: fm.getItUrl,
145
+ },
146
+ }),
147
+ ...(fm.demoUrl !== undefined && {
148
+ secondaryCta: {
149
+ label: labels.viewDemo,
150
+ href: fm.demoUrl,
151
+ variant: "outline" as const,
152
+ },
153
+ }),
154
+ },
155
+ };
156
+ const included: PageSection = {
157
+ type: "items",
158
+ id: "template-included",
159
+ data: {
160
+ headline: labels.included,
161
+ variant: "minimal",
162
+ items: (fm.includes ?? []).map(({ title: rowTitle, description }) => ({
163
+ title: rowTitle,
164
+ description,
165
+ })),
166
+ },
167
+ };
168
+ return [hero, included];
169
+ }
@@ -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.",
@@ -1058,7 +1077,8 @@
1058
1077
  "site": "Site",
1059
1078
  "team": "Team",
1060
1079
  "magnets": "Magnets",
1061
- "meetings": "Meetings"
1080
+ "meetings": "Meetings",
1081
+ "integrations": "Integrations"
1062
1082
  },
1063
1083
  "general": {
1064
1084
  "heading": "General",
@@ -1250,6 +1270,115 @@
1250
1270
  "privateOffToast": "Private: off for {title}",
1251
1271
  "errorToast": "Couldn't update"
1252
1272
  }
1273
+ },
1274
+ "integrations": {
1275
+ "heading": "Integrations",
1276
+ "lead": "Connect the services your site works with.",
1277
+ "slack": {
1278
+ "title": "Connection",
1279
+ "connectedBadge": "Connected",
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
+ },
1316
+ "botTokenInvalid": "A bot token starts with xoxb- and holds only letters, digits and hyphens.",
1317
+ "signingSecretInvalid": "A signing secret is 32 to 128 letters and digits.",
1318
+ "tokenRefused": {
1319
+ "tokenRefused": "Slack refused this token. Check it and try again.",
1320
+ "notBotToken": "This isn't a bot token. Copy the Bot User OAuth Token (xoxb-).",
1321
+ "enterpriseInstall": "Enterprise Grid installs aren't supported yet."
1322
+ },
1323
+ "scopesMissing": "The Slack app is missing these scopes:",
1324
+ "paidPlanRequired": "Slack Connect needs a paid Slack plan.",
1325
+ "teamConnectedElsewhere": "This Slack workspace is already connected to another site.",
1326
+ "slackUnreachable": "Slack couldn't be reached. Try again in a moment.",
1327
+ "connectFailed": "Couldn't connect Slack. Try again.",
1328
+ "disconnectFailed": "Couldn't disconnect Slack. Try again.",
1329
+ "confirmReplace": {
1330
+ "title": "Replace the Slack connection?",
1331
+ "description": "Prospect channels will open in the new Slack workspace.",
1332
+ "action": "Replace"
1333
+ },
1334
+ "confirmDisconnect": {
1335
+ "title": "Disconnect Slack?",
1336
+ "description": "New bookings will stop opening prospect channels until Slack is connected again.",
1337
+ "action": "Disconnect"
1338
+ }
1339
+ },
1340
+ "prospectChannel": {
1341
+ "heading": "Prospect channels",
1342
+ "lead": "Open a shared Slack channel with each prospect who books a meeting.",
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",
1353
+ "mergeFields": {
1354
+ "firstName": "The prospect's first name",
1355
+ "hostName": "The host's name",
1356
+ "callTime": "The meeting's time",
1357
+ "interest": "What the prospect is interested in",
1358
+ "timeline": "The prospect's timeline",
1359
+ "meetingLink": "The meeting link"
1360
+ },
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",
1366
+ "archiveDays": "Archive after (days)",
1367
+ "save": "Save",
1368
+ "saving": "Saving…",
1369
+ "saved": "Saved",
1370
+ "saveError": "Couldn't save. Try again.",
1371
+ "slackNotConnected": "Slack isn't connected, so these settings can't be changed.",
1372
+ "connectSlackLink": "Connect Slack",
1373
+ "errors": {
1374
+ "memberIds": "Enter Slack member ids like U012AB3CD, separated by commas, with no repeats (up to 50).",
1375
+ "welcomeTemplate": "Use only the merge fields listed, in up to 4,000 characters.",
1376
+ "channelPrefix": "Use lowercase letters, digits, - and _, starting with a letter or digit (up to 21).",
1377
+ "archiveDays": "Enter a whole number of days from 1 to 365."
1378
+ },
1379
+ "unavailableHeadline": "Prospect channel settings couldn't be read",
1380
+ "unavailableDescription": "Reload the page to try again, or come back later."
1381
+ }
1253
1382
  }
1254
1383
  },
1255
1384
  "edit": {
@@ -1370,5 +1499,17 @@
1370
1499
  "already": "You were already unsubscribed.",
1371
1500
  "invalid": "This link isn't valid.",
1372
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
+ }
1373
1514
  }
1374
1515
  }