hecks 3.10.0 → 3.11.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 (481) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/bluebook/pizzas.bluebook +29 -0
  3. data/examples/pizzas/bluebook/pizzas.hecksagon +7 -0
  4. data/exe/hecks +4 -4
  5. data/lib/hecks/adapters/driven/d1.rb +2 -10
  6. data/lib/hecks/adapters/driven/heki.adapter +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +2 -12
  8. data/lib/hecks/adapters/driven/lambda.adapter +1 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +3 -18
  10. data/lib/hecks/adapters/driven/postgres.rb +4 -11
  11. data/lib/hecks/adapters/driven/sqlite.adapter +1 -1
  12. data/lib/hecks/adapters/driven/sqlite.rb +2 -10
  13. data/lib/hecks/adapters/driving/admission.rb +46 -0
  14. data/lib/hecks/adapters/driving/cli/nesting.rb +36 -0
  15. data/lib/hecks/adapters/driving/cli/shorthand.rb +88 -0
  16. data/lib/hecks/adapters/driving/cli.rb +127 -0
  17. data/lib/hecks/adapters/driving/cli_runner/answers.rb +160 -0
  18. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +155 -0
  19. data/lib/hecks/adapters/driving/cli_runner/settling.rb +120 -0
  20. data/lib/hecks/adapters/driving/cli_runner/streaming.rb +93 -0
  21. data/lib/hecks/adapters/driving/cli_runner/suggestions.rb +47 -0
  22. data/lib/hecks/adapters/driving/cli_runner.rb +69 -0
  23. data/lib/hecks/adapters/driving/command_request.rb +121 -0
  24. data/lib/hecks/adapters/driving/handle.rb +214 -0
  25. data/lib/hecks/adapters/driving/json.rb +136 -0
  26. data/lib/hecks/adapters/driving/launcher_options.rb +267 -0
  27. data/lib/hecks/adapters/driving/mcp/properties.rb +49 -0
  28. data/lib/hecks/adapters/driving/mcp/tool_calls.rb +116 -0
  29. data/lib/hecks/adapters/driving/mcp/tool_definitions/auditing.rb +53 -0
  30. data/lib/hecks/adapters/driving/mcp/tool_definitions/calling.rb +71 -0
  31. data/lib/hecks/adapters/driving/mcp/tool_definitions/reading.rb +99 -0
  32. data/lib/hecks/adapters/driving/mcp/tool_definitions.rb +18 -0
  33. data/lib/hecks/adapters/driving/mcp.rb +159 -0
  34. data/lib/hecks/adapters/driving/mcp_guide_cache.rb +78 -0
  35. data/lib/hecks/adapters/driving/mcp_scope/argument_policy.rb +97 -0
  36. data/lib/hecks/adapters/driving/mcp_scope/env_problems.rb +69 -0
  37. data/lib/hecks/adapters/driving/mcp_scope/presenter.rb +83 -0
  38. data/lib/hecks/adapters/driving/mcp_scope.rb +213 -0
  39. data/lib/hecks/adapters/driving/ruby/aggregate_module.rb +171 -0
  40. data/lib/hecks/adapters/driving/ruby/chapter.rb +91 -0
  41. data/lib/hecks/adapters/driving/ruby.rb +83 -0
  42. data/lib/hecks/adapters/driving/usage_cache.rb +144 -0
  43. data/lib/hecks/{doors.rb → adapters/driving.rb} +10 -6
  44. data/lib/hecks/behaviors/expectations/boot.rb +1 -1
  45. data/lib/hecks/bench/ruby_runner.rb +1 -1
  46. data/lib/hecks/bluebook/ask_resolution.rb +130 -0
  47. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +8 -2
  48. data/lib/hecks/bluebook/assembly/contracts/rules.rb +4 -2
  49. data/lib/hecks/bluebook/assembly/contracts/structure.rb +3 -1
  50. data/lib/hecks/bluebook/assembly/marks.rb +1 -0
  51. data/lib/hecks/bluebook/attribute.rb +1 -1
  52. data/lib/hecks/bluebook/behaviour/command.rb +13 -8
  53. data/lib/hecks/bluebook/behaviour/policy.rb +30 -0
  54. data/lib/hecks/bluebook/capabilities/attribute_values.rb +72 -0
  55. data/lib/hecks/bluebook/capabilities.rb +91 -9
  56. data/lib/hecks/bluebook/domain_port.rb +16 -4
  57. data/lib/hecks/bluebook/dsl/adapter_builder.rb +18 -6
  58. data/lib/hecks/bluebook/dsl/binding_proxy.rb +13 -0
  59. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/computed_sources.rb +98 -0
  60. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/fan_out_rows.rb +42 -0
  61. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/projected_fields.rb +14 -2
  62. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/provisions.rb +69 -3
  63. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/with_projections.rb +17 -10
  64. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +5 -0
  65. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  66. data/lib/hecks/bluebook/dsl/command_builder/mutations.rb +8 -1
  67. data/lib/hecks/bluebook/dsl/command_builder/operands.rb +121 -0
  68. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -0
  69. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +10 -1
  70. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +45 -2
  71. data/lib/hecks/bluebook/dsl/policy_builder.rb +47 -10
  72. data/lib/hecks/bluebook/dsl/rule_reference.rb +7 -4
  73. data/lib/hecks/bluebook/dsl/world_builder.rb +1 -1
  74. data/lib/hecks/bluebook/expression/ast_json/emitters.rb +6 -0
  75. data/lib/hecks/bluebook/expression/ast_json.rb +9 -6
  76. data/lib/hecks/bluebook/expression/ast_reader.rb +14 -9
  77. data/lib/hecks/bluebook/expression/projection.json +36 -0
  78. data/lib/hecks/bluebook/expression/resolver/operations.rb +31 -0
  79. data/lib/hecks/bluebook/expression/resolver/parsing.rb +38 -5
  80. data/lib/hecks/bluebook/expression/resolver/scanning.rb +22 -5
  81. data/lib/hecks/bluebook/expression/resolver/values.rb +10 -0
  82. data/lib/hecks/bluebook/expression/resolver.rb +20 -1
  83. data/lib/hecks/bluebook/hecksagon.rb +23 -3
  84. data/lib/hecks/bluebook/lifecycle.rb +22 -3
  85. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +13 -1
  86. data/lib/hecks/bluebook/meta_validator/modes.rb +4 -1
  87. data/lib/hecks/bluebook/meta_validator/readings.rb +18 -1
  88. data/lib/hecks/bluebook/meta_validator/reconstruction/heads.rb +2 -1
  89. data/lib/hecks/bluebook/meta_validator/shapes.rb +5 -0
  90. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +1 -1
  91. data/lib/hecks/bluebook/meta_validator.rb +12 -12
  92. data/lib/hecks/bluebook/model_check/ask_checks.rb +71 -0
  93. data/lib/hecks/bluebook/model_check/policy_checks.rb +2 -1
  94. data/lib/hecks/bluebook/model_check/reference_checks.rb +70 -0
  95. data/lib/hecks/bluebook/model_check.rb +8 -2
  96. data/lib/hecks/bluebook/policy.rb +4 -2
  97. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  98. data/lib/hecks/bluebook/synthesizer.rb +2 -0
  99. data/lib/hecks/bluebook.rb +1 -0
  100. data/lib/hecks/cli/command_table.rb +1 -1
  101. data/lib/hecks/cli/console.rb +2 -1
  102. data/lib/hecks/cli/document.rb +1 -1
  103. data/lib/hecks/cli/launcher_words.rb +4 -4
  104. data/lib/hecks/cli/mcp.rb +4 -4
  105. data/lib/hecks/cli/project_cli/launcher_source.rb +2 -2
  106. data/lib/hecks/cli/project_cli/templates.rb +5 -5
  107. data/lib/hecks/cli/project_cli.rb +9 -6
  108. data/lib/hecks/cli/run.rb +2 -2
  109. data/lib/hecks/cli/smoke_http/checks.rb +4 -1
  110. data/lib/hecks/cli/smoke_http/transport.rb +3 -3
  111. data/lib/hecks/cli/smoke_http.rb +3 -0
  112. data/lib/hecks/codemod.rb +5 -2
  113. data/lib/hecks/corpus/routes.rb +7 -0
  114. data/lib/hecks/corpus/rust.rb +3 -2
  115. data/lib/hecks/deploy/bluebook/deploy.bluebook +18 -2
  116. data/lib/hecks/doc/reference/rendering.rb +6 -3
  117. data/lib/hecks/forms/app/commands.rb +8 -7
  118. data/lib/hecks/forms/app/queries.rb +2 -1
  119. data/lib/hecks/forms/app.rb +1 -1
  120. data/lib/hecks/forms/record_renderer.rb +1 -1
  121. data/lib/hecks/framework/bluebook/privacy.bluebook +11 -0
  122. data/lib/hecks/fuzzing/concurrent_dispatch.rb +4 -1
  123. data/lib/hecks/fuzzing/failure_triage.rb +2 -1
  124. data/lib/hecks/fuzzing/mutation/run.rb +11 -4
  125. data/lib/hecks/fuzzing/properties/catalog.rb +6 -4
  126. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +18 -11
  127. data/lib/hecks/fuzzing/properties/guards.rb +1 -1
  128. data/lib/hecks/fuzzing/self_consistency/saga_redelivery.rb +1 -1
  129. data/lib/hecks/fuzzing/self_consistency.rb +6 -2
  130. data/lib/hecks/fuzzing/shrinker.rb +3 -1
  131. data/lib/hecks/fuzzing/value_generator.rb +19 -5
  132. data/lib/hecks/gate/stages.yml +4 -10
  133. data/lib/hecks/grammar/evolve.rb +1 -1
  134. data/lib/hecks/grammar/expression_operators.json +372 -0
  135. data/lib/hecks/grammar/operators.rb +3 -1
  136. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +10 -10
  137. data/lib/hecks/hecks/adapters/codebase/inspection/rubycritic.rb +35 -0
  138. data/lib/hecks/hecks/adapters/codebase/inspection.rb +104 -0
  139. data/lib/hecks/hecks/adapters/codebase/publishing.rb +2 -1
  140. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +12 -2
  141. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +2 -1
  142. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +1 -1
  143. data/lib/hecks/hecks/adapters/cost_explorer.rb +8 -5
  144. data/lib/hecks/hecks/adapters/deploy_toolchain/statuses.rb +2 -1
  145. data/lib/hecks/hecks/adapters/finding_github.rb +13 -6
  146. data/lib/hecks/hecks/adapters/first_admin.rb +2 -2
  147. data/lib/hecks/hecks/adapters/github_rulesets.rb +6 -3
  148. data/lib/hecks/hecks/adapters/in_process_boot.rb +1 -1
  149. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +1 -1
  150. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +10 -20
  151. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +28 -5
  152. data/lib/hecks/hecks/adapters/local_files.rb +2 -2
  153. data/lib/hecks/hecks/adapters/rehearsal/audit_report.rb +107 -0
  154. data/lib/hecks/hecks/adapters/rehearsal/census.rb +71 -0
  155. data/lib/hecks/hecks/adapters/rehearsal/declaration.rb +133 -0
  156. data/lib/hecks/hecks/adapters/rehearsal/environment.rb +83 -0
  157. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +142 -0
  158. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +96 -0
  159. data/lib/hecks/hecks/adapters/rehearsal/postgres_census.rb +100 -0
  160. data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +69 -0
  161. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +129 -0
  162. data/lib/hecks/hecks/adapters/rehearsal/rows.rb +41 -0
  163. data/lib/hecks/hecks/adapters/rehearsal/rust_audit.rb +83 -0
  164. data/lib/hecks/hecks/adapters/rehearsal/subject.rb +104 -0
  165. data/lib/hecks/hecks/adapters/rehearsal.rb +54 -0
  166. data/lib/hecks/hecks/adapters/rehearsal_store.adapter +7 -0
  167. data/lib/hecks/hecks/adapters/rehearsal_store.rb +81 -0
  168. data/lib/hecks/hecks/adapters/terminal.rb +13 -11
  169. data/lib/hecks/hecks/codebase.behaviors +13 -1
  170. data/lib/hecks/hecks/codebase.bluebook +239 -5
  171. data/lib/hecks/hecks/custodian.behaviors +24 -6
  172. data/lib/hecks/hecks/custodian.bluebook +6 -1227
  173. data/lib/hecks/hecks/deploy.behaviors +1 -1
  174. data/lib/hecks/hecks/deploy.world +1 -3
  175. data/lib/hecks/hecks/hecks.behaviors +1 -1
  176. data/lib/hecks/hecks/hecks.hecksagon +53 -18
  177. data/lib/hecks/hecks/hecks.world +10 -10
  178. data/lib/hecks/hecks/rehearsal.bluebook +477 -0
  179. data/lib/hecks/hecks/site.behaviors +1 -1
  180. data/lib/hecks/hecks/site.world +0 -1
  181. data/lib/hecks/hecks/tooling.bluebook +1238 -0
  182. data/lib/hecks/indifferent_key.rb +20 -0
  183. data/lib/hecks/language/adapter.bluebook +13 -3
  184. data/lib/hecks/language/bluebook/aggregate.bluebook +23 -0
  185. data/lib/hecks/language/bluebook/bluebook.bluebook +15 -0
  186. data/lib/hecks/language/bluebook/entity.bluebook +18 -0
  187. data/lib/hecks/language/bluebook/policy.bluebook +9 -1
  188. data/lib/hecks/language/bluebook/query.bluebook +1 -1
  189. data/lib/hecks/language/bluebook/vocabulary.bluebook +11 -4
  190. data/lib/hecks/language/hecksagon/domain_port.bluebook +1 -1
  191. data/lib/hecks/language/hecksagon/hecksagon.bluebook +22 -0
  192. data/lib/hecks/language/oidc.json +10 -0
  193. data/lib/hecks/language/port.bluebook +4 -4
  194. data/lib/hecks/literal.rb +12 -3
  195. data/lib/hecks/ports/key_vault.rb +7 -2
  196. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +21 -10
  197. data/lib/hecks/ports/persistence/plugins/era/postgres_era/boot.rb +4 -2
  198. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +2 -1
  199. data/lib/hecks/ports/persistence/repository_factory.rb +6 -3
  200. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +29 -0
  201. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +1 -1
  202. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  203. data/lib/hecks/projections/deploy/shared.rb +5 -2
  204. data/lib/hecks/projections/model/deviations.rb +2 -2
  205. data/lib/hecks/projections/model.rb +2 -2
  206. data/lib/hecks/projections/oidc.rb +13 -1
  207. data/lib/hecks/projections/shape.rb +5 -2
  208. data/lib/hecks/projections/site/cms_editor/attributes.rb +26 -6
  209. data/lib/hecks/projections/site/cms_editor/blocks.rb +157 -0
  210. data/lib/hecks/projections/site/cms_editor/checks.rb +14 -1
  211. data/lib/hecks/projections/site/cms_editor/clearing.rb +70 -0
  212. data/lib/hecks/projections/site/cms_editor/color.rb +124 -0
  213. data/lib/hecks/projections/site/cms_editor/composition.rb +42 -0
  214. data/lib/hecks/projections/site/cms_editor/destructive.rb +61 -0
  215. data/lib/hecks/projections/site/cms_editor/drafts.rb +102 -0
  216. data/lib/hecks/projections/site/cms_editor/hints.rb +74 -0
  217. data/lib/hecks/projections/site/cms_editor/icons.rb +86 -0
  218. data/lib/hecks/projections/site/cms_editor/key_kinds.rb +31 -0
  219. data/lib/hecks/projections/site/cms_editor/limits.rb +72 -0
  220. data/lib/hecks/projections/site/cms_editor/media.rb +1 -1
  221. data/lib/hecks/projections/site/cms_editor/moment.rb +34 -0
  222. data/lib/hecks/projections/site/cms_editor/palette.rb +115 -0
  223. data/lib/hecks/projections/site/cms_editor/pickers.rb +85 -0
  224. data/lib/hecks/projections/site/cms_editor/preview.rb +91 -0
  225. data/lib/hecks/projections/site/cms_editor/relations.rb +92 -0
  226. data/lib/hecks/projections/site/cms_editor/scale_checks.rb +66 -0
  227. data/lib/hecks/projections/site/cms_editor/scheduling.rb +150 -0
  228. data/lib/hecks/projections/site/cms_editor/schema.rb +96 -19
  229. data/lib/hecks/projections/site/cms_editor/setting.rb +52 -9
  230. data/lib/hecks/projections/site/cms_editor/targets.rb +86 -0
  231. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +18 -1
  232. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +251 -57
  233. data/lib/hecks/projections/site/cms_editor/templates/src/assets.ts.tmpl +98 -0
  234. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +32 -24
  235. data/lib/hecks/projections/site/cms_editor/templates/src/browser/app.css.tmpl +412 -0
  236. data/lib/hecks/projections/site/cms_editor/templates/src/browser/autosave.js.tmpl +144 -0
  237. data/lib/hecks/projections/site/cms_editor/templates/src/browser/blocks.js.tmpl +500 -0
  238. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_doc.js.tmpl +175 -0
  239. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_editor.js.tmpl +418 -0
  240. data/lib/hecks/projections/site/cms_editor/templates/src/browser/epoch.js.tmpl +61 -0
  241. data/lib/hecks/projections/site/cms_editor/templates/src/browser/forms.js.tmpl +210 -0
  242. data/lib/hecks/projections/site/cms_editor/templates/src/browser/lists.js.tmpl +40 -0
  243. data/lib/hecks/projections/site/cms_editor/templates/src/browser/main.js.tmpl +26 -0
  244. data/lib/hecks/projections/site/cms_editor/templates/src/browser/media_picker.js.tmpl +232 -0
  245. data/lib/hecks/projections/site/cms_editor/templates/src/browser/moments.js.tmpl +73 -0
  246. data/lib/hecks/projections/site/cms_editor/templates/src/browser/pickers.js.tmpl +152 -0
  247. data/lib/hecks/projections/site/cms_editor/templates/src/browser/preview.js.tmpl +98 -0
  248. data/lib/hecks/projections/site/cms_editor/templates/src/browser/shell.js.tmpl +140 -0
  249. data/lib/hecks/projections/site/cms_editor/templates/src/browser/toast.js.tmpl +66 -0
  250. data/lib/hecks/projections/site/cms_editor/templates/src/choices.ts.tmpl +99 -0
  251. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +78 -24
  252. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +6 -0
  253. data/lib/hecks/projections/site/cms_editor/templates/src/flash.ts.tmpl +52 -0
  254. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +19 -2
  255. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +31 -8
  256. data/lib/hecks/projections/site/cms_editor/templates/src/names.ts.tmpl +36 -0
  257. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +131 -2
  258. data/lib/hecks/projections/site/cms_editor/templates/src/ui/availability.ts.tmpl +15 -0
  259. data/lib/hecks/projections/site/cms_editor/templates/src/ui/block_list.js.tmpl +213 -0
  260. data/lib/hecks/projections/site/cms_editor/templates/src/ui/blocks.ts.tmpl +275 -0
  261. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +8 -5
  262. data/lib/hecks/projections/site/cms_editor/templates/src/ui/detail.ts.tmpl +88 -0
  263. data/lib/hecks/projections/site/cms_editor/templates/src/ui/dialogs.ts.tmpl +43 -0
  264. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +229 -34
  265. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +40 -81
  266. data/lib/hecks/projections/site/cms_editor/templates/src/ui/icons.ts.tmpl +15 -0
  267. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +29 -12
  268. data/lib/hecks/projections/site/cms_editor/templates/src/ui/links.ts.tmpl +100 -0
  269. data/lib/hecks/projections/site/cms_editor/templates/src/ui/moments.ts.tmpl +44 -0
  270. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +53 -17
  271. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +178 -88
  272. data/lib/hecks/projections/site/cms_editor/templates/src/ui/paging.ts.tmpl +82 -0
  273. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pickers.ts.tmpl +110 -0
  274. data/lib/hecks/projections/site/cms_editor/templates/src/ui/preview.ts.tmpl +87 -0
  275. data/lib/hecks/projections/site/cms_editor/templates/src/ui/related.ts.tmpl +97 -0
  276. data/lib/hecks/projections/site/cms_editor/templates/src/ui/schedule.ts.tmpl +160 -0
  277. data/lib/hecks/projections/site/cms_editor/templates/src/ui/shell.ts.tmpl +211 -0
  278. data/lib/hecks/projections/site/cms_editor/templates/src/ui/table.ts.tmpl +151 -0
  279. data/lib/hecks/projections/site/cms_editor/templates/src/ui/theme.js.tmpl +16 -0
  280. data/lib/hecks/projections/site/cms_editor/templates/src/ui/values.ts.tmpl +69 -0
  281. data/lib/hecks/projections/site/cms_editor/templates/src/ui/words.ts.tmpl +97 -0
  282. data/lib/hecks/projections/site/cms_editor/templates/src/ui/world.ts.tmpl +35 -0
  283. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +2 -1
  284. data/lib/hecks/projections/site/cms_editor/theme.rb +29 -0
  285. data/lib/hecks/projections/site/cms_editor/theme_checks.rb +57 -0
  286. data/lib/hecks/projections/site/cms_editor.rb +49 -23
  287. data/lib/hecks/projections/site/role_probe.rb +2 -0
  288. data/lib/hecks/projector/cli_audience.rb +2 -1
  289. data/lib/hecks/projector/cli_projector/listing.rb +1 -1
  290. data/lib/hecks/projector/cli_projector/option_specs.rb +1 -1
  291. data/lib/hecks/projector/docs_projector/closing.rb +1 -1
  292. data/lib/hecks/projector/docs_projector.rb +1 -1
  293. data/lib/hecks/projector/exporter/bindings.rb +40 -10
  294. data/lib/hecks/projector/exporter/capability_values.rb +55 -0
  295. data/lib/hecks/projector/narrate_projector/reactions.rb +1 -1
  296. data/lib/hecks/projector/target.rb +11 -0
  297. data/lib/hecks/projector.rb +32 -5
  298. data/lib/hecks/quality_control/adapters/github_checks.rb +12 -8
  299. data/lib/hecks/quality_control/adapters/github_issues.rb +8 -4
  300. data/lib/hecks/quality_control/cli/qa_open_pr.rb +2 -2
  301. data/lib/hecks/quality_control/cli/qa_sweep/checks/shrinking.rb +1 -1
  302. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +1 -1
  303. data/lib/hecks/quality_control/cli/qa_sweep.rb +1 -1
  304. data/lib/hecks/quality_control/quality_control.bluebook +6 -2
  305. data/lib/hecks/release/approval.rb +64 -0
  306. data/lib/hecks/router/{door.rb → aggregate_reader.rb} +5 -4
  307. data/lib/hecks/router/namespace_installer.rb +16 -16
  308. data/lib/hecks/router.rb +1 -1
  309. data/lib/hecks/runtime/best_effort.rb +22 -0
  310. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -2
  311. data/lib/hecks/runtime/command_interpreter.rb +3 -2
  312. data/lib/hecks/runtime/command_rules/emission.rb +1 -1
  313. data/lib/hecks/runtime/dispatcher.rb +4 -4
  314. data/lib/hecks/runtime/entity_element/location.rb +1 -1
  315. data/lib/hecks/runtime/event.rb +5 -0
  316. data/lib/hecks/runtime/identity.rb +7 -6
  317. data/lib/hecks/runtime/interpreting.rb +2 -1
  318. data/lib/hecks/runtime/invocation_builder.rb +1 -1
  319. data/lib/hecks/runtime/loader/boot_steps.rb +8 -5
  320. data/lib/hecks/runtime/loader.rb +10 -10
  321. data/lib/hecks/runtime/policy_interpreter/arguments.rb +7 -4
  322. data/lib/hecks/runtime/policy_interpreter/fan_out.rb +3 -3
  323. data/lib/hecks/runtime/policy_interpreter.rb +6 -6
  324. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -1
  325. data/lib/hecks/runtime/reaction_invocation.rb +1 -1
  326. data/lib/hecks/runtime/rebuild_sweep.rb +11 -1
  327. data/lib/hecks/runtime/registry/ask_verification.rb +35 -0
  328. data/lib/hecks/runtime/registry/hecksagon_rules.rb +10 -0
  329. data/lib/hecks/runtime/registry/merging.rb +1 -1
  330. data/lib/hecks/runtime/registry/providers.rb +16 -0
  331. data/lib/hecks/runtime/registry/read_repository.rb +6 -4
  332. data/lib/hecks/runtime/registry/verification.rb +4 -1
  333. data/lib/hecks/runtime/registry/world_defaults.rb +29 -0
  334. data/lib/hecks/runtime/registry.rb +1 -1
  335. data/lib/hecks/runtime/saga_interpreter/compensation.rb +1 -1
  336. data/lib/hecks/runtime/saga_interpreter/delivery.rb +4 -4
  337. data/lib/hecks/runtime/saga_interpreter/transitions.rb +1 -1
  338. data/lib/hecks/runtime/saga_interpreter.rb +3 -3
  339. data/lib/hecks/runtime/tenant_check.rb +5 -4
  340. data/lib/hecks/runtime/value/coercion.rb +2 -2
  341. data/lib/hecks/runtime/value/validation.rb +16 -4
  342. data/lib/hecks/runtime.rb +9 -9
  343. data/lib/hecks/rust_build/project_rust/target_ir.rb +1 -1
  344. data/lib/hecks/rust_build/wasm.rb +1 -1
  345. data/lib/hecks/rust_build/wasm_browser.rb +1 -1
  346. data/lib/hecks/rust_build.rb +11 -0
  347. data/lib/hecks/served_options.rb +53 -0
  348. data/lib/hecks/site/bluebook/site.bluebook +1 -0
  349. data/lib/hecks/storehouse/audit.rb +1 -1
  350. data/lib/hecks/storehouse/dispatching.rb +8 -6
  351. data/lib/hecks/storehouse/introspection.rb +2 -2
  352. data/lib/hecks/storehouse/querying.rb +6 -4
  353. data/lib/hecks/storehouse.rb +4 -4
  354. data/lib/hecks/tools/comment_style/source_file.rb +4 -1
  355. data/lib/hecks/tools/deploy_recipe_lint/fixtures.rb +4 -1
  356. data/lib/hecks/tools/lanes/rows.rb +6 -0
  357. data/lib/hecks/tools/oidc_manifests.rb +4 -5
  358. data/lib/hecks/tools/regeneration_run.rb +11 -4
  359. data/lib/hecks/tools/site_routes/editor_files.rb +12 -5
  360. data/lib/hecks/tools/tenant_provisioning.rb +2 -2
  361. data/lib/hecks/tools/tools_doc.rb +4 -4
  362. data/lib/hecks/version.rb +1 -1
  363. data/lib/hecks/vocabulary.rb +4 -4
  364. data/lib/hecks.rb +15 -15
  365. data/lib/rubocop/cop/hecks/env_dump.rb +69 -0
  366. data/lib/rubocop/cop/hecks/monotonic_duration.rb +53 -0
  367. data/lib/rubocop/cop/hecks/no_wall_clock_in_runtime.rb +34 -0
  368. data/lib/rubocop/cop/hecks/raw_shell_interpolation.rb +79 -0
  369. data/lib/rubocop/cop/hecks/sleep_in_spec.rb +34 -0
  370. data/lib/rubocop/cop/hecks/swallowed_rescue.rb +37 -0
  371. data/lib/rubocop/cop/hecks/symbol_string_key_mix.rb +50 -0
  372. data/lib/rubocop/cop/hecks/untyped_raise.rb +37 -0
  373. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  374. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
  375. data/prebuilt/verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json +1 -0
  376. data/rust/.gitignore +6 -0
  377. data/rust/Cargo.toml +6 -0
  378. data/rust/build/src/binding_pass.rs +103 -0
  379. data/rust/build/src/cargo_sync.rs +6 -0
  380. data/rust/build/src/lineage_pass.rs +3 -3
  381. data/rust/build/src/main.rs +17 -5
  382. data/rust/build/src/pipeline.rs +48 -5
  383. data/rust/build/src/seams_pass.rs +123 -0
  384. data/rust/build/src/translation_pass.rs +167 -0
  385. data/rust/codegen/src/asks.rs +103 -0
  386. data/rust/codegen/src/commands.rs +13 -8
  387. data/rust/codegen/src/constraints.rs +25 -0
  388. data/rust/codegen/src/domain_generator.rs +2 -0
  389. data/rust/codegen/src/expr_emitter.rs +13 -0
  390. data/rust/codegen/src/json_codec.rs +54 -15
  391. data/rust/codegen/src/main.rs +1 -0
  392. data/rust/codegen/src/mutations.rs +128 -3
  393. data/rust/codegen/src/queries.rs +11 -2
  394. data/rust/codegen/src/reactions.rs +1 -1
  395. data/rust/codegen/src/types.rs +166 -8
  396. data/rust/host/HECKS_RELEASE +1 -1
  397. data/rust/host/src/api.rs +4 -4
  398. data/rust/host/src/bin/validate_harness.rs +43 -0
  399. data/rust/host/src/boundary_fuzz/expr_json.rs +2 -2
  400. data/rust/host/src/boundary_fuzz/ir.rs +1 -0
  401. data/rust/host/src/boundary_fuzz/server.rs +1 -1
  402. data/rust/host/src/checkout.rs +35 -19
  403. data/rust/host/src/commerce_ir.rs +408 -2
  404. data/rust/host/src/expr_json.rs +118 -0
  405. data/rust/host/src/mint.rs +1 -1
  406. data/rust/host/src/payments/tests.rs +27 -1
  407. data/rust/host/src/payments.rs +12 -4
  408. data/rust/host/src/query_step.rs +22 -4
  409. data/rust/host/src/reference_validate.rs +653 -108
  410. data/rust/host/src/resend.rs +2 -0
  411. data/rust/host/src/server.rs +19 -6
  412. data/rust/host/src/ui_schema.rs +132 -5
  413. data/rust/host/src/web/newsletter.rs +7 -8
  414. data/rust/host/src/web/newsletter_send.rs +38 -13
  415. data/rust/host/src/web/registrations.rs +229 -26
  416. data/rust/host/src/web.rs +3 -3
  417. data/rust/parser/src/emit.rs +38 -6
  418. data/rust/parser/src/expr/ast_json.rs +7 -1
  419. data/rust/parser/src/expr/resolver.rs +171 -6
  420. data/rust/parser/src/ir.rs +7 -0
  421. data/rust/parser/src/keywords.rs +24 -0
  422. data/rust/parser/src/main.rs +3 -0
  423. data/rust/parser/src/parse/aggregate.rs +2 -1
  424. data/rust/parser/src/parse/chapter.rs +2 -2
  425. data/rust/parser/src/parse/command.rs +2 -0
  426. data/rust/parser/src/parse/hecksagon.rs +5 -2
  427. data/rust/parser/src/parse/lifecycle.rs +81 -0
  428. data/rust/parser/src/parse/mod.rs +15 -1
  429. data/rust/parser/src/parse/policy.rs +13 -2
  430. data/rust/parser/src/parse/value_object.rs +13 -8
  431. data/rust/parser/src/ruby_value.rs +1 -1
  432. data/rust/src/exemplar/commands.rs +4 -4
  433. data/rust/src/exemplar/constraints.rs +1 -1
  434. data/rust/src/exemplar/json.rs +43 -1
  435. data/rust/src/kernel/cli.rs +7 -5
  436. data/rust/src/kernel/dispatch.rs +2 -2
  437. data/rust/src/kernel/dispatch_guard_tests.rs +1 -1
  438. data/rust/src/kernel/expr.rs +66 -3
  439. data/rust/src/kernel/expression_operators/arithmetic.rs +130 -5
  440. data/rust/src/kernel/expression_operators/text.rs +26 -2
  441. data/rust/src/kernel/json.rs +15 -0
  442. data/rust/src/kernel/mod.rs +1 -1
  443. data/rust/src/kernel/orchestrate.rs +54 -5
  444. data/rust/src/kernel/orchestrate_guard_tests.rs +67 -0
  445. data/rust/src/kernel/pattern.rs +60 -0
  446. data/rust/src/kernel/reference_lookup.rs +90 -7
  447. metadata +140 -38
  448. data/lib/hecks/doors/cli_door/nesting.rb +0 -34
  449. data/lib/hecks/doors/cli_door/shorthand.rb +0 -85
  450. data/lib/hecks/doors/cli_door.rb +0 -124
  451. data/lib/hecks/doors/cli_runner/answers.rb +0 -138
  452. data/lib/hecks/doors/cli_runner/resolution.rb +0 -146
  453. data/lib/hecks/doors/cli_runner/settling.rb +0 -115
  454. data/lib/hecks/doors/cli_runner/streaming.rb +0 -90
  455. data/lib/hecks/doors/cli_runner/suggestions.rb +0 -43
  456. data/lib/hecks/doors/cli_runner.rb +0 -66
  457. data/lib/hecks/doors/command_request.rb +0 -119
  458. data/lib/hecks/doors/handle.rb +0 -208
  459. data/lib/hecks/doors/json_door.rb +0 -132
  460. data/lib/hecks/doors/launcher_options.rb +0 -247
  461. data/lib/hecks/doors/mcp_door/properties.rb +0 -47
  462. data/lib/hecks/doors/mcp_door/tool_calls.rb +0 -113
  463. data/lib/hecks/doors/mcp_door/tool_definitions/auditing.rb +0 -51
  464. data/lib/hecks/doors/mcp_door/tool_definitions/calling.rb +0 -69
  465. data/lib/hecks/doors/mcp_door/tool_definitions/reading.rb +0 -97
  466. data/lib/hecks/doors/mcp_door/tool_definitions.rb +0 -15
  467. data/lib/hecks/doors/mcp_door.rb +0 -156
  468. data/lib/hecks/doors/mcp_door_scope/argument_policy.rb +0 -92
  469. data/lib/hecks/doors/mcp_door_scope/env_problems.rb +0 -55
  470. data/lib/hecks/doors/mcp_door_scope/presenter.rb +0 -78
  471. data/lib/hecks/doors/mcp_door_scope.rb +0 -201
  472. data/lib/hecks/doors/mcp_guide_cache.rb +0 -75
  473. data/lib/hecks/doors/ruby_door/aggregate_door.rb +0 -162
  474. data/lib/hecks/doors/ruby_door/chapter.rb +0 -89
  475. data/lib/hecks/doors/ruby_door.rb +0 -80
  476. data/lib/hecks/doors/usage_cache.rb +0 -139
  477. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +0 -282
  478. data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +0 -109
  479. data/prebuilt/c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal +0 -0
  480. data/prebuilt/cb375cba61ca0dbe8ddb9739c306510fe05e5f4b7a9b6955caff8224c63ea0e0.marshal +0 -0
  481. data/prebuilt/verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json +0 -1
@@ -4,6 +4,8 @@ require "json"
4
4
  require_relative "../root_rows"
5
5
  require_relative "../routes_ts"
6
6
  require_relative "checks"
7
+ require_relative "preview"
8
+ require_relative "../../../version"
7
9
 
8
10
  module Hecks
9
11
  module Projections
@@ -20,11 +22,14 @@ module Hecks
20
22
  "Editor",
21
23
  fields: { domain: String, chapter: String, base_path: String, sso_path: String, session_cookie: String,
22
24
  host_cookie: String, host_env: String, host_default: String, roles: String, login: String,
23
- title: String, skip: String, media_dir: String, media_max_bytes: String },
25
+ title: String, brand: String, accent: String, logo: String, skip: String, media: String,
26
+ media_dir: String, media_max_bytes: String, chapter_roles: String, page_size: [String, Integer],
27
+ preview: String },
24
28
  required: %i[domain chapter host_env login],
25
29
  defaults: { base_path: "/editor", session_cookie: "hecks_editor", host_cookie: "hecks_session",
26
- host_default: "http://127.0.0.1:4322", roles: "Admin,Owner", title: "Editor", skip: "",
27
- media_dir: "media", media_max_bytes: "5242880" }
30
+ host_default: "http://127.0.0.1:4322", roles: "Admin,Owner", title: "Editor", brand: "",
31
+ accent: "", logo: "", skip: "", media: "", media_dir: "media", media_max_bytes: "5242880",
32
+ chapter_roles: "", page_size: "25", preview: "" }
28
33
  )
29
34
 
30
35
  # @return [Hash{Symbol => String}] the checked row, defaults filled
@@ -45,7 +50,7 @@ module Hecks
45
50
  # @param table [Table, nil] the checked route table
46
51
  # @raise [Table::Invalid] naming every problem when there is one
47
52
  def initialize(row, table: nil)
48
- @row = { sso_path: "#{row[:base_path]}/api/sso" }.merge(row)
53
+ @row = { sso_path: "#{row[:base_path]}/api/sso" }.merge(row, page_size: row[:page_size].to_s)
49
54
  problems = Checks.problems(@row, roles, table)
50
55
  return if problems.empty?
51
56
 
@@ -55,10 +60,40 @@ module Hecks
55
60
  # @return [String] the domain's directory, relative to the project
56
61
  def domain = row.fetch(:domain)
57
62
 
58
- # @return [String] the chapter the editor edits
59
- def chapter = row.fetch(:chapter)
63
+ # @return [String] the first chapter the editor edits, which names the generated package
64
+ def chapter = chapters.first.to_s
60
65
 
61
- # @return [Array<String>] the aggregates left out
66
+ # @return [Array<String>] every chapter the editor edits, in the order the row names them:
67
+ # `chapter` is one name or several, comma separated
68
+ def chapters = row.fetch(:chapter).split(",").map(&:strip).reject(&:empty?)
69
+
70
+ # @return [Hash{String => Array<String>}] the roles a chapter's aggregates are limited to,
71
+ # from `Chapter=Role,Role;Chapter=Role`; a chapter not named is open to every role the
72
+ # editor admits
73
+ def chapter_roles
74
+ row.fetch(:chapter_roles).split(";").map(&:strip).reject(&:empty?).to_h do |entry|
75
+ name, roles = entry.split("=", 2)
76
+ [name.to_s.strip, roles.to_s.split(",").map(&:strip).reject(&:empty?)]
77
+ end
78
+ end
79
+
80
+ # @return [String, nil] the URL template a record is previewed at, or nil for no preview
81
+ def preview = row.fetch(:preview).strip.then { |template| template.empty? ? nil : template }
82
+
83
+ # @return [Integer] how many instances a list shows to a page
84
+ def page_size = row.fetch(:page_size).to_i
85
+
86
+ # @return [String, nil] the other chapter whose picture aggregate the editor's pictures
87
+ # use, or nil when the pictures (if any) are in the editor's own chapter
88
+ def media_chapter = row.fetch(:media).strip.then { |name| name.empty? ? nil : name }
89
+
90
+ # @return [String, nil] the product name the header shows, or nil for the domain's name
91
+ def brand = row.fetch(:brand).strip.then { |name| name.empty? ? nil : name }
92
+
93
+ # @return [String, nil] the accent colour as hex, or nil for the default theme
94
+ def accent = row.fetch(:accent).strip.then { |hex| hex.empty? ? nil : hex }
95
+
96
+ # @return [Array<String>] the aggregates left out: `Name`, or `Chapter::Name`
62
97
  def skip = row.fetch(:skip).split(",").map(&:strip).reject(&:empty?)
63
98
 
64
99
  # @return [Array<String>] the roles that count as an editor
@@ -70,14 +105,22 @@ module Hecks
70
105
  # @return [Hash{String => String}] each placeholder to the text that replaces it
71
106
  def tokens
72
107
  { "__BANNER__" => RoutesTs::BANNER, "__PACKAGE__" => package, "__ROLES__" => RoutesTs.literal(roles),
73
- "__MEDIA_MAX_BYTES__" => row.fetch(:media_max_bytes), **quoted }
108
+ "__MEDIA_MAX_BYTES__" => row.fetch(:media_max_bytes), "__PAGE_SIZE__" => page_size.to_s,
109
+ "__CLIENT_VERSION__" => Hecks::VERSION, **preview_tokens, **quoted }
110
+ end
111
+
112
+ # @return [Hash{String => String}] the preview template as a literal, and the policy line
113
+ # that lets the editor frame its origin, which is empty when there is no preview
114
+ def preview_tokens
115
+ source = Preview.frame_source(row.fetch(:preview).strip)
116
+ { "__PREVIEW__" => JSON.generate(preview), "__FRAME_POLICY__" => source ? " \"frame-src #{source}\"," : "" }
74
117
  end
75
118
 
76
119
  # @return [Hash{String => String}] each text field's placeholder to the field as JSON
77
120
  def quoted
78
121
  { "TITLE" => :title, "BASE_PATH" => :base_path, "SSO_PATH" => :sso_path, "LOGIN" => :login,
79
122
  "SESSION_COOKIE" => :session_cookie, "HOST_COOKIE" => :host_cookie, "HOST_ENV" => :host_env,
80
- "HOST_DEFAULT" => :host_default, "MEDIA_DIR" => :media_dir }
123
+ "HOST_DEFAULT" => :host_default, "MEDIA_DIR" => :media_dir, "LOGO" => :logo }
81
124
  .to_h { |token, field| ["__#{token}__", JSON.generate(row.fetch(field))] }
82
125
  end
83
126
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ module CmsEditor
7
+ # The aggregates an attribute can name, found among the aggregates of one editor, and what
8
+ # a picker needs to know about each: its key, the attribute that labels it and the query
9
+ # that lists the ones to offer.
10
+ class Targets
11
+ # The attribute names that label an instance, in the order they are tried; an aggregate
12
+ # with none is labelled by its identity.
13
+ LABELS = %w[title name label].freeze
14
+
15
+ # The zero-argument queries that list what is on offer, tried in this order: ones that
16
+ # list everything, then the one that lists what is in use.
17
+ LISTINGS = %w[all list listing active].freeze
18
+
19
+ # @param aggregates [Array<Hash{String => Object}>] the aggregates as `Schema` shapes them
20
+ # @param media [Hash{String => Object}, nil] the picture aggregate, as `Media` says
21
+ def initialize(aggregates, media = nil)
22
+ @aggregates = aggregates
23
+ @media = media
24
+ end
25
+
26
+ # @return [Hash{String => Object}, nil] the aggregate named `name`, as a picker names it
27
+ def named(name, from)
28
+ pick(@aggregates.select { |agg| agg["name"] == name }, from)
29
+ end
30
+
31
+ # @return [Hash{String => Object}, nil] the aggregate whose identity is of type `stem`, or
32
+ # that is named `stem`, as a picker names it
33
+ def keyed_by(stem, from)
34
+ pick(@aggregates.select { |agg| identity_type(agg) == stem }, from) || named(stem, from)
35
+ end
36
+
37
+ # @return [Hash{String => Object}, nil] the aggregate that is `word` in snake case
38
+ def kind(word, from)
39
+ pick(@aggregates.select { |agg| snake(agg["name"]) == word }, from)
40
+ end
41
+
42
+ # @return [Hash{String => Object}, nil] the picture aggregate, which offers keys and
43
+ # accepts any other text as well, since a picture may be named by an address
44
+ def pictures
45
+ return nil unless @media
46
+
47
+ agg = @media["definition"] || @aggregates.find { |candidate| candidate["name"] == @media["aggregate"] }
48
+ return nil unless agg
49
+
50
+ described = describe(agg, agg["chapter"] || @media["domain"], @media["listing"])
51
+ described.merge("label" => @media.dig("fields", "alt", "name"), "strict" => false)
52
+ end
53
+
54
+ private
55
+
56
+ def pick(found, from)
57
+ own = found.select { |agg| agg["chapter"] == from["chapter"] }
58
+ chosen = own.first || (found.first if found.size == 1)
59
+ chosen && describe(chosen, chosen["chapter"], listing(chosen)).merge("strict" => true)
60
+ end
61
+
62
+ def describe(agg, chapter, listing)
63
+ { "target" => agg["name"], "chapter" => chapter, "key" => agg["identity"], "label" => label(agg),
64
+ "listing" => listing }.compact
65
+ end
66
+
67
+ def identity_type(agg)
68
+ agg["attributes"].find { |attr| attr["name"] == agg["identity"] }&.fetch("type", nil)
69
+ end
70
+
71
+ def label(agg)
72
+ plain = agg["attributes"].reject { |attr| attr["list"] || attr["widget"] == "body" }.map { |attr| attr["name"] }
73
+ LABELS.find { |name| plain.include?(name) } || agg["identity"]
74
+ end
75
+
76
+ def listing(agg)
77
+ named = agg["queries"].select { |query| query["attributes"].empty? }.map { |query| query["name"] }
78
+ LISTINGS.filter_map { |word| named.find { |name| name.downcase == word } }.first
79
+ end
80
+
81
+ def snake(name) = name.gsub(/([a-z0-9])([A-Z])/, "\\1_\\2").downcase
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -6,14 +6,31 @@
6
6
  "node": ">=22.18"
7
7
  },
8
8
  "scripts": {
9
+ "clean": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\"",
10
+ "build": "npm run clean && npm run build:css && npm run build:js",
11
+ "build:css": "tailwindcss --input src/browser/app.css --output dist/editor.css --minify",
12
+ "build:js": "esbuild src/browser/main.js --bundle --splitting --format=esm --target=es2022 --minify --outdir=dist --entry-names=editor --chunk-names=[name]-[hash]",
13
+ "prestart": "npm run build",
9
14
  "start": "node src/server.ts",
10
15
  "check": "tsc -p tsconfig.json"
11
16
  },
12
17
  "dependencies": {
13
- "@hecks/client": "^3.8.0"
18
+ "@hecks/client": "^__CLIENT_VERSION__",
19
+ "@tiptap/core": "3.31.4",
20
+ "@tiptap/extension-blockquote": "3.31.4",
21
+ "@tiptap/extension-image": "3.31.4",
22
+ "@tiptap/extensions": "3.31.4",
23
+ "@tiptap/pm": "3.31.4",
24
+ "@tiptap/starter-kit": "3.31.4",
25
+ "tiptap-extension-flat-list": "0.4.0"
14
26
  },
15
27
  "devDependencies": {
28
+ "@tailwindcss/cli": "4.3.3",
29
+ "@tailwindcss/typography": "0.5.20",
16
30
  "@types/node": "^22.10.0",
31
+ "daisyui": "5.7.47",
32
+ "esbuild": "0.28.2",
33
+ "tailwindcss": "4.3.3",
17
34
  "typescript": "^5.8.0"
18
35
  }
19
36
  }
@@ -7,25 +7,40 @@ __BANNER__
7
7
  // (auth/membership.ts). A visitor with neither is sent to the site's login page; nothing in the
8
8
  // domain is read or changed before that. Posts must come from this site (the cookie is SameSite=Lax
9
9
  // and the Origin header is checked too).
10
- import { readFileSync } from "node:fs";
10
+ import { randomUUID } from "node:crypto";
11
11
 
12
- import { DomainRefusal, DomainUnavailable, rowsOf } from "@hecks/client";
13
- import type { HostClient } from "@hecks/client";
12
+ import { DomainRefusal, DomainUnavailable } from "@hecks/client";
13
+ import type { Answer, HostClient } from "@hecks/client";
14
14
 
15
+ import { serveAsset } from "./assets.ts";
15
16
  import { authSecret, membershipCheck } from "./auth/membership.ts";
16
17
  import type { Membership } from "./auth/membership.ts";
17
- import { readSession } from "./auth/session.ts";
18
+ import { clearedCookie, readSession } from "./auth/session.ts";
18
19
  import { signIn } from "./auth/sso.ts";
19
- import { idOf, instanceIn, runCommand } from "./commands.ts";
20
+ import { choicesFor, unknownKey } from "./choices.ts";
21
+ import { idOf, instanceIn, reading, runCommand } from "./commands.ts";
20
22
  import type { State } from "./commands.ts";
21
23
  import { EDITOR } from "./config.ts";
22
- import { hostClient } from "./host.ts";
24
+ import { flashCookie, readFlash, spentFlash } from "./flash.ts";
25
+ import type { Flash } from "./flash.ts";
26
+ import { hostClient, rowsAnswered } from "./host.ts";
27
+ import { at, keyOf, locate, mayEdit, qualified } from "./names.ts";
23
28
  import { SCHEMA } from "./schema.ts";
24
29
  import type { Aggregate, Command } from "./schema.ts";
30
+ import { available, needsPage } from "./ui/detail.ts";
31
+ import { href } from "./ui/html.ts";
25
32
  import { commandArguments, parseForm } from "./ui/input.ts";
26
- import { commandPage, detailPage, home, listPage, notFound } from "./ui/pages.ts";
33
+ import { backOf, plainValue } from "./ui/links.ts";
34
+ import type { Back } from "./ui/links.ts";
35
+ import { commandPage, detailPage, home, instanceCommandPage, listPage, notFound, scheduledPage } from "./ui/pages.ts";
27
36
  import type { Submitted } from "./ui/pages.ts";
28
- import { href } from "./ui/html.ts";
37
+ import { knownStatus, narrow, slice, viewOf } from "./ui/paging.ts";
38
+ import type { View } from "./ui/paging.ts";
39
+ import { schedules, subjectQuery } from "./ui/schedule.ts";
40
+ import { decorate } from "./ui/shell.ts";
41
+ import type { World } from "./ui/world.ts";
42
+ import { schedulingAggregate } from "./ui/world.ts";
43
+ import { done, commandLabel, plural } from "./ui/words.ts";
29
44
  __MEDIA_IMPORT__
30
45
 
31
46
  export interface AppOptions {
@@ -38,12 +53,39 @@ export interface AppOptions {
38
53
  __MEDIA_OPTION__
39
54
  }
40
55
 
41
- const html = (body: string, status = 200): Response =>
42
- new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
56
+ /**
57
+ * What every page is sent with. The editor loads its own script, stylesheet, pictures and icons and
58
+ * nothing else, so the policy names no other origin and allows no inline script and no inline style
59
+ * (no `style` attribute, no style element: the stylesheet is a file, and the writing surface is told
60
+ * to inject none). `data:` in `img-src` is for the sprite-free case of a picture drawn from a data
61
+ * address; `blob:` shows a picture chosen for upload before it is sent. Script may still set styles
62
+ * through the page's object model, which the policy allows.
63
+ */
64
+ const POLICY = [
65
+ "default-src 'none'",
66
+ "script-src 'self'",
67
+ "style-src 'self'",
68
+ "img-src 'self' data: blob:",
69
+ "connect-src 'self'",
70
+ "form-action 'self'",
71
+ __FRAME_POLICY__
72
+ "base-uri 'none'",
73
+ "frame-ancestors 'none'",
74
+ ].join("; ");
75
+
76
+ const PAGE_HEADERS = {
77
+ "Content-Type": "text/html; charset=utf-8",
78
+ "Cache-Control": "no-store",
79
+ "Content-Security-Policy": POLICY,
80
+ "Referrer-Policy": "same-origin",
81
+ "X-Content-Type-Options": "nosniff",
82
+ };
83
+
84
+ const plain = (body: string, status: number): Response => new Response(body, { status, headers: { "Content-Type": "text/plain; charset=utf-8" } });
43
85
 
44
- const redirect = (location: string, status = 303, cookie?: string): Response => {
86
+ const redirect = (location: string, status = 303, ...cookies: string[]): Response => {
45
87
  const headers = new Headers({ Location: location });
46
- if (cookie) headers.append("Set-Cookie", cookie);
88
+ for (const cookie of cookies) headers.append("Set-Cookie", cookie);
47
89
  return new Response(null, { status, headers });
48
90
  };
49
91
 
@@ -54,6 +96,14 @@ function sameSite(request: Request): boolean {
54
96
  return (request.headers.get("sec-fetch-site") ?? "same-origin") !== "cross-site";
55
97
  }
56
98
 
99
+ /** What a route needs to answer: show a page for this person, or send them on with a notice. */
100
+ interface Scene {
101
+ /** The person's role, which decides the chapters they may edit. */
102
+ role: string | null;
103
+ show(body: string, status?: number, notice?: Flash): Promise<Response>;
104
+ sent(location: string, message: string): Response;
105
+ }
106
+
57
107
  /** The editor, as a handler. */
58
108
  export function createApp(options: AppOptions = {}): (request: Request) => Promise<Response> {
59
109
  const secret = options.secret ?? authSecret();
@@ -62,94 +112,193 @@ export function createApp(options: AppOptions = {}): (request: Request) => Promi
62
112
  const client = hostClient({ url: options.url, fetch: options.fetch });
63
113
  __MEDIA_SETUP__
64
114
 
115
+ /**
116
+ * The instances each aggregate holds, for the navigation; a host that cannot say shows no counts.
117
+ * The host has no count to ask for, so they are the lengths of what one read answers, the same
118
+ * read a page of a list is made from.
119
+ */
120
+ async function counts(from: HostClient): Promise<Record<string, number>> {
121
+ try {
122
+ const answer = await from.read();
123
+ return Object.fromEntries(SCHEMA.aggregates.map((agg) => [keyOf(agg), from.instancesOf(answer, qualified(agg)).length]));
124
+ } catch {
125
+ return {};
126
+ }
127
+ }
128
+
129
+ function sceneFor(email: string, role: string | null, flash: Flash | null, from: HostClient): Scene {
130
+ return {
131
+ role,
132
+ async show(body, status = 200, notice) {
133
+ const text = decorate(body, { email, role, counts: await counts(from), flash: notice ?? flash });
134
+ const response = new Response(text, { status, headers: PAGE_HEADERS });
135
+ if (flash) response.headers.append("Set-Cookie", spentFlash());
136
+ return response;
137
+ },
138
+ sent: (location, message) => redirect(location, 303, flashCookie({ kind: "success", message }, secret, now)),
139
+ };
140
+ }
141
+
65
142
  async function handle(request: Request): Promise<Response> {
66
143
  const url = new URL(request.url);
67
144
  const path = url.pathname.replace(/\/+$/, "") || "/";
68
145
  if (path === EDITOR.ssoPath) return enter(url, secret, membership, now);
69
- if (path !== EDITOR.basePath && !path.startsWith(`${EDITOR.basePath}/`)) return html(notFound(path), 404);
146
+ if (path !== EDITOR.basePath && !path.startsWith(`${EDITOR.basePath}/`)) return anonymous(notFound(path), 404);
147
+ if (path === href("logout") && request.method === "POST") return signOut(request);
70
148
 
71
149
  const email = readSession(request.headers.get("cookie"), secret, now);
72
150
  if (!email || !(await membership.isAdmitted(email))) {
73
- return request.method === "GET" ? redirect(EDITOR.login, 302) : new Response("Sign in again.", { status: 403 });
151
+ return request.method === "GET" ? redirect(EDITOR.login, 302) : plain("Sign in again.", 403);
74
152
  }
75
- if (request.method === "POST" && !sameSite(request)) return new Response("Cross-site post refused.", { status: 403 });
153
+ if (request.method === "POST" && !sameSite(request)) return plain("Cross-site post refused.", 403);
76
154
  const segments = path.slice(EDITOR.basePath.length).split("/").filter(Boolean).map(decodeURIComponent);
155
+ const role = membership.roleOf ? await membership.roleOf(email) : null;
77
156
  __MEDIA_ROUTE__
78
- return route(client, request, url, segments);
157
+ const reader = request.method === "GET" ? reading(client) : client;
158
+ const scene = sceneFor(email, role, readFlash(request.headers.get("cookie"), secret, now), reader);
159
+ return route(reader, request, url, segments, scene);
79
160
  }
80
161
 
81
162
  return async (request) => {
82
163
  try {
83
164
  return await handle(request);
84
165
  } catch (err) {
85
- if (err instanceof DomainUnavailable) return html(notFound(`The domain could not be reached: ${err.message}`), 502);
166
+ if (err instanceof DomainUnavailable) return anonymous(notFound(`The domain could not be reached: ${err.message}`, "The domain is not answering"), 502);
86
167
  throw err;
87
168
  }
88
169
  };
89
170
  }
90
171
 
91
- /** The browser modules the rich-text widget loads (with the picture picker when there are pictures), for signed-in editors only. */
92
- const ASSETS = __ASSETS__;
172
+ /** A page for someone with no session to show, such as a path the editor does not serve. */
173
+ function anonymous(body: string, status: number): Response {
174
+ return new Response(decorate(body, { email: "", role: null, counts: {}, flash: null }), { status, headers: PAGE_HEADERS });
175
+ }
93
176
 
94
- function asset(name: string): Response {
95
- if (!ASSETS.includes(name)) return html(notFound(`No asset ${name}.`), 404);
96
- const text = readFileSync(new URL(`./ui/${name}`, import.meta.url), "utf8");
97
- return new Response(text, { headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "private, max-age=300" } });
177
+ /** Ends the session: the cookie is cleared and the person is sent to the site's login page. */
178
+ function signOut(request: Request): Response {
179
+ if (!sameSite(request)) return plain("Cross-site post refused.", 403);
180
+ return redirect(EDITOR.login, 303, clearedCookie());
98
181
  }
99
182
 
100
183
  async function enter(url: URL, secret: string, membership: Membership, now: () => number): Promise<Response> {
101
184
  const result = await signIn(url.searchParams.get("token"), secret, membership, now);
102
- if (!result.ok) return new Response(result.message, { status: result.status });
185
+ if (!result.ok) return plain(result.message, result.status);
103
186
  return redirect(result.location, 302, result.cookie);
104
187
  }
105
188
 
106
- function route(client: HostClient, request: Request, url: URL, segments: string[]): Promise<Response> | Response {
107
- const [name, verb, ...rest] = segments;
108
- if (segments.length === 0) return html(home());
109
- if (name === "assets" && segments.length === 2 && request.method === "GET") return asset(verb);
110
- const agg = SCHEMA.aggregates.find((candidate) => candidate.name === name);
111
- if (!agg) return html(notFound(`No aggregate ${name}.`), 404);
112
- if (verb === undefined) return list(client, agg, url);
113
- if (verb === "id" && rest.length === 1 && request.method === "GET") return detail(client, agg, rest[0], null);
114
- if (request.method === "POST") return post(client, request, agg, verb, rest);
115
- if ((verb === "new" || verb === "run") && rest.length === 1) return commandView(agg, verb, rest[0]);
116
- return html(notFound(`No page ${segments.join("/")}.`), 404);
189
+ function route(client: HostClient, request: Request, url: URL, segments: string[], scene: Scene): Promise<Response> | Response {
190
+ if (segments.length === 0) return scene.show(home());
191
+ if (segments[0] === "assets" && segments.length === 2 && request.method === "GET") return serveAsset(segments[1], request);
192
+ if (segments[0] === "scheduled" && segments.length === 1 && request.method === "GET" && SCHEMA.scheduling) return scheduledView(client, scene);
193
+ const { agg, rest: [verb, ...rest] } = locate(segments);
194
+ if (!agg) return scene.show(notFound(`No aggregate ${segments.slice(0, SCHEMA.chapters ? 2 : 1).join("/")}.`), 404);
195
+ if (!mayEdit(agg, scene.role)) return scene.show(notFound(`${plural(agg.name)} are not open to your role.`, "Not open to your role"), 403);
196
+ if (verb === undefined) return list(client, agg, url, scene);
197
+ if (verb === "id" && rest.length === 1 && request.method === "GET") return detail(client, agg, rest[0], null, scene);
198
+ if (verb === "id" && rest.length === 2 && request.method === "GET") return instanceView(client, agg, rest[0], rest[1], url, scene);
199
+ if (request.method === "POST") return post(client, request, agg, verb, rest, scene);
200
+ if ((verb === "new" || verb === "run") && rest.length === 1) return commandView(client, agg, verb, rest[0], url, scene);
201
+ return scene.show(notFound(`No page ${segments.join("/")}.`), 404);
202
+ }
203
+
204
+ /** The pairs of an address a list keeps on every link: its view and that view's arguments, not the filters or the page. */
205
+ const KEPT = (key: string): boolean => !["_page", "_q", "_status"].includes(key);
206
+
207
+ /** The filters and the page an address asks for. */
208
+ function asked(url: URL): { q: string; status: string; page: number; size: number; params: [string, string][] } {
209
+ const page = Number.parseInt(url.searchParams.get("_page") ?? "1", 10);
210
+ return {
211
+ q: (url.searchParams.get("_q") ?? "").trim().slice(0, 200),
212
+ status: url.searchParams.get("_status") ?? "",
213
+ page: Number.isFinite(page) ? page : 1,
214
+ size: EDITOR.pageSize,
215
+ params: [...url.searchParams].filter(([key]) => KEPT(key)),
216
+ };
117
217
  }
118
218
 
119
- async function list(client: HostClient, agg: Aggregate, url: URL): Promise<Response> {
219
+ async function list(client: HostClient, agg: Aggregate, url: URL, scene: Scene): Promise<Response> {
120
220
  const query = agg.queries.find((candidate) => candidate.name === url.searchParams.get("query")) ?? null;
121
221
  const form = parseForm(url.search.slice(1));
122
222
  const args = query ? commandArguments(agg, query.attributes, form) : {};
123
- const asked = query !== null && (query.attributes.length === 0 || Object.keys(args).length > 0);
124
- if (query && !asked) return html(listPage(agg, [], query, null));
223
+ const wanted = query !== null && (query.attributes.length === 0 || Object.keys(args).length > 0);
224
+ const raw = asked(url);
225
+ const filters = { ...raw, status: knownStatus(agg, raw.status) };
226
+ const choices = query ? await choicesFor(client, agg, query.attributes) : {};
227
+ const none = viewOf(0, 0, filters);
228
+ if (query && !wanted) return scene.show(listPage(agg, [], query, null, false, none, choices));
125
229
  try {
126
- const rows = await rowsFor(client, agg, asked ? query : null, args);
127
- return html(listPage(agg, rows, query, null));
230
+ const found = await rowsFor(client, agg, wanted ? query : null, args);
231
+ const matching = narrow(agg, found, filters.q, filters.status);
232
+ const view: View = viewOf(matching.length, found.length, filters);
233
+ return scene.show(listPage(agg, slice(matching, view), query, null, true, view, choices));
128
234
  } catch (err) {
129
- if (err instanceof DomainRefusal) return html(listPage(agg, [], query, err.message), 200);
235
+ if (err instanceof DomainRefusal) return scene.show(listPage(agg, [], query, err.message, true, none, choices), 200);
130
236
  throw err;
131
237
  }
132
238
  }
133
239
 
134
240
  async function rowsFor(client: HostClient, agg: Aggregate, query: { name: string } | null, args: State): Promise<[string, State][]> {
135
- if (!query) return client.instancesOf(await client.read(), agg.name);
136
- const rows = rowsOf(await client.query(`${agg.name}.${query.name}`, args), `${agg.name}.${query.name}`) ?? [];
241
+ if (!query) return client.instancesOf(await client.read(), qualified(agg));
242
+ const name = `${qualified(agg)}.${query.name}`;
243
+ const rows = rowsAnswered(await client.query(name, args), name);
137
244
  return rows.flatMap((row): [string, State][] => {
138
245
  const id = idOf(agg, row);
139
246
  return id === null ? [] : [[id, row]];
140
247
  });
141
248
  }
142
249
 
143
- async function detail(client: HostClient, agg: Aggregate, id: string, shown: Submitted | null, status = 200): Promise<Response> {
250
+ const refused = (agg: Aggregate, command: Command): Flash => ({ kind: "error", message: `${commandLabel(agg, command)} was not applied. Fix what is marked and try again.` });
251
+
252
+ /** What a page of one record may read of the rest of the domain, from the read the page already made. */
253
+ async function worldFor(client: HostClient, answer: Answer, role: string | null, record?: { agg: Aggregate; id: string }): Promise<World> {
254
+ const world: World = { role, lookup: (agg, id) => instanceIn(client, answer, agg, id), rows: (agg) => client.instancesOf(answer, qualified(agg)) };
255
+ const asked = record && schedules(record.agg, role) ? subjectQuery(record.agg, record.id) : null;
256
+ const sched = schedulingAggregate();
257
+ if (!asked || !sched) return world;
258
+ const name = `${qualified(sched)}.${asked.name}`;
259
+ try {
260
+ const rows = rowsAnswered(await client.query(name, asked.args), name);
261
+ return { ...world, actions: rows.flatMap((row): [string, State][] => (idOf(sched, row) === null ? [] : [[idOf(sched, row) as string, row]])) };
262
+ } catch (err) {
263
+ if (err instanceof DomainRefusal || err instanceof DomainUnavailable) return world;
264
+ throw err;
265
+ }
266
+ }
267
+
268
+ /** The screen of every pending action, for those who may edit them. */
269
+ async function scheduledView(client: HostClient, scene: Scene): Promise<Response> {
270
+ const sched = schedulingAggregate();
271
+ if (!sched) return scene.show(notFound("Nothing is scheduled here."), 404);
272
+ if (!mayEdit(sched, scene.role)) return scene.show(notFound("Scheduled actions are not open to your role.", "Not open to your role"), 403);
273
+ return scene.show(scheduledPage(await worldFor(client, await client.read(), scene.role)));
274
+ }
275
+
276
+ async function detail(client: HostClient, agg: Aggregate, id: string, shown: Submitted | null, scene: Scene, status = 200): Promise<Response> {
277
+ const answer = await client.read();
278
+ const values = instanceIn(client, answer, agg, id);
279
+ if (!values) return scene.show(notFound(`No ${agg.name} ${id}.`), 404);
280
+ const command = agg.commands.find((candidate) => candidate.name === shown?.command);
281
+ const world = await worldFor(client, answer, scene.role, { agg, id });
282
+ return scene.show(detailPage(agg, id, values, shown, world), status, shown && command ? refused(agg, command) : undefined);
283
+ }
284
+
285
+ /** The page of a command that acts on one instance, when that instance is in a state it applies to. */
286
+ async function instanceView(client: HostClient, agg: Aggregate, id: string, name: string, url: URL, scene: Scene): Promise<Response> {
144
287
  const values = instanceIn(client, await client.read(), agg, id);
145
- if (!values) return html(notFound(`No ${agg.name} ${id}.`), 404);
146
- return html(detailPage(agg, id, values, shown), status);
288
+ if (!values) return scene.show(notFound(`No ${agg.name} ${id}.`), 404);
289
+ const command = commandFor(agg, "id", name);
290
+ if (!command || !available(agg, command, values)) return scene.show(notFound(`${name} does not apply to ${id} now.`), 404);
291
+ const choices = await choicesFor(client, agg, command.attributes);
292
+ return scene.show(instanceCommandPage(agg, id, command, values, null, choices, backOf(url.searchParams.get("_back"))));
147
293
  }
148
294
 
149
- function commandView(agg: Aggregate, verb: string, name: string): Response {
295
+ /** A command's page; a link may fill its inputs from the address and name the page to return to. */
296
+ async function commandView(client: HostClient, agg: Aggregate, verb: string, name: string, url: URL, scene: Scene): Promise<Response> {
150
297
  const command = commandFor(agg, verb, name);
151
- if (!command) return html(notFound(`No command ${name}.`), 404);
152
- return html(commandPage(agg, command, href(agg.name, verb, name), null));
298
+ if (!command) return scene.show(notFound(`No command ${name}.`), 404);
299
+ const state = commandArguments(agg, command.attributes, parseForm(url.search.slice(1)));
300
+ const back = backOf(url.searchParams.get("_back"));
301
+ return scene.show(commandPage(agg, command, href(...at(agg), verb, name), null, await choicesFor(client, agg, command.attributes), { state, back }));
153
302
  }
154
303
 
155
304
  /** The command a `new` (creating) or `run` (acting on another aggregate) path names, or null. */
@@ -161,16 +310,61 @@ function commandFor(agg: Aggregate, verb: string, name: string): Command | null
161
310
  return command.on === agg.name ? command : null;
162
311
  }
163
312
 
164
- async function post(client: HostClient, request: Request, agg: Aggregate, verb: string, rest: string[]): Promise<Response> {
313
+ /** What a form is refused for before the command is sent: a key a picker names that is not there. */
314
+ async function unchecked(client: HostClient, agg: Aggregate, command: Command, args: State): Promise<{ ok: false; message: string; field: string } | null> {
315
+ const problem = await unknownKey(client, agg, command.attributes, args);
316
+ return problem ? { ok: false, message: problem.message, field: problem.field } : null;
317
+ }
318
+
319
+ /** The scheduled-action record the editor makes itself: a creating command gets an identity nobody typed. */
320
+ function withMadeIdentity(agg: Aggregate, command: Command, args: State): State {
321
+ const spec = SCHEMA.scheduling;
322
+ if (!spec?.generated || !command.creates || command.name !== spec.schedule || agg.name !== spec.aggregate || agg.chapter !== spec.chapter) return args;
323
+ const identity = agg.attributes.find((attr) => attr.name === agg.identity);
324
+ return identity ? { ...args, [identity.name]: plainValue(agg, identity, randomUUID()) } : args;
325
+ }
326
+
327
+ /**
328
+ * A draft saved by the page's script while the person writes. It is answered in plain text, never
329
+ * with a redirect or a notice, and it only ever runs the aggregate's draft-saving command, so the
330
+ * live attribute is never written from here.
331
+ */
332
+ async function autosave(client: HostClient, agg: Aggregate, command: Command, args: State, to: string | undefined): Promise<Response> {
333
+ if (!to || agg.drafts?.save !== command.name) return plain("Only a draft can be saved this way.", 400);
334
+ const outcome = (await unchecked(client, agg, command, args)) ?? (await runCommand(client, agg, command, args, to));
335
+ if (outcome.ok) return plain("Saved.", 200);
336
+ const refused = plain(outcome.message, 422);
337
+ if ("problems" in outcome && outcome.problems?.length) refused.headers.set("x-editor-problems", encodeURIComponent(JSON.stringify(outcome.problems)));
338
+ return refused;
339
+ }
340
+
341
+ /** Where a command that worked leads: the page the person came from, another blank form, or the instance. */
342
+ function afterwards(agg: Aggregate, command: Command, again: boolean, id: string, back: Back | null): string {
343
+ if (back) return back.path;
344
+ return command.creates && again ? href(...at(agg), "new", command.name) : href(...at(agg), "id", id);
345
+ }
346
+
347
+ async function post(client: HostClient, request: Request, agg: Aggregate, verb: string, rest: string[], scene: Scene): Promise<Response> {
165
348
  const onInstance = verb === "id" && rest.length === 2;
166
349
  const command = onInstance ? commandFor(agg, "id", rest[1]) : rest.length === 1 ? commandFor(agg, verb, rest[0]) : null;
167
- if (!command) return html(notFound("No such command."), 404);
350
+ if (!command) return scene.show(notFound("No such command."), 404);
168
351
  const form = parseForm(await request.text());
169
- const args = commandArguments(agg, command.attributes, form);
352
+ const args = withMadeIdentity(agg, command, commandArguments(agg, command.attributes, form, command.empty));
170
353
  const to = onInstance ? rest[0] : command.creates ? undefined : (form.get("__to") ?? "");
171
- const outcome = to === "" ? { ok: false as const, message: "Name the instance to act on." } : await runCommand(client, agg, command, args, to);
172
- if (outcome.ok) return redirect(href(agg.name, "id", outcome.id));
173
- const shown: Submitted = { command: command.name, message: outcome.message, values: { ...args, __to: to } };
174
- if (onInstance) return detail(client, agg, rest[0], shown, 422);
175
- return html(commandPage(agg, command, href(agg.name, verb, command.name), shown), 422);
354
+ if (request.headers.get("x-editor-autosave") === "1") return autosave(client, agg, command, args, to);
355
+ const outcome = to === "" ? { ok: false as const, message: "Name the instance to act on." } : ((await unchecked(client, agg, command, args)) ?? (await runCommand(client, agg, command, args, to)));
356
+ const back = backOf(form.get("__back"));
357
+ if (outcome.ok) return scene.sent(afterwards(agg, command, form.get("__again") === "1", outcome.id, back), done(agg, command));
358
+ const shown: Submitted = { command: command.name, message: outcome.message, values: { ...args, __to: to }, field: outcome.field, problems: "problems" in outcome ? outcome.problems : undefined };
359
+ if (onInstance) return refusedOnInstance(client, agg, rest[0], command, shown, scene, back);
360
+ const choices = await choicesFor(client, agg, command.attributes);
361
+ return scene.show(commandPage(agg, command, href(...at(agg), verb, command.name), shown, choices, { back }), 422, refused(agg, command));
362
+ }
363
+
364
+ /** The refusal of a command run on one instance: shown on its form, or on the page it was run from. */
365
+ async function refusedOnInstance(client: HostClient, agg: Aggregate, id: string, command: Command, shown: Submitted, scene: Scene, back: Back | null): Promise<Response> {
366
+ if (!needsPage(command)) return detail(client, agg, id, shown, scene, 422);
367
+ const values = instanceIn(client, await client.read(), agg, id);
368
+ if (!values) return scene.show(notFound(`No ${agg.name} ${id}.`), 404);
369
+ return scene.show(instanceCommandPage(agg, id, command, values, shown, await choicesFor(client, agg, command.attributes), back), 422, refused(agg, command));
176
370
  }