create-ailk 0.1.1 → 0.3.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 (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. package/templates/apps/web/source.config.ts +0 -78
@@ -1,398 +0,0 @@
1
- /**
2
- * @file playbook-compile.ts
3
- * @description FEAT-074 U4a (#4715) — compiles a scored `targeting-maturity`
4
- * signup into a person-scoped Playbook and persists it append-only.
5
- *
6
- * `compilePlaybook` is a PURE function (`rules/execution-substrate.md` — the
7
- * compile is a payload, never a per-respondent model call): given a scored
8
- * `WaitlistScore` row's facts (`{id, tier, ruleTrace}`), the play library,
9
- * and the lead id, it returns `{source, markdown}` — no Prisma, no network,
10
- * no clock, no filesystem read of its own. `source` validates against
11
- * `vendor/playbook-method/playbook-source.schema.json` (U1) and passes
12
- * `vendor/playbook-method/check-playbook.py` — see this file's own test
13
- * suite, which runs the vendored checker against every golden.
14
- *
15
- * `compileAndStorePlaybook` is the thin, fail-soft Prisma-touching wrapper —
16
- * the sibling to A2's `waitlist-scoring.ts` glue. It loads the vendored
17
- * play library LAZILY, memoized, on first use inside its own try block (a
18
- * `readFileSync`, not a static `import … from "*.json"` — `apps/api`'s
19
- * `tsconfig.json` sets `rootDir: "./src"` + `composite: true`, and the
20
- * vendored library lives at the repo root, outside that boundary;
21
- * `apps-paid/design-reference-gallery/lib/corpus.ts` is this repo's own
22
- * precedent for reading vendored repo-root content via `readFileSync`
23
- * rather than a cross-boundary static import). Deliberately NOT at module
24
- * scope (security-review finding M1, PR #4727): a missing/corrupt vendored
25
- * file must degrade like any other compile failure (D4), never throw at
26
- * import time where no catch in this file can reach it. `compilePlaybook`
27
- * itself never touches the filesystem.
28
- *
29
- * The path is resolved via `process.cwd()`-relative candidate probing
30
- * (`apps/api/src/lib/locales.ts`'s own `resolveProjectYamlPath` pattern),
31
- * NOT `import.meta.url` — Jest's ts-jest preset here compiles to CommonJS
32
- * (`jest.config.js`), which cannot execute `import.meta` (Node throws
33
- * `SyntaxError: Cannot use 'import.meta' outside a module`); this repo's own
34
- * `moduleNameMapper` comment on `@working-theory/database` names the exact
35
- * same v7 prisma-client `import.meta.url` incompatibility and mocks around
36
- * it. `process.cwd()` has no such split — it resolves correctly whether the
37
- * process was started from `apps/api/` (dev/build) or from this package's
38
- * own test run.
39
- *
40
- * The unlock map (`unlocks_next`) is LIBRARY-DECLARED, resolved from
41
- * `levels[level].unlocks_next` — NEVER computed as `level + 1` (the
42
- * method's map is not monotonic: Level 5 unlocks Level 7, Level 8 unlocks
43
- * Level 7 — spec §2.1, U3 Clarifications).
44
- */
45
-
46
- import { readFileSync } from "node:fs";
47
- import { join } from "node:path";
48
-
49
- import type { Prisma } from "@working-theory/database";
50
- import { logger } from "@working-theory/observability";
51
-
52
- import { prisma } from "../lib/prisma.js";
53
-
54
- // ─── Play-library types (the vendored targeting-maturity.plays.json shape) ──
55
-
56
- export interface PlaybookPlay {
57
- id: string;
58
- title: string;
59
- text: string;
60
- }
61
-
62
- export interface PlaybookLevelEntry {
63
- level: number;
64
- name?: string;
65
- use_if?: string;
66
- cautions?: string[];
67
- plays: PlaybookPlay[];
68
- unlocks_next: number | null;
69
- }
70
-
71
- export interface PlayLibrary {
72
- version: string;
73
- method: string;
74
- /** A bare pointer object at level 0 in the authored shape (D4) — the Gate's
75
- * own plays live in `levels[]` at `level: 0`; consulted only as a fallback
76
- * when `levels[]` carries no level-0 entry (mirrors
77
- * `check-playbook.py`'s `_library_entry_for_level`). */
78
- gate?: { level: number; plays?: PlaybookPlay[]; unlocks_next?: number | null };
79
- levels: PlaybookLevelEntry[];
80
- }
81
-
82
- // ─── The vendored play library — lazy-loaded, memoized (see header) ─────────
83
-
84
- const VENDORED_LIBRARY_RELATIVE = "vendor/playbook-method/targeting-maturity.plays.json";
85
-
86
- /** Probes `[cwd, cwd/.., cwd/../..]` for the vendored library — apps run
87
- * from their own dir in dev/build (`apps/api/`) but this package's own
88
- * tests may run from either `apps/api/` or the repo root (mirrors
89
- * `apps/api/src/lib/locales.ts`'s `resolveProjectYamlPath`). Falls back to
90
- * the cwd-relative path so a genuine miss's error names the expected
91
- * location. */
92
- export function resolveVendoredLibraryPath(cwd = process.cwd()): string {
93
- const candidates = [
94
- join(cwd, VENDORED_LIBRARY_RELATIVE),
95
- join(cwd, "..", VENDORED_LIBRARY_RELATIVE),
96
- join(cwd, "..", "..", VENDORED_LIBRARY_RELATIVE),
97
- ];
98
- for (const candidate of candidates) {
99
- try {
100
- readFileSync(candidate, "utf8");
101
- return candidate;
102
- } catch {
103
- // continue
104
- }
105
- }
106
- return candidates[0]!;
107
- }
108
-
109
- export function loadVendoredPlayLibrary(): PlayLibrary {
110
- return JSON.parse(readFileSync(resolveVendoredLibraryPath(), "utf8")) as PlayLibrary;
111
- }
112
-
113
- // LAZY + memoized (security-review finding M1, PR #4727): loading the
114
- // library at MODULE SCOPE would let a missing/corrupt vendored file throw
115
- // at import time — outside every try/catch this file adds, and outside the
116
- // fail-soft contract this whole unit exists to uphold. On a bundler that
117
- // cannot statically resolve the `process.cwd()`-relative repo-root path
118
- // (e.g. a serverless target with no `functions.includeFiles` entry for
119
- // `vendor/**`), that import-time throw would 500 EVERY request through this
120
- // module's static import chain (`waitlist-scoring.ts` → `flow-engine.ts` →
121
- // the route layer → the server entrypoint) — not merely fail to compile a
122
- // Playbook. Deferring the read to first use inside `compileAndStorePlaybook`'s
123
- // own try block (below) means a missing/corrupt file degrades exactly like
124
- // any other compile failure (D4: logged, the score is unaffected) instead of
125
- // taking the process down. Memoized so the read still happens at most once.
126
- let vendoredPlayLibraryCache: PlayLibrary | undefined;
127
- function vendoredPlayLibrary(): PlayLibrary {
128
- if (!vendoredPlayLibraryCache) {
129
- vendoredPlayLibraryCache = loadVendoredPlayLibrary();
130
- }
131
- return vendoredPlayLibraryCache;
132
- }
133
-
134
- /** The one experiment this compile hooks (D4) — every other experiment's
135
- * `scoreCompletedSignup` completion never reaches this module. */
136
- export const TARGETING_MATURITY_EXPERIMENT_KEY = "targeting-maturity";
137
-
138
- // ─── compilePlaybook — the pure payload (D1) ────────────────────────────────
139
-
140
- export interface PlaybookSource {
141
- artifact_type: "playbook";
142
- scope: "person";
143
- scope_ref: string;
144
- method: string;
145
- level: number;
146
- plays: string[];
147
- unlocks_next?: string[];
148
- compiled_from: string;
149
- }
150
-
151
- /** The WaitlistScore facts `compilePlaybook` reads — a subset of the Prisma
152
- * row, never the row itself (the pure function takes no Prisma type). */
153
- export interface CompilePlaybookScoreInput {
154
- /** `WaitlistScore.id` — becomes `source.compiled_from` (D2). */
155
- id: string;
156
- /** `WaitlistScore.tier` — a `level_N` string. */
157
- tier: string;
158
- /** `WaitlistScore.ruleTrace` — opaque at this boundary, mirroring
159
- * `packages/validation`'s own `ruleTrace: z.unknown()` convention; parsed
160
- * defensively for the Level-1 loop-back framing only (see
161
- * `decidingRefNames`). */
162
- ruleTrace: unknown;
163
- }
164
-
165
- export interface CompilePlaybookInput {
166
- score: CompilePlaybookScoreInput;
167
- plays: PlayLibrary;
168
- leadId: string;
169
- }
170
-
171
- export interface CompilePlaybookResult {
172
- source: PlaybookSource;
173
- markdown: string;
174
- }
175
-
176
- /** A `compilePlaybook` input that cannot be compiled — the library declares
177
- * no Level for the score's tier, or that Level has no plays (spec §5.3 — the
178
- * play-library owner's defect, never a compile branch). */
179
- export class PlaybookCompileError extends Error {}
180
-
181
- const TIER_LEVEL_RE = /^level_(\d+)$/;
182
-
183
- /** The library entry for `level` — `levels[]` first, the `gate` pointer as a
184
- * level-0 fallback only (mirrors `check-playbook.py`'s `_library_entry_for_level`,
185
- * the #8496 amendment). */
186
- function levelEntryFor(library: PlayLibrary, level: number): PlaybookLevelEntry | undefined {
187
- const fromLevels = library.levels.find((entry) => entry.level === level);
188
- if (fromLevels) return fromLevels;
189
- if (level === 0 && library.gate?.plays && library.gate.plays.length > 0) {
190
- return {
191
- level: 0,
192
- plays: library.gate.plays,
193
- unlocks_next: library.gate.unlocks_next ?? null,
194
- };
195
- }
196
- return undefined;
197
- }
198
-
199
- /**
200
- * The `ref(<name>)` def names that DECIDED the matched tier — every
201
- * `kind: "predicate"` trace entry whose `tier` equals the matched tier AND
202
- * whose `result` is `true`, with every `ref(...)` segment in its `path`
203
- * extracted (`RuleTraceEntry.path` embeds each traversed def as
204
- * `ref(<name>)`, per `evaluatePredicateNode`'s own path-building — there is
205
- * no separate `def` field to read instead). Duck-typed, not
206
- * `ruleTraceEntrySchema`-parsed: `ruleTrace` is `unknown` at every boundary
207
- * this function is reached from (Prisma `Json`, the dashboard's own
208
- * `z.unknown()`), and this compile never needs the full shape — only
209
- * whether a specific ref fired true for this tier.
210
- */
211
- function decidingRefNames(ruleTrace: unknown, tier: string): Set<string> {
212
- const names = new Set<string>();
213
- if (!Array.isArray(ruleTrace)) return names;
214
- for (const entry of ruleTrace) {
215
- if (typeof entry !== "object" || entry === null) continue;
216
- const e = entry as Record<string, unknown>;
217
- if (e["kind"] !== "predicate" || e["tier"] !== tier || e["result"] !== true) continue;
218
- const path = e["path"];
219
- if (typeof path !== "string") continue;
220
- for (const m of path.matchAll(/ref\(([^)]+)\)/g)) {
221
- names.add(m[1]!);
222
- }
223
- }
224
- return names;
225
- }
226
-
227
- function renderPlaybookMarkdown(args: {
228
- source: PlaybookSource;
229
- entry: PlaybookLevelEntry;
230
- unlockEntry: PlaybookLevelEntry | undefined;
231
- isLoopBackCohort: boolean;
232
- }): string {
233
- const { source, entry, unlockEntry, isLoopBackCohort } = args;
234
- const heading = entry.name ? `Level ${source.level} — ${entry.name}` : `Level ${source.level}`;
235
- const lines: string[] = [];
236
-
237
- lines.push(`# Playbook — ${heading}`, "");
238
-
239
- if (isLoopBackCohort) {
240
- lines.push(
241
- "> You reached Level 1 via the Level-6 loop-back — a confirmed-Fit " +
242
- "cohort (not paying customers) put you here. Once this Level has " +
243
- "given you a validated ICP filter, revisit at Level 7 or 8.",
244
- "",
245
- );
246
- }
247
-
248
- lines.push(
249
- "## §1. Identity",
250
- "",
251
- `**artifact_type:** \`${source.artifact_type}\``,
252
- `**scope:** \`${source.scope}\``,
253
- `**scope_ref:** \`${source.scope_ref}\``,
254
- `**method:** \`${source.method}\``,
255
- `**level:** \`${source.level}\``,
256
- `**compiled_from:** \`${source.compiled_from}\``,
257
- "",
258
- "<!-- machine-region -->",
259
- `<!-- playbook-source:\n${JSON.stringify(source)}\n-->`,
260
- "<!-- /machine-region -->",
261
- "",
262
- "---",
263
- "",
264
- "## §2. Plays",
265
- "",
266
- `### Level ${source.level}`,
267
- "",
268
- );
269
- for (const play of entry.plays) {
270
- lines.push(`- \`${play.id}\` — ${play.title}`);
271
- }
272
-
273
- if (unlockEntry && source.unlocks_next) {
274
- lines.push(
275
- "",
276
- "---",
277
- "",
278
- "## §3. Unlocks next",
279
- "",
280
- `### Level ${unlockEntry.level}`,
281
- "",
282
- );
283
- for (const play of unlockEntry.plays) {
284
- lines.push(`- \`${play.id}\` — ${play.title}`);
285
- }
286
- }
287
-
288
- return lines.join("\n") + "\n";
289
- }
290
-
291
- /**
292
- * Compile a scored `targeting-maturity` signup into a person-scoped
293
- * Playbook — pure, deterministic, no I/O (D1). Throws `PlaybookCompileError`
294
- * when the library declares no Level for the score's tier, or that Level
295
- * has no plays — the caller (`compileAndStorePlaybook`) is fail-soft over
296
- * this throw (D4, spec §5.3).
297
- */
298
- export function compilePlaybook(input: CompilePlaybookInput): CompilePlaybookResult {
299
- const { score, plays, leadId } = input;
300
-
301
- const match = TIER_LEVEL_RE.exec(score.tier);
302
- if (!match) {
303
- throw new PlaybookCompileError(
304
- `cannot compile a Playbook for tier "${score.tier}" — not a level_N tier`,
305
- );
306
- }
307
- const level = Number(match[1]);
308
-
309
- const entry = levelEntryFor(plays, level);
310
- if (!entry || entry.plays.length === 0) {
311
- throw new PlaybookCompileError(
312
- `the play library declares no plays for Level ${level} — a library defect, not a compile branch (spec §5.3)`,
313
- );
314
- }
315
-
316
- let unlockEntry: PlaybookLevelEntry | undefined;
317
- let unlockIds: string[] | undefined;
318
- if (entry.unlocks_next !== null && entry.unlocks_next !== undefined) {
319
- unlockEntry = levelEntryFor(plays, entry.unlocks_next);
320
- if (unlockEntry && unlockEntry.plays.length > 0) {
321
- unlockIds = unlockEntry.plays.map((play) => play.id);
322
- }
323
- }
324
-
325
- const source: PlaybookSource = {
326
- artifact_type: "playbook",
327
- scope: "person",
328
- scope_ref: leadId,
329
- method: plays.method,
330
- level,
331
- plays: entry.plays.map((play) => play.id),
332
- ...(unlockIds ? { unlocks_next: unlockIds } : {}),
333
- compiled_from: score.id,
334
- };
335
-
336
- const isLoopBackCohort =
337
- level === 1 && decidingRefNames(score.ruleTrace, score.tier).has("has_confirmed_fit_cohort");
338
-
339
- const markdown = renderPlaybookMarkdown({ source, entry, unlockEntry, isLoopBackCohort });
340
-
341
- return { source, markdown };
342
- }
343
-
344
- // ─── compileAndStorePlaybook — the fail-soft Prisma-touching wrapper (D4) ───
345
-
346
- export interface CompileAndStorePlaybookInput {
347
- siteId: string;
348
- leadId: string;
349
- experimentKey: string;
350
- scoreId: string;
351
- tier: string;
352
- ruleTrace: unknown;
353
- }
354
-
355
- /**
356
- * Compile + append a `PlaybookInstance` row. Fail-soft over BOTH the pure
357
- * compile (`PlaybookCompileError` or any other throw) and the Prisma write —
358
- * a compile/store failure is logged and never fails the signup or the score
359
- * (D4). The caller (`waitlist-scoring.ts`) awaits this unconditionally; it
360
- * never throws.
361
- */
362
- export async function compileAndStorePlaybook(
363
- input: CompileAndStorePlaybookInput,
364
- ): Promise<void> {
365
- const { siteId, leadId, experimentKey, scoreId, tier, ruleTrace } = input;
366
- try {
367
- const { source, markdown } = compilePlaybook({
368
- score: { id: scoreId, tier, ruleTrace },
369
- plays: vendoredPlayLibrary(),
370
- leadId,
371
- });
372
-
373
- await prisma.playbookInstance.create({
374
- data: {
375
- siteId,
376
- leadId,
377
- experimentKey,
378
- scoreId,
379
- tier,
380
- level: source.level,
381
- ruleTrace: (ruleTrace ?? null) as unknown as Prisma.InputJsonValue,
382
- source: source as unknown as Prisma.InputJsonValue,
383
- markdown,
384
- },
385
- });
386
- } catch (err) {
387
- logger.error(
388
- {
389
- siteId,
390
- experimentKey,
391
- leadId,
392
- kind: "playbook_compile_failed",
393
- errKind: err instanceof Error ? err.constructor.name : typeof err,
394
- },
395
- "[playbook-compile] compile/store failed — the score is unaffected",
396
- );
397
- }
398
- }
@@ -1,263 +0,0 @@
1
- /**
2
- * @file playbook-render.ts
3
- * @description FEAT-074 U4b (#4718) — renders a stored `PlaybookInstance` to
4
- * a customer-facing PDF, on request.
5
- *
6
- * `renderPlaybookPdf({source, markdown, locale})` is a pure-TypeScript
7
- * renderer (D2, spec §3): a small typed template lays out the Level/Gate
8
- * heading, the Level's bound plays (title + body, resolved from `source`'s
9
- * `plays`/`unlocks_next` id lists against the LIVE vendored play library —
10
- * D1's "a template or renderer fix improves every already-issued Playbook"
11
- * intent, so this deliberately reads the library fresh at render time
12
- * rather than trusting anything frozen into the stored `markdown` snapshot),
13
- * and the unlocks-next block. `markdown` is accepted per the spec's literal
14
- * `{source, markdown, locale}` signature (matching `compilePlaybook`'s own
15
- * `{source, markdown}` output pair, the exact shape `PlaybookInstance`
16
- * persists) but is NOT parsed for content in this unit — `source`'s id
17
- * lists plus a live library lookup already carry everything §2.1 requires
18
- * (the Level label, the bound plays, the unlocks-next block); reserved for
19
- * a future direct-render use.
20
- *
21
- * Renderer choice (D2, recorded per the dispatch's own requirement): PDF.js
22
- * (mozilla's) sibling project `pdf-lib` (v1.17.1, MIT) — pure TypeScript,
23
- * zero native dependencies (its own runtime deps —
24
- * `@pdf-lib/standard-fonts`, `@pdf-lib/upng`, `pako`, `tslib` — are all pure
25
- * JS/TS too), no headless browser, no postinstall download. It embeds the
26
- * 14 PDF standard fonts (Helvetica here) with no external font files, and
27
- * exposes `font.widthOfTextAtSize` for the manual word-wrap this file
28
- * implements (pdf-lib has no built-in text-flow/wrap primitive — a small
29
- * typed template has no need for one).
30
- *
31
- * A play id from `source.plays`/`source.unlocks_next` that the CURRENT
32
- * vendored library no longer declares is a library defect, never a render
33
- * branch — mirrors `playbook-compile.ts`'s own `PlaybookCompileError`
34
- * posture ("a library defect, not a compile branch") applied symmetrically
35
- * to render.
36
- *
37
- * Every CHROME label (the Level/Gate heading text, the "Plays"/"Unlocks
38
- * next" section titles) is a `LocalizedString` resolved via
39
- * `resolveLocalizedString` (`@working-theory/validation`, the ONE shared
40
- * resolution rule — flow-i18n.ts's own header) against the request
41
- * `locale`. Play TITLES/BODIES are read straight from the vendored library,
42
- * which carries no `LocalizedString` shape of its own (English-only at the
43
- * source, the same convention `compilePlaybook`'s stored `markdown` already
44
- * uses) — the i18n requirement (spec §2.1) scopes to the renderer's own
45
- * chrome, not to library content. `renderPlaybookPdf`'s fixed
46
- * `{source, markdown, locale}` signature carries no separate flow
47
- * `defaultLocale` field, so this file's label table declares `en` as its
48
- * sole `defaultLocale` (a module constant, `LABEL_DEFAULT_LOCALE` below) —
49
- * every label below currently declares only `en`, so this is presently a
50
- * no-op fallback, kept for structural correctness against a future
51
- * multi-locale label table.
52
- *
53
- * The Gate (Level 0) is labelled "Gate," never "Level 0" (spec §2.1's
54
- * never-say-Level-0 rule — mirrors the operator dashboard's own
55
- * `waitlistDashboard.level.gate` key, U4a D5, and the AC-1 Gate filename
56
- * convention `playbook-gate.pdf`).
57
- */
58
-
59
- import { resolveLocalizedString } from "@working-theory/validation";
60
- import { PDFDocument, PageSizes, StandardFonts, type PDFFont, type PDFPage } from "pdf-lib";
61
-
62
- import { loadVendoredPlayLibrary, type PlaybookPlay, type PlaybookSource, type PlayLibrary } from "./playbook-compile.js";
63
-
64
- // ─── Chrome labels — the CUSTOMER-FACING structural text (spec §2.1) ──────
65
-
66
- const LABEL_DEFAULT_LOCALE = "en";
67
-
68
- const LABELS = {
69
- documentTitle: { en: "Your Playbook" },
70
- gate: { en: "Gate" },
71
- /** `{n}` is a literal placeholder — substituted after resolution. */
72
- levelLabel: { en: "Level {n}" },
73
- plays: { en: "Plays" },
74
- unlocksNext: { en: "Unlocks next" },
75
- } as const satisfies Record<string, Record<string, string>>;
76
-
77
- function label(key: keyof typeof LABELS, locale: string): string {
78
- return resolveLocalizedString(LABELS[key], locale, LABEL_DEFAULT_LOCALE);
79
- }
80
-
81
- /** Level 0 is "Gate," never "Level 0" (spec §2.1 — the never-say-Level-0 rule). */
82
- function levelHeading(level: number, locale: string): string {
83
- if (level === 0) return label("gate", locale);
84
- return label("levelLabel", locale).replace("{n}", String(level));
85
- }
86
-
87
- // ─── The vendored play library — a per-play id index ───────────────────────
88
-
89
- interface IndexedPlay {
90
- play: PlaybookPlay;
91
- level: number;
92
- levelName: string | undefined;
93
- }
94
-
95
- /** A `source.plays`/`source.unlocks_next` id the CURRENT vendored library no
96
- * longer declares — a library defect, never a render branch (mirrors
97
- * `PlaybookCompileError`'s identical posture in `playbook-compile.ts`). */
98
- export class PlaybookRenderError extends Error {}
99
-
100
- /** Flattens every Level's `plays[]` into one id-keyed index, carrying each
101
- * play's owning Level number + name — so an unlocks-next play id resolves
102
- * both its own title/text AND which Level it belongs to, with no need to
103
- * parse a `tm-<level>.<n>` id-format convention the schema does not
104
- * guarantee. */
105
- function buildPlayIndex(library: PlayLibrary): Map<string, IndexedPlay> {
106
- const index = new Map<string, IndexedPlay>();
107
- for (const entry of library.levels) {
108
- for (const play of entry.plays) {
109
- index.set(play.id, { play, level: entry.level, levelName: entry.name });
110
- }
111
- }
112
- return index;
113
- }
114
-
115
- function resolvePlays(index: Map<string, IndexedPlay>, ids: string[]): IndexedPlay[] {
116
- return ids.map((id) => {
117
- const entry = index.get(id);
118
- if (!entry) {
119
- throw new PlaybookRenderError(
120
- `the vendored play library declares no play "${id}" — a library defect, never a render branch (mirrors playbook-compile.ts's PlaybookCompileError posture)`,
121
- );
122
- }
123
- return entry;
124
- });
125
- }
126
-
127
- // ─── Word wrap + pagination (pdf-lib has no built-in text-flow primitive) ──
128
-
129
- const PAGE_SIZE = PageSizes.Letter; // [612, 792]
130
- const MARGIN = 54; // 0.75in
131
- const CONTENT_WIDTH = PAGE_SIZE[0] - MARGIN * 2;
132
- const TOP_Y = PAGE_SIZE[1] - MARGIN;
133
- const BOTTOM_Y = MARGIN;
134
-
135
- /** Greedy word-wrap against a font's measured width — pdf-lib exposes
136
- * `font.widthOfTextAtSize` but no wrap primitive of its own. */
137
- function wrapLine(text: string, font: PDFFont, size: number, maxWidth: number): string[] {
138
- const words = text.split(/\s+/).filter((w) => w.length > 0);
139
- const lines: string[] = [];
140
- let current = "";
141
- for (const word of words) {
142
- const candidate = current.length > 0 ? `${current} ${word}` : word;
143
- if (font.widthOfTextAtSize(candidate, size) <= maxWidth) {
144
- current = candidate;
145
- } else {
146
- if (current.length > 0) lines.push(current);
147
- current = word;
148
- }
149
- }
150
- if (current.length > 0) lines.push(current);
151
- return lines;
152
- }
153
-
154
- interface PdfWriter {
155
- heading(text: string, size: number): void;
156
- subheading(text: string, size: number): void;
157
- paragraph(text: string, size: number): void;
158
- spacer(height: number): void;
159
- save(): Promise<Uint8Array>;
160
- }
161
-
162
- /** A closure-scoped writer (`@working-theory/design-system/no-undisciplined-class`
163
- * forbids `this`-bearing stateful classes) — the mutable page/y cursor lives
164
- * in this function's own local variables, never on an instance. */
165
- async function createPdfWriter(): Promise<PdfWriter> {
166
- const doc = await PDFDocument.create();
167
- const regular = await doc.embedFont(StandardFonts.Helvetica);
168
- const bold = await doc.embedFont(StandardFonts.HelveticaBold);
169
-
170
- let page: PDFPage = doc.addPage(PAGE_SIZE);
171
- let y = TOP_Y;
172
-
173
- function ensureSpace(height: number): void {
174
- if (y - height < BOTTOM_Y) {
175
- page = doc.addPage(PAGE_SIZE);
176
- y = TOP_Y;
177
- }
178
- }
179
-
180
- function drawLines(lines: string[], font: PDFFont, size: number, lineHeight: number): void {
181
- for (const line of lines) {
182
- ensureSpace(lineHeight);
183
- page.drawText(line, { x: MARGIN, y: y - size, size, font });
184
- y -= lineHeight;
185
- }
186
- }
187
-
188
- return {
189
- heading(text, size) {
190
- ensureSpace(size * 1.6 + 8);
191
- drawLines(wrapLine(text, bold, size, CONTENT_WIDTH), bold, size, size * 1.4);
192
- y -= 8;
193
- },
194
- subheading(text, size) {
195
- ensureSpace(size * 1.6 + 6);
196
- drawLines(wrapLine(text, bold, size, CONTENT_WIDTH), bold, size, size * 1.35);
197
- y -= 6;
198
- },
199
- paragraph(text, size) {
200
- drawLines(wrapLine(text, regular, size, CONTENT_WIDTH), regular, size, size * 1.4);
201
- y -= 6;
202
- },
203
- spacer(height) {
204
- y -= height;
205
- },
206
- async save() {
207
- return doc.save();
208
- },
209
- };
210
- }
211
-
212
- // ─── renderPlaybookPdf ───────────────────────────────────────────────────────
213
-
214
- export interface RenderPlaybookPdfInput {
215
- source: PlaybookSource;
216
- /** Accepted per the spec's literal signature; not parsed for content in
217
- * this unit — see the file header. */
218
- markdown: string;
219
- locale: string;
220
- }
221
-
222
- /**
223
- * Renders a stored `PlaybookInstance` to a PDF: the Level/Gate heading, the
224
- * Level's bound plays (title + body from the LIVE vendored library), and
225
- * the unlocks-next block (spec §2.1). Throws `PlaybookRenderError` when the
226
- * current library no longer declares a play `source` names — a library
227
- * defect, never a render branch.
228
- */
229
- export async function renderPlaybookPdf(input: RenderPlaybookPdfInput): Promise<Uint8Array> {
230
- const { source, locale } = input;
231
-
232
- const library = loadVendoredPlayLibrary();
233
- const index = buildPlayIndex(library);
234
-
235
- const currentPlays = resolvePlays(index, source.plays);
236
- const unlockPlays = source.unlocks_next ? resolvePlays(index, source.unlocks_next) : [];
237
-
238
- const writer = await createPdfWriter();
239
-
240
- writer.heading(label("documentTitle", locale), 20);
241
- writer.subheading(levelHeading(source.level, locale), 15);
242
- writer.spacer(4);
243
-
244
- writer.subheading(label("plays", locale), 13);
245
- renderPlays(writer, currentPlays);
246
-
247
- if (unlockPlays.length > 0) {
248
- writer.spacer(10);
249
- const unlockLevel = unlockPlays[0]!.level;
250
- writer.subheading(`${label("unlocksNext", locale)} — ${levelHeading(unlockLevel, locale)}`, 13);
251
- renderPlays(writer, unlockPlays);
252
- }
253
-
254
- return writer.save();
255
- }
256
-
257
- function renderPlays(writer: PdfWriter, plays: IndexedPlay[]): void {
258
- for (const { play } of plays) {
259
- writer.paragraph(`• ${play.title}`, 11);
260
- writer.paragraph(play.text, 10);
261
- writer.spacer(4);
262
- }
263
- }