create-ailk 0.7.0 → 0.9.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 (310) hide show
  1. package/README.md +112 -10
  2. package/component-catalog.json +798 -49
  3. package/component-catalog.md +9 -6
  4. package/dist/add-module.d.ts +20 -0
  5. package/dist/add-module.js +43 -7
  6. package/dist/add-route.d.ts +56 -0
  7. package/dist/add-route.js +286 -0
  8. package/dist/apply-design-md.d.ts +7 -0
  9. package/dist/apply-design-md.js +50 -0
  10. package/dist/apply-design.d.ts +64 -0
  11. package/dist/apply-design.js +273 -0
  12. package/dist/claude-md.d.ts +111 -0
  13. package/dist/claude-md.js +334 -0
  14. package/dist/cli.js +114 -28
  15. package/dist/derive-architecture.js +41 -36
  16. package/dist/design-compile.d.ts +71 -0
  17. package/dist/design-compile.js +98 -0
  18. package/dist/design-package.d.ts +83 -0
  19. package/dist/design-package.js +468 -0
  20. package/dist/design-presets.d.ts +86 -0
  21. package/dist/design-presets.js +642 -0
  22. package/dist/design-scaffold.d.ts +76 -0
  23. package/dist/design-scaffold.js +220 -0
  24. package/dist/index.d.ts +2 -1
  25. package/dist/index.js +4 -1
  26. package/dist/legacy-nav-renderers.d.ts +45 -0
  27. package/dist/legacy-nav-renderers.js +545 -0
  28. package/dist/lib/auth-secret-file.d.ts +19 -0
  29. package/dist/lib/auth-secret-file.js +112 -0
  30. package/dist/lib/design-checks.d.ts +78 -0
  31. package/dist/lib/design-checks.js +174 -0
  32. package/dist/lib/extract-module-bundle.js +5 -1
  33. package/dist/lib/license-key-files.d.ts +9 -0
  34. package/dist/lib/license-key-files.js +2 -2
  35. package/dist/lib/module-license-gate.d.ts +89 -0
  36. package/dist/lib/module-license-gate.js +171 -12
  37. package/dist/lib/module-tier.d.ts +45 -16
  38. package/dist/lib/module-tier.js +76 -0
  39. package/dist/lib/pro-callout.js +10 -2
  40. package/dist/lib/render-verdict.d.ts +52 -0
  41. package/dist/lib/render-verdict.js +77 -0
  42. package/dist/lib/scaffold-render-sweep.d.ts +105 -0
  43. package/dist/lib/scaffold-render-sweep.js +236 -0
  44. package/dist/module-architecture.d.ts +19 -16
  45. package/dist/module-architecture.js +64 -21
  46. package/dist/parse-args.d.ts +88 -10
  47. package/dist/parse-args.js +272 -21
  48. package/dist/programmatic.d.ts +66 -12
  49. package/dist/programmatic.js +361 -34
  50. package/dist/site-chrome.d.ts +19 -9
  51. package/dist/site-chrome.js +23 -13
  52. package/dist/site-manifest.d.ts +25 -0
  53. package/dist/site-manifest.js +254 -0
  54. package/dist/site-roster.d.ts +41 -0
  55. package/dist/site-roster.js +149 -11
  56. package/dist/starter-grid.d.ts +232 -0
  57. package/dist/starter-grid.js +545 -0
  58. package/dist/surfaces.d.ts +41 -9
  59. package/dist/surfaces.js +268 -92
  60. package/dist/template-manifest.d.ts +65 -29
  61. package/dist/template-manifest.js +161 -382
  62. package/dist/template-names.d.ts +14 -12
  63. package/dist/template-names.js +13 -17
  64. package/package.json +5 -4
  65. package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
  66. package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
  67. package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
  68. package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
  69. package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
  70. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
  71. package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
  72. package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
  73. package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
  74. package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
  75. package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
  76. package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
  77. package/site-templates/_base/content/en/UseCase/persona-1.mdx +70 -0
  78. package/site-templates/_base/content/en/UseCase/persona-2.mdx +70 -0
  79. package/site-templates/_base/content/en/UseCase/persona-3.mdx +70 -0
  80. package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
  81. package/site-templates/designs/minimalism/DESIGN.md +98 -6
  82. package/site-templates/designs/minimalism/sample/README.md +48 -0
  83. package/site-templates/designs/standard/DESIGN.md +103 -6
  84. package/site-templates/marketing-site/DESIGN.md +103 -6
  85. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +144 -47
  86. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +82 -41
  87. package/site-templates/marketing-site-free/DESIGN.md +103 -6
  88. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +150 -48
  89. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +114 -67
  90. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  91. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
  92. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
  93. package/site-templates/saas-product/DESIGN.md +103 -6
  94. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
  95. package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +210 -81
  96. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +356 -0
  97. package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
  98. package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
  99. package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
  100. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  101. package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
  102. package/site-templates/{minimalism-site → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
  103. package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
  104. package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
  105. package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
  106. package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
  107. package/site-templates/shapes/local-business-service/provenance.json +4 -0
  108. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +471 -0
  109. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +354 -0
  110. package/site-templates/shapes/organization-service/ailk-template.json +10 -0
  111. package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
  112. package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
  113. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  114. package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
  115. package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
  116. package/site-templates/{minimalism-site → shapes/organization-service}/content/en/ServicesList/services.mdx +3 -4
  117. package/site-templates/shapes/organization-service/provenance.json +4 -0
  118. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +478 -0
  119. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +353 -0
  120. package/site-templates/shapes/person-service/ailk-template.json +10 -0
  121. package/site-templates/shapes/person-service/content/_site.mdx +85 -0
  122. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
  123. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
  124. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
  125. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +171 -0
  126. package/site-templates/{simple-service → shapes/person-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +10 -11
  127. package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
  128. package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
  129. package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
  130. package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
  131. package/site-templates/shapes/person-service/provenance.json +4 -0
  132. package/templates/.claude/rules/architecture.md +7 -0
  133. package/templates/.claude/skills/README.md +5 -3
  134. package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
  135. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
  136. package/templates/.claude/skills/add-page/SKILL.md +241 -238
  137. package/templates/.claude/skills/add-page/references/page-types.md +85 -72
  138. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
  139. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
  140. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
  141. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
  142. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
  143. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
  144. package/templates/.claude/skills/check-site/SKILL.md +23 -0
  145. package/templates/.claude/skills/fill-page/SKILL.md +26 -7
  146. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
  147. package/templates/.claude/skills/setup-database/SKILL.md +156 -0
  148. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
  149. package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
  150. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +44 -36
  151. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
  152. package/templates/.claude/skills/update-page/SKILL.md +117 -0
  153. package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
  154. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
  155. package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
  156. package/templates/.github/workflows/ci.yml +4 -0
  157. package/templates/.prettierignore +1 -0
  158. package/templates/CLAUDE.md +146 -103
  159. package/templates/DESIGN.md +103 -6
  160. package/templates/README.md +63 -43
  161. package/templates/apps/api/.env.example +2 -1
  162. package/templates/apps/api/CLAUDE.md +3 -2
  163. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
  164. package/templates/apps/api/src/__tests__/server.test.ts +116 -0
  165. package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
  166. package/templates/apps/api/src/config/index.ts +35 -16
  167. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
  168. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
  169. package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
  170. package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
  171. package/templates/apps/api/src/lib/stripe.ts +9 -0
  172. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
  173. package/templates/apps/api/src/middleware/auth.ts +54 -0
  174. package/templates/apps/api/src/openapi/spec.ts +63 -12
  175. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
  176. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
  177. package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
  178. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
  179. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
  180. package/templates/apps/api/src/routes/leads/index.ts +46 -1
  181. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
  182. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
  183. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
  184. package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
  185. package/templates/apps/api/src/server.ts +35 -0
  186. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
  187. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
  188. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
  189. package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
  190. package/templates/apps/api/src/services/lead-routing.ts +140 -30
  191. package/templates/apps/api/src/services/leads-inbox.ts +48 -11
  192. package/templates/apps/api/src/services/revalidation.ts +60 -15
  193. package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
  194. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
  195. package/templates/apps/mcp/src/tools/create_project.ts +21 -6
  196. package/templates/apps/mcp/src/tools/purchase.ts +4 -2
  197. package/templates/apps/web/.env.example +13 -0
  198. package/templates/apps/web/CLAUDE.md +22 -28
  199. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
  200. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
  201. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
  202. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
  203. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
  204. package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
  205. package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
  206. package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
  207. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
  208. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
  209. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
  210. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
  211. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
  212. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
  213. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
  214. package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
  215. package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
  216. package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
  217. package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
  218. package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +21 -5
  219. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
  220. package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
  221. package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
  222. package/templates/apps/web/app/[locale]/layout.tsx +65 -17
  223. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
  224. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
  225. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
  226. package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
  227. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
  228. package/templates/apps/web/app/[locale]/{book-a-call → portfolio}/page.tsx +12 -13
  229. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
  230. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
  231. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
  232. package/templates/apps/web/app/api/revalidate/route.ts +78 -36
  233. package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
  234. package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
  235. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
  236. package/templates/apps/web/lib/locale-guard.ts +18 -0
  237. package/templates/apps/web/lib/meet-placeholder.ts +5 -0
  238. package/templates/apps/web/lib/nav-config.ts +50 -37
  239. package/templates/apps/web/lib/site-brand.tsx +13 -9
  240. package/templates/apps/web/lib/site-identity.ts +14 -0
  241. package/templates/apps/web/lib/site-logo.tsx +36 -0
  242. package/templates/apps/web/lib/site-theme.ts +46 -26
  243. package/templates/apps/web/messages/en.json +45 -5
  244. package/templates/apps/web/middleware.ts +28 -4
  245. package/templates/content/_site.mdx +4 -2
  246. package/templates/content/en/BlogList/blog.mdx +37 -16
  247. package/templates/content/en/BookCall/book-a-call.mdx +80 -0
  248. package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
  249. package/templates/content/en/BookCall/onsite-service.mdx +56 -0
  250. package/templates/content/en/BookCall/start-project.mdx +56 -0
  251. package/templates/content/en/ContactPage/contact.mdx +43 -34
  252. package/templates/content/en/HomePage/home.mdx +10 -22
  253. package/templates/content/en/LandingPage/meet.mdx +59 -0
  254. package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
  255. package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
  256. package/templates/database/CHANGELOG.md +14 -0
  257. package/templates/database/package.json +1 -1
  258. package/templates/package.json +2 -1
  259. package/templates/tools/check-claude-md.ts +128 -0
  260. package/versions.json +33 -33
  261. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -53
  262. package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
  263. package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
  264. package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
  265. package/site-templates/minimalism-site/DESIGN.md +0 -162
  266. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -470
  267. package/site-templates/minimalism-site/ailk-template.json +0 -23
  268. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
  269. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -53
  270. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
  271. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
  272. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
  273. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
  274. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
  275. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
  276. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
  277. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
  278. package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
  279. package/site-templates/minimalism-site/provenance.json +0 -4
  280. package/site-templates/simple-service/DESIGN.md +0 -162
  281. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
  282. package/site-templates/simple-service/ailk-template.json +0 -11
  283. package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
  284. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
  285. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
  286. package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
  287. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
  288. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
  289. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
  290. package/site-templates/simple-service/provenance.json +0 -4
  291. package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
  292. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
  293. package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
  294. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
  295. package/templates/content/en/SoftwareProduct/product.mdx +0 -175
  296. /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
  297. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
  298. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
  299. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
  300. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
  301. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
  302. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
  303. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
  304. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
  305. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
  306. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
  307. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
  308. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/first-engagement.mdx +0 -0
  309. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/second-engagement.mdx +0 -0
  310. /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/third-engagement.mdx +0 -0
@@ -0,0 +1,640 @@
1
+ #!/usr/bin/env -S pnpm exec tsx
2
+ /**
3
+ * update-page — move a page from `/old` to `/new` on this site, safely.
4
+ *
5
+ * pnpm exec tsx .claude/skills/update-page/scripts/update-page.ts plan --from /meet --to /schedule [--name "Title"] [--json]
6
+ * pnpm exec tsx .claude/skills/update-page/scripts/update-page.ts apply --from /meet --to /schedule [--name "Title"] --confirm <hash>
7
+ *
8
+ * `plan` writes nothing: it prints every file it would create / move / modify /
9
+ * delete, the notes a reader should see, and a plan hash. `apply` re-plans
10
+ * and writes ONLY when the hash it computes equals `--confirm` — so a plan the
11
+ * user never saw cannot be applied, and a file that changed between plan and
12
+ * apply invalidates the hash.
13
+ *
14
+ * Exit codes: 0 done · 1 hash mismatch or missing (nothing written) · 2 usage ·
15
+ * 3 refused (one line naming the reason; nothing written).
16
+ *
17
+ * What a move touches (spec docs/specs/6937-an-update-page-skill-renames-spec.md):
18
+ * - the page's content file(s) in every locale, and their `slug:`;
19
+ * - the page's own route directory, when it has one (an `add-page` route),
20
+ * and the `getPage("old")` literal inside its page.tsx;
21
+ * - exact internal links to `/old` in content and the site nav;
22
+ * - the page's row in `.ailk-site-manifest.json`;
23
+ * - a permanent redirect `/old` → `/new`, kept in a managed block inside
24
+ * `apps/web/next.config.mjs`'s `redirects()`.
25
+ *
26
+ * Only `node:` built-ins plus the site's own `@working-theory/templates/routes`
27
+ * (resolved from `apps/web`, never a new dependency).
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import {
31
+ existsSync,
32
+ mkdirSync,
33
+ readFileSync,
34
+ readdirSync,
35
+ rmdirSync,
36
+ statSync,
37
+ unlinkSync,
38
+ writeFileSync,
39
+ } from "node:fs";
40
+ import { createRequire } from "node:module";
41
+ import { dirname, join, relative, sep } from "node:path";
42
+ import { pathToFileURL } from "node:url";
43
+
44
+ // ── Types ────────────────────────────────────────────────────────────────────
45
+
46
+ export type ChangeKind = "create" | "move" | "modify" | "delete";
47
+
48
+ export interface Change {
49
+ kind: ChangeKind;
50
+ /** Site-relative path the change lands at (for `move`, the destination). */
51
+ path: string;
52
+ /** For `move`: the site-relative path being moved away from. */
53
+ from?: string;
54
+ /** New file contents for `create` / `move` / `modify`. Absent for `delete`. */
55
+ content?: string;
56
+ }
57
+
58
+ export interface RouteRowLike {
59
+ route: string;
60
+ module: string;
61
+ legacyRoutes?: readonly string[];
62
+ }
63
+
64
+ export interface Deps {
65
+ /** `routeFor(type, slug)` from `@working-theory/templates/routes`. */
66
+ routeFor: (type: string, slug?: string) => string;
67
+ /** `ROUTES` from the same module (only `route` and `module` are read). */
68
+ routes: readonly RouteRowLike[];
69
+ }
70
+
71
+ export interface RenameRequest {
72
+ from: string;
73
+ to: string;
74
+ name?: string;
75
+ }
76
+
77
+ export type Plan =
78
+ | { ok: true; changes: Change[]; notes: string[]; hash: string }
79
+ | { ok: false; reason: string };
80
+
81
+ // ── Small helpers ────────────────────────────────────────────────────────────
82
+
83
+ const SEGMENT = /^\/[a-z0-9]+(-[a-z0-9]+)*$/;
84
+ const WEB_APP = join("apps", "web", "app");
85
+ const NAV_CONFIG = join("apps", "web", "lib", "nav-config.ts");
86
+ const NEXT_CONFIG = join("apps", "web", "next.config.mjs");
87
+
88
+ function rel(root: string, abs: string): string {
89
+ return relative(root, abs).split(sep).join("/");
90
+ }
91
+
92
+ function isDir(p: string): boolean {
93
+ return existsSync(p) && statSync(p).isDirectory();
94
+ }
95
+
96
+ function listDirs(p: string): string[] {
97
+ return isDir(p)
98
+ ? readdirSync(p).filter((n) => isDir(join(p, n)))
99
+ : [];
100
+ }
101
+
102
+ function walkFiles(dir: string, out: string[] = []): string[] {
103
+ if (!isDir(dir)) return out;
104
+ for (const name of readdirSync(dir)) {
105
+ const abs = join(dir, name);
106
+ if (statSync(abs).isDirectory()) walkFiles(abs, out);
107
+ else out.push(abs);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /** The frontmatter block (between the first two `---` lines), or undefined. */
113
+ function frontmatter(text: string): string | undefined {
114
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
115
+ return m?.[1];
116
+ }
117
+
118
+ /** A top-level scalar `key: value` from a frontmatter block, quotes stripped. */
119
+ function fmValue(fm: string, key: string): string | undefined {
120
+ const m = new RegExp(`^${key}\\s*:\\s*(["']?)([^"'\\r\\n]+?)\\1\\s*$`, "m").exec(fm);
121
+ return m?.[2]?.trim();
122
+ }
123
+
124
+ /** Replace a top-level scalar `key: value` in the frontmatter, keeping its quoting. */
125
+ function fmReplace(text: string, key: string, next: string): string {
126
+ const fm = frontmatter(text);
127
+ if (fm === undefined) return text;
128
+ const re = new RegExp(`^(${key}\\s*:\\s*)(["']?)([^"'\\r\\n]+?)(\\2)(\\s*)$`, "m");
129
+ const replaced = fm.replace(re, (_m, pre: string, q: string, _v: string, q2: string, ws: string) => {
130
+ const quote = q === "" && /[:#{}\[\],&*?|<>=!%@`]/.test(next) ? '"' : q;
131
+ return `${pre}${quote}${next}${quote === "" ? q2 : quote}${ws}`;
132
+ });
133
+ return text.replace(fm, replaced);
134
+ }
135
+
136
+ function firstSegment(route: string): string {
137
+ return route.replace(/^\//, "").split("/")[0] ?? "";
138
+ }
139
+
140
+ /** Does a route directory serve `/x`? Route groups `(g)` add no URL segment. */
141
+ function routeDirFor(root: string, x: string): string | undefined {
142
+ const bases = [join(root, WEB_APP, "[locale]"), join(root, WEB_APP)];
143
+ for (const base of bases) {
144
+ const direct = join(base, x);
145
+ if (isDir(direct)) return rel(root, direct);
146
+ for (const g of listDirs(base).filter((n) => /^\(.*\)$/.test(n))) {
147
+ const inGroup = join(base, g, x);
148
+ if (isDir(inGroup)) return rel(root, inGroup);
149
+ }
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ interface ContentFile {
155
+ /** Site-relative path. */
156
+ path: string;
157
+ locale?: string;
158
+ type: string;
159
+ slug: string;
160
+ text: string;
161
+ }
162
+
163
+ /**
164
+ * Every content page on the site, in both layouts: `content/<locale>/<Type>/<slug>.mdx`
165
+ * (a locale directory is lowercase) and add-page's literal `content/<Type>/<slug>.mdx`
166
+ * (a Type directory is Capitalised).
167
+ */
168
+ function contentFiles(root: string): ContentFile[] {
169
+ const out: ContentFile[] = [];
170
+ const contentRoot = join(root, "content");
171
+ const read = (abs: string, locale: string | undefined, typeDir: string) => {
172
+ const text = readFileSync(abs, "utf8");
173
+ const fm = frontmatter(text);
174
+ if (fm === undefined) return;
175
+ const type = fmValue(fm, "type");
176
+ const slug = fmValue(fm, "slug");
177
+ if (type === undefined || slug === undefined || type !== typeDir) return;
178
+ out.push({ path: rel(root, abs), locale, type, slug, text });
179
+ };
180
+ for (const a of listDirs(contentRoot)) {
181
+ if (/^[A-Z]/.test(a)) {
182
+ for (const f of readdirSync(join(contentRoot, a)).filter((n) => n.endsWith(".mdx"))) {
183
+ read(join(contentRoot, a, f), undefined, a);
184
+ }
185
+ } else {
186
+ for (const t of listDirs(join(contentRoot, a))) {
187
+ for (const f of readdirSync(join(contentRoot, a, t)).filter((n) => n.endsWith(".mdx"))) {
188
+ read(join(contentRoot, a, t, f), a, t);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /** The scaffold's module set, from `architecture.yaml`'s `# Modules:` line. */
197
+ function siteModules(root: string): Set<string> {
198
+ const p = join(root, "architecture.yaml");
199
+ if (!existsSync(p)) return new Set();
200
+ const m = /^# Modules: (.+)$/m.exec(readFileSync(p, "utf8"));
201
+ return new Set((m?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean));
202
+ }
203
+
204
+ // ── The managed redirects() block ────────────────────────────────────────────
205
+
206
+ // This file runs two ways: as a CommonJS module under the site's jest (where
207
+ // `__dirname` exists) and as a script under `pnpm exec tsx` (where argv[1] is
208
+ // this file). Either way the template sits beside it.
209
+ const HERE =
210
+ typeof __dirname !== "undefined" ? __dirname : dirname(process.argv[1] ?? ".");
211
+ const TEMPLATE_PATH = join(HERE, "..", "templates", "redirects.mjs.tmpl");
212
+ const PRISTINE_RE = /^([ \t]*)async redirects\(\) \{\r?\n[ \t]*return legacyRedirects\(\);\r?\n[ \t]*\},[ \t]*\r?\n/m;
213
+ const ROW_RE = /^[ \t]*\{ from: "([^"]+)", to: "([^"]+)" \},[ \t]*$/;
214
+
215
+ export interface RenameRow {
216
+ from: string;
217
+ to: string;
218
+ }
219
+
220
+ function escapeRe(s: string): string {
221
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
222
+ }
223
+
224
+ export function renderManagedRedirects(rows: readonly RenameRow[]): string {
225
+ const template = readFileSync(TEMPLATE_PATH, "utf8");
226
+ const lines = rows.map((r) => ` { from: "${r.from}", to: "${r.to}" },`).join("\n");
227
+ return template.replace("{{ROWS}}", lines);
228
+ }
229
+
230
+ /** The template as a regex with the rows region captured. */
231
+ function managedRe(): RegExp {
232
+ const template = readFileSync(TEMPLATE_PATH, "utf8");
233
+ const [head, tail] = template.split("{{ROWS}}");
234
+ return new RegExp(`${escapeRe(head ?? "")}([\\s\\S]*?)${escapeRe(tail ?? "")}`);
235
+ }
236
+
237
+ type RedirectsState =
238
+ | { kind: "pristine"; match: string }
239
+ | { kind: "managed"; match: string; rows: RenameRow[] }
240
+ | { kind: "edited" };
241
+
242
+ function readRedirectsState(text: string): RedirectsState {
243
+ const pristine = PRISTINE_RE.exec(text);
244
+ if (pristine !== null) return { kind: "pristine", match: pristine[0] };
245
+ const managed = managedRe().exec(text);
246
+ if (managed !== null) {
247
+ const rows: RenameRow[] = [];
248
+ for (const line of (managed[1] ?? "").split(/\r?\n/)) {
249
+ if (line.trim() === "") continue;
250
+ const m = ROW_RE.exec(line);
251
+ if (m === null) return { kind: "edited" };
252
+ rows.push({ from: m[1] as string, to: m[2] as string });
253
+ }
254
+ // The whole method must be exactly what we would render for those rows.
255
+ if (managed[0] !== renderManagedRedirects(rows)) return { kind: "edited" };
256
+ return { kind: "managed", match: managed[0], rows };
257
+ }
258
+ return { kind: "edited" };
259
+ }
260
+
261
+ /** D4 row maintenance: no chains, no self-loops, renaming back removes the old row. */
262
+ export function nextRenameRows(rows: readonly RenameRow[], from: string, to: string): RenameRow[] {
263
+ const out = rows
264
+ .map((r) => (r.to === from ? { from: r.from, to } : r))
265
+ .filter((r) => r.from !== to)
266
+ .filter((r) => r.from !== r.to);
267
+ out.push({ from, to });
268
+ return out;
269
+ }
270
+
271
+ // ── Plan ─────────────────────────────────────────────────────────────────────
272
+
273
+ function refuse(reason: string): Plan {
274
+ return { ok: false, reason };
275
+ }
276
+
277
+ /** The exact-link pattern (D5): `/s` bounded by a quote or paren on both sides. */
278
+ function linkRe(s: string): RegExp {
279
+ return new RegExp(`(["'(])\\/${escapeRe(s)}(["')#?])`, "g");
280
+ }
281
+
282
+ export function planRename(siteDir: string, req: RenameRequest, deps: Deps): Plan {
283
+ const root = siteDir;
284
+ const { from, to } = req;
285
+ if (!SEGMENT.test(from) || !SEGMENT.test(to)) {
286
+ return refuse("routes must be one lowercase segment (e.g. /meet → /schedule)");
287
+ }
288
+ if (from === to) return refuse("the new route is the same as the old one");
289
+ const s = from.slice(1);
290
+ const t = to.slice(1);
291
+
292
+ // D2 rule 2 — the page's content files.
293
+ const all = contentFiles(root);
294
+ const pages = all.filter((f) => f.slug === s && deps.routeFor(f.type, s) === from);
295
+ const oldDir = routeDirFor(root, s);
296
+ if (pages.length === 0) {
297
+ if (oldDir !== undefined) {
298
+ return refuse(`${from} is a code route with no page content to move (${oldDir})`);
299
+ }
300
+ return refuse(`no page at ${from}`);
301
+ }
302
+ const types = new Set(pages.map((p) => p.type));
303
+ if (types.size > 1) {
304
+ return refuse(`${from} has pages of more than one type (${[...types].join(", ")}); move them one at a time`);
305
+ }
306
+ const type = pages[0]!.type;
307
+
308
+ // D2 rule 3 — the type must not fix the URL.
309
+ if (deps.routeFor(type, "probe") !== "/probe") {
310
+ return refuse(
311
+ `a ${type} page's URL is fixed by its type (${type} always lives at ${deps.routeFor(type, s)})`,
312
+ );
313
+ }
314
+
315
+ // D2 rule 4 — the page must actually be served at /s by a route we may move.
316
+ const routesSegments = new Map<string, RouteRowLike>();
317
+ for (const r of deps.routes) {
318
+ const seg = firstSegment(r.route);
319
+ if (seg !== "" && !routesSegments.has(seg)) routesSegments.set(seg, r);
320
+ }
321
+ const servedByCatchRoute = type === "LandingPage" && oldDir === undefined;
322
+ const servedByOwnDir = oldDir !== undefined && !routesSegments.has(s);
323
+ if (!servedByCatchRoute && !servedByOwnDir) {
324
+ return refuse(`${from} is served by a route the scaffold ships (its type's route is fixed)`);
325
+ }
326
+
327
+ // D2 — the new route must be free.
328
+ const newDir = routeDirFor(root, t);
329
+ if (newDir !== undefined) return refuse(`${to} already exists: route directory ${newDir}`);
330
+ const clash = all.find((f) => deps.routeFor(f.type, f.slug) === to);
331
+ if (clash !== undefined) return refuse(`${to} already exists: ${clash.path}`);
332
+ if (isDir(join(root, "content", t))) {
333
+ return refuse(`${to} already exists: content locale directory content/${t}/`);
334
+ }
335
+
336
+ // D4 — the redirects() method must be one we recognise.
337
+ const nextConfigAbs = join(root, NEXT_CONFIG);
338
+ if (!existsSync(nextConfigAbs)) return refuse(`${NEXT_CONFIG} not found`);
339
+ const nextConfig = readFileSync(nextConfigAbs, "utf8");
340
+ if (!/^const enabledLocalesAtBuild\b/m.test(nextConfig) || !/^const defaultLocaleAtBuild\b/m.test(nextConfig)) {
341
+ return refuse(
342
+ `${NEXT_CONFIG} no longer defines enabledLocalesAtBuild / defaultLocaleAtBuild — the managed redirects block cannot read the site's locales; stop and report this`,
343
+ );
344
+ }
345
+ const state = readRedirectsState(nextConfig);
346
+ if (state.kind === "edited") {
347
+ return refuse(
348
+ `${NEXT_CONFIG} redirects() has been edited — add { source: '${from}', destination: '${to}', permanent: true } yourself`,
349
+ );
350
+ }
351
+
352
+ // D3 — the manifest row.
353
+ const manifestRel = existsSync(join(root, ".ailk-site-manifest.json"))
354
+ ? ".ailk-site-manifest.json"
355
+ : existsSync(join(root, "TEMPLATE-MANIFEST.json"))
356
+ ? "TEMPLATE-MANIFEST.json"
357
+ : undefined;
358
+ const rowRe = new RegExp(`("route"\\s*:\\s*")${escapeRe(from)}(")`, "g");
359
+ let manifestText: string | undefined;
360
+ if (manifestRel !== undefined) {
361
+ manifestText = readFileSync(join(root, manifestRel), "utf8");
362
+ const hits = manifestText.match(rowRe)?.length ?? 0;
363
+ if (hits > 1) return refuse(`${manifestRel} has ${hits} rows for ${from}; expected one`);
364
+ if (hits === 0) manifestText = undefined;
365
+ }
366
+
367
+ // ── Build the changes ────────────────────────────────────────────────────
368
+ const changes: Change[] = [];
369
+ const notes: string[] = [];
370
+ const link = linkRe(s);
371
+ const rewriteLinks = (text: string): string => text.replace(link, `$1${to}$2`);
372
+ const rewrittenContentPaths: string[] = [];
373
+ const movedPaths = new Set(pages.map((p) => p.path));
374
+
375
+ // The page's own content files move (and get their links rewritten too).
376
+ for (const p of pages) {
377
+ let text = fmReplace(p.text, "slug", t);
378
+ if (req.name !== undefined) {
379
+ if (p.locale === undefined || p.locale === "en") text = fmReplace(text, "title", req.name);
380
+ else notes.push(`${p.path}: title left as translated (--name sets the en title only)`);
381
+ }
382
+ text = rewriteLinks(text);
383
+ const dest = p.path.replace(new RegExp(`/${escapeRe(s)}\\.mdx$`), `/${t}.mdx`);
384
+ changes.push({ kind: "move", from: p.path, path: dest, content: text });
385
+ if (link.test(p.text)) rewrittenContentPaths.push(dest);
386
+ link.lastIndex = 0;
387
+ }
388
+
389
+ // Other content files with an exact link to /s.
390
+ for (const f of all) {
391
+ if (movedPaths.has(f.path)) continue;
392
+ link.lastIndex = 0;
393
+ if (!link.test(f.text)) continue;
394
+ link.lastIndex = 0;
395
+ changes.push({ kind: "modify", path: f.path, content: rewriteLinks(f.text) });
396
+ rewrittenContentPaths.push(f.path);
397
+ }
398
+
399
+ // The nav.
400
+ const navAbs = join(root, NAV_CONFIG);
401
+ let navChanged = false;
402
+ if (existsSync(navAbs)) {
403
+ const nav = readFileSync(navAbs, "utf8");
404
+ link.lastIndex = 0;
405
+ if (link.test(nav)) {
406
+ link.lastIndex = 0;
407
+ changes.push({ kind: "modify", path: NAV_CONFIG, content: rewriteLinks(nav) });
408
+ navChanged = true;
409
+ const label = /labelKey:\s*"([^"]+)"[^}]*path:\s*"\/[^"]*"/g;
410
+ for (const m of nav.matchAll(label)) {
411
+ if (m[0].includes(`"${from}"`)) notes.push(`nav: the item labelled \`${m[1]}\` now points at ${to}`);
412
+ }
413
+ }
414
+ }
415
+
416
+ // The page's own route directory (case 3).
417
+ if (oldDir !== undefined) {
418
+ const oldAbs = join(root, oldDir);
419
+ const newAbs = join(dirname(oldAbs), t);
420
+ for (const fileAbs of walkFiles(oldAbs)) {
421
+ const inner = relative(oldAbs, fileAbs);
422
+ let text = readFileSync(fileAbs, "utf8");
423
+ if (inner === "page.tsx") {
424
+ const getPage = new RegExp(`getPage\\("${escapeRe(s)}"\\)`, "g");
425
+ text = text.replace(getPage, `getPage("${t}")`);
426
+ const others = text.match(new RegExp(`"${escapeRe(s)}"`, "g"))?.length ?? 0;
427
+ if (others > 0) notes.push(`${rel(root, join(newAbs, inner))}: ${others} other "${s}" literal(s) left as-is — check them by hand`);
428
+ }
429
+ changes.push({ kind: "move", from: rel(root, fileAbs), path: rel(root, join(newAbs, inner)), content: text });
430
+ }
431
+ }
432
+
433
+ // The manifest row.
434
+ if (manifestText !== undefined && manifestRel !== undefined) {
435
+ changes.push({ kind: "modify", path: manifestRel, content: manifestText.replace(rowRe, `$1${to}$2`) });
436
+ } else {
437
+ notes.push(`no manifest row for ${from} — .ailk-site-manifest.json is unchanged`);
438
+ }
439
+
440
+ // The redirect.
441
+ const rows = nextRenameRows(state.kind === "managed" ? state.rows : [], from, to);
442
+ changes.push({ kind: "modify", path: NEXT_CONFIG, content: nextConfig.replace(state.match, renderManagedRedirects(rows)) });
443
+
444
+ // Reported, not rewritten: links in code.
445
+ for (const dir of [join(root, WEB_APP), join(root, "apps", "web", "lib"), join(root, "apps", "web", "components")]) {
446
+ for (const fileAbs of walkFiles(dir)) {
447
+ const r = rel(root, fileAbs);
448
+ if (r === NAV_CONFIG || !/\.(tsx?|mjs|js)$/.test(r)) continue;
449
+ const text = readFileSync(fileAbs, "utf8");
450
+ link.lastIndex = 0;
451
+ const n = text.match(link)?.length ?? 0;
452
+ if (n > 0) notes.push(`${r}: ${n} link(s) to ${from} left as-is (code; the redirect covers them)`);
453
+ }
454
+ }
455
+
456
+ // Fixed children left behind.
457
+ const children = new Set<string>();
458
+ const hubRow = deps.routes.find((r) => r.route === from || r.route.startsWith(`${from}/`));
459
+ if (hubRow !== undefined) children.add(`${from}/…`);
460
+ if (manifestText !== undefined) {
461
+ for (const m of manifestText.matchAll(/"route"\s*:\s*"([^"]+)"/g)) {
462
+ if ((m[1] ?? "").startsWith(`${from}/`)) children.add(m[1] as string);
463
+ }
464
+ }
465
+ if (children.size > 0) {
466
+ notes.push(`the pages under ${from} (${[...children].join(", ")}) keep their URLs — only ${from} itself moves`);
467
+ }
468
+
469
+ // D9 — what a later add-module run will do differently.
470
+ const modules = siteModules(root);
471
+ const sRow = routesSegments.get(s);
472
+ if (sRow !== undefined && oldDir === undefined) {
473
+ notes.push(
474
+ `${from} belongs to the ${sRow.module} module's routes. If they are added later, the redirect ${from} → ${to} will hide its ${from} page; remove the \`${from}\` row from the \`ailk:page-renames\` block in apps/web/next.config.mjs first.`,
475
+ );
476
+ }
477
+ const tRow = routesSegments.get(t);
478
+ if (tRow !== undefined && newDir === undefined) {
479
+ notes.push(
480
+ `${to} belongs to the ${tRow.module} module's routes. If they are added later, its ${to} directory will take precedence over this page, which will stop being served; rename it again first.`,
481
+ );
482
+ }
483
+ const seeds = new Map<string, string[]>();
484
+ for (const c of changes) {
485
+ if (c.content === undefined || !c.path.endsWith(".mdx") || !rewrittenContentPaths.includes(c.path)) continue;
486
+ const fm = frontmatter(c.content);
487
+ if (fm === undefined) continue;
488
+ const ty = fmValue(fm, "type");
489
+ const sl = fmValue(fm, "slug");
490
+ if (ty === undefined || sl === undefined) continue;
491
+ const row = routesSegments.get(firstSegment(deps.routeFor(ty, sl)));
492
+ if (row === undefined || modules.has(row.module)) continue;
493
+ const list = seeds.get(row.module) ?? [];
494
+ list.push(c.path);
495
+ seeds.set(row.module, list);
496
+ }
497
+ for (const [module, paths] of seeds) {
498
+ notes.push(
499
+ `add-module ${module} will keep these edited placeholder files and list them for review instead of replacing them: ${paths.join(", ")}`,
500
+ );
501
+ }
502
+ if (navChanged) {
503
+ notes.push("add-module will no longer regenerate the nav; it will list it for manual review");
504
+ }
505
+
506
+ changes.sort((a, b) => a.path.localeCompare(b.path));
507
+ return { ok: true, changes, notes, hash: hashChanges(changes) };
508
+ }
509
+
510
+ export function hashChanges(changes: readonly Change[]): string {
511
+ const canonical = JSON.stringify(
512
+ [...changes]
513
+ .sort((a, b) => a.path.localeCompare(b.path))
514
+ .map((c) => ({ kind: c.kind, path: c.path, from: c.from ?? null, content: c.content ?? null })),
515
+ );
516
+ return createHash("sha256").update(canonical).digest("hex");
517
+ }
518
+
519
+ // ── Apply ────────────────────────────────────────────────────────────────────
520
+
521
+ export function applyPlan(siteDir: string, plan: Extract<Plan, { ok: true }>): void {
522
+ const touchedDirs = new Set<string>();
523
+ for (const c of plan.changes) {
524
+ const abs = join(siteDir, c.path);
525
+ if (c.kind === "delete") {
526
+ unlinkSync(abs);
527
+ continue;
528
+ }
529
+ mkdirSync(dirname(abs), { recursive: true });
530
+ writeFileSync(abs, c.content ?? "");
531
+ if (c.kind === "move" && c.from !== undefined) {
532
+ const fromAbs = join(siteDir, c.from);
533
+ unlinkSync(fromAbs);
534
+ touchedDirs.add(dirname(fromAbs));
535
+ }
536
+ }
537
+ // Remove directories a route move emptied.
538
+ for (const d of [...touchedDirs].sort((a, b) => b.length - a.length)) {
539
+ let cur = d;
540
+ while (isDir(cur) && readdirSync(cur).length === 0 && cur !== siteDir) {
541
+ rmdirSync(cur);
542
+ cur = dirname(cur);
543
+ }
544
+ }
545
+ }
546
+
547
+ // ── CLI ──────────────────────────────────────────────────────────────────────
548
+
549
+ interface Args {
550
+ cmd: "plan" | "apply";
551
+ from: string;
552
+ to: string;
553
+ name?: string;
554
+ root: string;
555
+ confirm?: string;
556
+ json: boolean;
557
+ }
558
+
559
+ function parseArgs(argv: string[]): Args | string {
560
+ const cmd = argv[0];
561
+ if (cmd !== "plan" && cmd !== "apply") return "usage: update-page plan|apply --from /old --to /new [--name \"Title\"] [--root <site>] [--confirm <hash>] [--json]";
562
+ const a: Partial<Args> = { cmd, root: process.cwd(), json: false };
563
+ for (let i = 1; i < argv.length; i++) {
564
+ const k = argv[i];
565
+ const v = argv[i + 1];
566
+ if (k === "--json") { a.json = true; continue; }
567
+ if (v === undefined) return `missing value for ${k}`;
568
+ if (k === "--from") a.from = v;
569
+ else if (k === "--to") a.to = v;
570
+ else if (k === "--name") a.name = v;
571
+ else if (k === "--root") a.root = v;
572
+ else if (k === "--confirm") a.confirm = v;
573
+ else return `unknown argument ${k}`;
574
+ i++;
575
+ }
576
+ if (a.from === undefined || a.to === undefined) return "--from and --to are required";
577
+ return a as Args;
578
+ }
579
+
580
+ async function loadDeps(root: string): Promise<Deps> {
581
+ const req = createRequire(join(root, "apps", "web", "package.json"));
582
+ const mod = (await import(pathToFileURL(req.resolve("@working-theory/templates/routes")).href)) as {
583
+ routeFor: Deps["routeFor"];
584
+ ROUTES: readonly RouteRowLike[];
585
+ };
586
+ return { routeFor: mod.routeFor, routes: mod.ROUTES };
587
+ }
588
+
589
+ function printPlan(plan: Extract<Plan, { ok: true }>): void {
590
+ for (const c of plan.changes) {
591
+ const line = c.kind === "move" ? `move ${c.from} -> ${c.path}` : `${c.kind.padEnd(7)} ${c.path}`;
592
+ console.log(line);
593
+ }
594
+ if (plan.notes.length > 0) {
595
+ console.log("");
596
+ for (const n of plan.notes) console.log(`note: ${n}`);
597
+ }
598
+ console.log("");
599
+ console.log(`hash: ${plan.hash}`);
600
+ }
601
+
602
+ async function main(argv: string[]): Promise<number> {
603
+ const args = parseArgs(argv);
604
+ if (typeof args === "string") {
605
+ console.error(args);
606
+ return 2;
607
+ }
608
+ const deps = await loadDeps(args.root);
609
+ const plan = planRename(args.root, { from: args.from, to: args.to, ...(args.name !== undefined && { name: args.name }) }, deps);
610
+ if (!plan.ok) {
611
+ console.error(`refused: ${plan.reason}`);
612
+ return 3;
613
+ }
614
+ if (args.cmd === "plan") {
615
+ if (args.json) console.log(JSON.stringify(plan, null, 2));
616
+ else printPlan(plan);
617
+ return 0;
618
+ }
619
+ if (args.confirm === undefined || args.confirm !== plan.hash) {
620
+ console.error(
621
+ args.confirm === undefined
622
+ ? "apply needs --confirm <hash> from a plan the user has seen; nothing written"
623
+ : "the plan has changed since that hash was printed; run plan again and confirm the new hash; nothing written",
624
+ );
625
+ return 1;
626
+ }
627
+ applyPlan(args.root, plan);
628
+ console.log(`applied ${plan.changes.length} change(s): ${args.from} -> ${args.to}`);
629
+ return 0;
630
+ }
631
+
632
+ if (/update-page\.(ts|js)$/.test(process.argv[1] ?? "")) {
633
+ main(process.argv.slice(2)).then(
634
+ (code) => process.exit(code),
635
+ (err: unknown) => {
636
+ console.error(err instanceof Error ? err.message : String(err));
637
+ process.exit(2);
638
+ },
639
+ );
640
+ }
@@ -0,0 +1,31 @@
1
+ // Page renames — managed by the `update-page` skill. Edit ONLY the rows
2
+ // between the begin/end markers (one `{ from, to }` per line); the skill
3
+ // rewrites them on the next rename and refuses to touch any other edit.
4
+ async redirects() {
5
+ const renames = [
6
+ // ailk:page-renames:begin
7
+ {{ROWS}}
8
+ // ailk:page-renames:end
9
+ ];
10
+ const locales = String(enabledLocalesAtBuild ?? "")
11
+ .split(",")
12
+ .map((l) => l.trim())
13
+ .filter((l) => l !== "" && l !== defaultLocaleAtBuild);
14
+ const renamed = renames.flatMap(({ from, to }) => [
15
+ { source: from, destination: to, permanent: true },
16
+ ...(locales.length > 0
17
+ ? [
18
+ {
19
+ source: `/:locale(${locales.join("|")})${from}`,
20
+ destination: `/:locale${to}`,
21
+ permanent: true,
22
+ },
23
+ ]
24
+ : []),
25
+ ]);
26
+ const owned = new Set(renames.map((r) => r.to));
27
+ return [
28
+ ...renamed,
29
+ ...legacyRedirects().filter((r) => !owned.has(r.source)),
30
+ ];
31
+ },