hecks 3.9.1 → 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 (504) 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 +124 -2
  116. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  117. data/lib/hecks/deploy/oidc.json +16 -3
  118. data/lib/hecks/doc/reference/rendering.rb +6 -3
  119. data/lib/hecks/forms/app/commands.rb +8 -7
  120. data/lib/hecks/forms/app/queries.rb +2 -1
  121. data/lib/hecks/forms/app.rb +1 -1
  122. data/lib/hecks/forms/record_renderer.rb +1 -1
  123. data/lib/hecks/framework/bluebook/privacy.bluebook +11 -0
  124. data/lib/hecks/fuzzing/concurrent_dispatch.rb +4 -1
  125. data/lib/hecks/fuzzing/failure_triage.rb +2 -1
  126. data/lib/hecks/fuzzing/mutation/run.rb +11 -4
  127. data/lib/hecks/fuzzing/properties/catalog.rb +6 -4
  128. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +18 -11
  129. data/lib/hecks/fuzzing/properties/guards.rb +1 -1
  130. data/lib/hecks/fuzzing/self_consistency/saga_redelivery.rb +1 -1
  131. data/lib/hecks/fuzzing/self_consistency.rb +6 -2
  132. data/lib/hecks/fuzzing/shrinker.rb +3 -1
  133. data/lib/hecks/fuzzing/value_generator.rb +19 -5
  134. data/lib/hecks/gate/stages.yml +4 -10
  135. data/lib/hecks/grammar/evolve.rb +1 -1
  136. data/lib/hecks/grammar/expression_operators.json +372 -0
  137. data/lib/hecks/grammar/operators.rb +3 -1
  138. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +10 -10
  139. data/lib/hecks/hecks/adapters/codebase/inspection/rubycritic.rb +35 -0
  140. data/lib/hecks/hecks/adapters/codebase/inspection.rb +104 -0
  141. data/lib/hecks/hecks/adapters/codebase/publishing.rb +2 -1
  142. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +12 -2
  143. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +2 -1
  144. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +1 -1
  145. data/lib/hecks/hecks/adapters/cost_explorer.rb +8 -5
  146. data/lib/hecks/hecks/adapters/deploy_toolchain/clearing.rb +51 -0
  147. data/lib/hecks/hecks/adapters/deploy_toolchain/statuses.rb +2 -1
  148. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -0
  149. data/lib/hecks/hecks/adapters/finding_github.rb +13 -6
  150. data/lib/hecks/hecks/adapters/first_admin.rb +2 -2
  151. data/lib/hecks/hecks/adapters/github_rulesets.rb +6 -3
  152. data/lib/hecks/hecks/adapters/in_process_boot.rb +1 -1
  153. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +1 -1
  154. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +10 -20
  155. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +28 -5
  156. data/lib/hecks/hecks/adapters/local_files.rb +2 -2
  157. data/lib/hecks/hecks/adapters/rehearsal/audit_report.rb +107 -0
  158. data/lib/hecks/hecks/adapters/rehearsal/census.rb +71 -0
  159. data/lib/hecks/hecks/adapters/rehearsal/declaration.rb +133 -0
  160. data/lib/hecks/hecks/adapters/rehearsal/environment.rb +83 -0
  161. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +142 -0
  162. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +96 -0
  163. data/lib/hecks/hecks/adapters/rehearsal/postgres_census.rb +100 -0
  164. data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +69 -0
  165. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +129 -0
  166. data/lib/hecks/hecks/adapters/rehearsal/rows.rb +41 -0
  167. data/lib/hecks/hecks/adapters/rehearsal/rust_audit.rb +83 -0
  168. data/lib/hecks/hecks/adapters/rehearsal/subject.rb +104 -0
  169. data/lib/hecks/hecks/adapters/rehearsal.rb +54 -0
  170. data/lib/hecks/hecks/adapters/rehearsal_store.adapter +7 -0
  171. data/lib/hecks/hecks/adapters/rehearsal_store.rb +81 -0
  172. data/lib/hecks/hecks/adapters/site_toolchain/role_prober.rb +78 -0
  173. data/lib/hecks/hecks/adapters/site_toolchain.rb +29 -4
  174. data/lib/hecks/hecks/adapters/terminal.rb +13 -11
  175. data/lib/hecks/hecks/adapters/world_clearing.rb +65 -0
  176. data/lib/hecks/hecks/codebase.behaviors +13 -1
  177. data/lib/hecks/hecks/codebase.bluebook +239 -5
  178. data/lib/hecks/hecks/custodian.behaviors +24 -6
  179. data/lib/hecks/hecks/custodian.bluebook +6 -1227
  180. data/lib/hecks/hecks/deploy.behaviors +1 -1
  181. data/lib/hecks/hecks/deploy.world +1 -3
  182. data/lib/hecks/hecks/hecks.behaviors +1 -1
  183. data/lib/hecks/hecks/hecks.hecksagon +61 -18
  184. data/lib/hecks/hecks/hecks.world +11 -10
  185. data/lib/hecks/hecks/rehearsal.bluebook +477 -0
  186. data/lib/hecks/hecks/site.behaviors +1 -1
  187. data/lib/hecks/hecks/site.world +0 -1
  188. data/lib/hecks/hecks/tooling.bluebook +1238 -0
  189. data/lib/hecks/indifferent_key.rb +20 -0
  190. data/lib/hecks/language/adapter.bluebook +13 -3
  191. data/lib/hecks/language/bluebook/aggregate.bluebook +23 -0
  192. data/lib/hecks/language/bluebook/bluebook.bluebook +15 -0
  193. data/lib/hecks/language/bluebook/entity.bluebook +18 -0
  194. data/lib/hecks/language/bluebook/policy.bluebook +9 -1
  195. data/lib/hecks/language/bluebook/query.bluebook +1 -1
  196. data/lib/hecks/language/bluebook/vocabulary.bluebook +11 -4
  197. data/lib/hecks/language/hecksagon/domain_port.bluebook +1 -1
  198. data/lib/hecks/language/hecksagon/hecksagon.bluebook +22 -0
  199. data/lib/hecks/language/oidc.json +10 -0
  200. data/lib/hecks/language/port.bluebook +4 -4
  201. data/lib/hecks/literal.rb +12 -3
  202. data/lib/hecks/ports/key_vault.rb +7 -2
  203. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +21 -10
  204. data/lib/hecks/ports/persistence/plugins/era/postgres_era/boot.rb +4 -2
  205. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +2 -1
  206. data/lib/hecks/ports/persistence/repository_factory.rb +6 -3
  207. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +29 -0
  208. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +1 -1
  209. data/lib/hecks/projections/deploy/fargate/settings/tuning.rb +15 -0
  210. data/lib/hecks/projections/deploy/fargate/settings.rb +3 -1
  211. data/lib/hecks/projections/deploy/fargate/stack/readers.rb +1 -0
  212. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  213. data/lib/hecks/projections/deploy/shared.rb +5 -2
  214. data/lib/hecks/projections/deploy/templates/fargate/Makefile.tmpl +7 -4
  215. data/lib/hecks/projections/model/deviations.rb +2 -2
  216. data/lib/hecks/projections/model.rb +2 -2
  217. data/lib/hecks/projections/oidc.rb +13 -1
  218. data/lib/hecks/projections/shape.rb +5 -2
  219. data/lib/hecks/projections/site/cms_editor/attributes.rb +95 -0
  220. data/lib/hecks/projections/site/cms_editor/blocks.rb +157 -0
  221. data/lib/hecks/projections/site/cms_editor/checks.rb +77 -0
  222. data/lib/hecks/projections/site/cms_editor/clearing.rb +70 -0
  223. data/lib/hecks/projections/site/cms_editor/color.rb +124 -0
  224. data/lib/hecks/projections/site/cms_editor/composition.rb +42 -0
  225. data/lib/hecks/projections/site/cms_editor/destructive.rb +61 -0
  226. data/lib/hecks/projections/site/cms_editor/drafts.rb +102 -0
  227. data/lib/hecks/projections/site/cms_editor/hints.rb +74 -0
  228. data/lib/hecks/projections/site/cms_editor/icons.rb +86 -0
  229. data/lib/hecks/projections/site/cms_editor/key_kinds.rb +31 -0
  230. data/lib/hecks/projections/site/cms_editor/limits.rb +72 -0
  231. data/lib/hecks/projections/site/cms_editor/media.rb +85 -0
  232. data/lib/hecks/projections/site/cms_editor/moment.rb +34 -0
  233. data/lib/hecks/projections/site/cms_editor/palette.rb +115 -0
  234. data/lib/hecks/projections/site/cms_editor/pickers.rb +85 -0
  235. data/lib/hecks/projections/site/cms_editor/pretty.rb +40 -0
  236. data/lib/hecks/projections/site/cms_editor/preview.rb +91 -0
  237. data/lib/hecks/projections/site/cms_editor/relations.rb +92 -0
  238. data/lib/hecks/projections/site/cms_editor/scale_checks.rb +66 -0
  239. data/lib/hecks/projections/site/cms_editor/scheduling.rb +150 -0
  240. data/lib/hecks/projections/site/cms_editor/schema.rb +161 -0
  241. data/lib/hecks/projections/site/cms_editor/setting.rb +130 -0
  242. data/lib/hecks/projections/site/cms_editor/targets.rb +86 -0
  243. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +36 -0
  244. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +370 -0
  245. data/lib/hecks/projections/site/cms_editor/templates/src/assets.ts.tmpl +98 -0
  246. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +87 -0
  247. data/lib/hecks/projections/site/cms_editor/templates/src/auth/session.ts.tmpl +36 -0
  248. data/lib/hecks/projections/site/cms_editor/templates/src/auth/sso.ts.tmpl +20 -0
  249. data/lib/hecks/projections/site/cms_editor/templates/src/browser/app.css.tmpl +412 -0
  250. data/lib/hecks/projections/site/cms_editor/templates/src/browser/autosave.js.tmpl +144 -0
  251. data/lib/hecks/projections/site/cms_editor/templates/src/browser/blocks.js.tmpl +500 -0
  252. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_doc.js.tmpl +175 -0
  253. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_editor.js.tmpl +418 -0
  254. data/lib/hecks/projections/site/cms_editor/templates/src/browser/epoch.js.tmpl +61 -0
  255. data/lib/hecks/projections/site/cms_editor/templates/src/browser/forms.js.tmpl +210 -0
  256. data/lib/hecks/projections/site/cms_editor/templates/src/browser/lists.js.tmpl +40 -0
  257. data/lib/hecks/projections/site/cms_editor/templates/src/browser/main.js.tmpl +26 -0
  258. data/lib/hecks/projections/site/cms_editor/templates/src/browser/media_picker.js.tmpl +232 -0
  259. data/lib/hecks/projections/site/cms_editor/templates/src/browser/moments.js.tmpl +73 -0
  260. data/lib/hecks/projections/site/cms_editor/templates/src/browser/pickers.js.tmpl +152 -0
  261. data/lib/hecks/projections/site/cms_editor/templates/src/browser/preview.js.tmpl +98 -0
  262. data/lib/hecks/projections/site/cms_editor/templates/src/browser/shell.js.tmpl +140 -0
  263. data/lib/hecks/projections/site/cms_editor/templates/src/browser/toast.js.tmpl +66 -0
  264. data/lib/hecks/projections/site/cms_editor/templates/src/choices.ts.tmpl +99 -0
  265. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +133 -0
  266. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +29 -0
  267. data/lib/hecks/projections/site/cms_editor/templates/src/flash.ts.tmpl +52 -0
  268. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +43 -0
  269. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +177 -0
  270. data/lib/hecks/projections/site/cms_editor/templates/src/media/multipart.ts.tmpl +53 -0
  271. data/lib/hecks/projections/site/cms_editor/templates/src/media/sniff.ts.tmpl +94 -0
  272. data/lib/hecks/projections/site/cms_editor/templates/src/media/storage.ts.tmpl +50 -0
  273. data/lib/hecks/projections/site/cms_editor/templates/src/names.ts.tmpl +36 -0
  274. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +214 -0
  275. data/lib/hecks/projections/site/cms_editor/templates/src/server.ts.tmpl +65 -0
  276. data/lib/hecks/projections/site/cms_editor/templates/src/ui/availability.ts.tmpl +15 -0
  277. data/lib/hecks/projections/site/cms_editor/templates/src/ui/block_list.js.tmpl +213 -0
  278. data/lib/hecks/projections/site/cms_editor/templates/src/ui/blocks.ts.tmpl +275 -0
  279. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +123 -0
  280. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_parse.js.tmpl +320 -0
  281. data/lib/hecks/projections/site/cms_editor/templates/src/ui/detail.ts.tmpl +88 -0
  282. data/lib/hecks/projections/site/cms_editor/templates/src/ui/dialogs.ts.tmpl +43 -0
  283. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +275 -0
  284. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +61 -0
  285. data/lib/hecks/projections/site/cms_editor/templates/src/ui/icons.ts.tmpl +15 -0
  286. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +92 -0
  287. data/lib/hecks/projections/site/cms_editor/templates/src/ui/links.ts.tmpl +100 -0
  288. data/lib/hecks/projections/site/cms_editor/templates/src/ui/moments.ts.tmpl +44 -0
  289. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +63 -0
  290. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +213 -0
  291. data/lib/hecks/projections/site/cms_editor/templates/src/ui/paging.ts.tmpl +82 -0
  292. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pickers.ts.tmpl +110 -0
  293. data/lib/hecks/projections/site/cms_editor/templates/src/ui/preview.ts.tmpl +87 -0
  294. data/lib/hecks/projections/site/cms_editor/templates/src/ui/related.ts.tmpl +97 -0
  295. data/lib/hecks/projections/site/cms_editor/templates/src/ui/schedule.ts.tmpl +160 -0
  296. data/lib/hecks/projections/site/cms_editor/templates/src/ui/shell.ts.tmpl +211 -0
  297. data/lib/hecks/projections/site/cms_editor/templates/src/ui/table.ts.tmpl +151 -0
  298. data/lib/hecks/projections/site/cms_editor/templates/src/ui/theme.js.tmpl +16 -0
  299. data/lib/hecks/projections/site/cms_editor/templates/src/ui/values.ts.tmpl +69 -0
  300. data/lib/hecks/projections/site/cms_editor/templates/src/ui/words.ts.tmpl +97 -0
  301. data/lib/hecks/projections/site/cms_editor/templates/src/ui/world.ts.tmpl +35 -0
  302. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +17 -0
  303. data/lib/hecks/projections/site/cms_editor/theme.rb +29 -0
  304. data/lib/hecks/projections/site/cms_editor/theme_checks.rb +57 -0
  305. data/lib/hecks/projections/site/cms_editor.rb +133 -0
  306. data/lib/hecks/projections/site/role_probe.rb +74 -0
  307. data/lib/hecks/projector/cli_audience.rb +2 -1
  308. data/lib/hecks/projector/cli_projector/listing.rb +1 -1
  309. data/lib/hecks/projector/cli_projector/option_specs.rb +1 -1
  310. data/lib/hecks/projector/docs_projector/closing.rb +1 -1
  311. data/lib/hecks/projector/docs_projector.rb +1 -1
  312. data/lib/hecks/projector/exporter/bindings.rb +40 -10
  313. data/lib/hecks/projector/exporter/capability_values.rb +55 -0
  314. data/lib/hecks/projector/narrate_projector/reactions.rb +1 -1
  315. data/lib/hecks/projector/target.rb +11 -0
  316. data/lib/hecks/projector.rb +32 -5
  317. data/lib/hecks/quality_control/adapters/github_checks.rb +12 -8
  318. data/lib/hecks/quality_control/adapters/github_issues.rb +8 -4
  319. data/lib/hecks/quality_control/cli/qa_open_pr.rb +2 -2
  320. data/lib/hecks/quality_control/cli/qa_sweep/checks/shrinking.rb +1 -1
  321. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +1 -1
  322. data/lib/hecks/quality_control/cli/qa_sweep.rb +1 -1
  323. data/lib/hecks/quality_control/quality_control.bluebook +6 -2
  324. data/lib/hecks/release/approval.rb +64 -0
  325. data/lib/hecks/router/{door.rb → aggregate_reader.rb} +5 -4
  326. data/lib/hecks/router/namespace_installer.rb +16 -16
  327. data/lib/hecks/router.rb +1 -1
  328. data/lib/hecks/runtime/best_effort.rb +22 -0
  329. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -2
  330. data/lib/hecks/runtime/command_interpreter.rb +3 -2
  331. data/lib/hecks/runtime/command_rules/emission.rb +1 -1
  332. data/lib/hecks/runtime/dispatcher.rb +4 -4
  333. data/lib/hecks/runtime/entity_element/location.rb +1 -1
  334. data/lib/hecks/runtime/event.rb +5 -0
  335. data/lib/hecks/runtime/identity.rb +7 -6
  336. data/lib/hecks/runtime/interpreting.rb +2 -1
  337. data/lib/hecks/runtime/invocation_builder.rb +1 -1
  338. data/lib/hecks/runtime/loader/boot_steps.rb +8 -5
  339. data/lib/hecks/runtime/loader.rb +10 -10
  340. data/lib/hecks/runtime/policy_interpreter/arguments.rb +7 -4
  341. data/lib/hecks/runtime/policy_interpreter/fan_out.rb +3 -3
  342. data/lib/hecks/runtime/policy_interpreter.rb +6 -6
  343. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -1
  344. data/lib/hecks/runtime/reaction_invocation.rb +1 -1
  345. data/lib/hecks/runtime/rebuild_sweep.rb +11 -1
  346. data/lib/hecks/runtime/registry/ask_verification.rb +35 -0
  347. data/lib/hecks/runtime/registry/hecksagon_rules.rb +10 -0
  348. data/lib/hecks/runtime/registry/merging.rb +1 -1
  349. data/lib/hecks/runtime/registry/providers.rb +16 -0
  350. data/lib/hecks/runtime/registry/read_repository.rb +6 -4
  351. data/lib/hecks/runtime/registry/verification.rb +4 -1
  352. data/lib/hecks/runtime/registry/world_defaults.rb +29 -0
  353. data/lib/hecks/runtime/registry.rb +1 -1
  354. data/lib/hecks/runtime/saga_interpreter/compensation.rb +1 -1
  355. data/lib/hecks/runtime/saga_interpreter/delivery.rb +4 -4
  356. data/lib/hecks/runtime/saga_interpreter/transitions.rb +1 -1
  357. data/lib/hecks/runtime/saga_interpreter.rb +3 -3
  358. data/lib/hecks/runtime/tenant_check.rb +5 -4
  359. data/lib/hecks/runtime/value/coercion.rb +2 -2
  360. data/lib/hecks/runtime/value/validation.rb +16 -4
  361. data/lib/hecks/runtime.rb +9 -9
  362. data/lib/hecks/rust_build/project_rust/target_ir.rb +1 -1
  363. data/lib/hecks/rust_build/wasm.rb +1 -1
  364. data/lib/hecks/rust_build/wasm_browser.rb +1 -1
  365. data/lib/hecks/rust_build.rb +11 -0
  366. data/lib/hecks/served_options.rb +53 -0
  367. data/lib/hecks/site/bluebook/site.bluebook +29 -1
  368. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  369. data/lib/hecks/storehouse/audit.rb +1 -1
  370. data/lib/hecks/storehouse/dispatching.rb +8 -6
  371. data/lib/hecks/storehouse/introspection.rb +2 -2
  372. data/lib/hecks/storehouse/querying.rb +6 -4
  373. data/lib/hecks/storehouse.rb +4 -4
  374. data/lib/hecks/tools/comment_style/source_file.rb +4 -1
  375. data/lib/hecks/tools/deploy_recipe_lint/fixtures.rb +4 -1
  376. data/lib/hecks/tools/lanes/rows.rb +6 -0
  377. data/lib/hecks/tools/oidc_manifests.rb +4 -5
  378. data/lib/hecks/tools/regeneration_run.rb +11 -4
  379. data/lib/hecks/tools/site_routes/cli.rb +1 -1
  380. data/lib/hecks/tools/site_routes/editor_files.rb +45 -0
  381. data/lib/hecks/tools/site_routes.rb +14 -7
  382. data/lib/hecks/tools/tenant_provisioning.rb +2 -2
  383. data/lib/hecks/tools/tools_doc.rb +4 -4
  384. data/lib/hecks/version.rb +1 -1
  385. data/lib/hecks/vocabulary.rb +4 -4
  386. data/lib/hecks.rb +15 -15
  387. data/lib/rubocop/cop/hecks/env_dump.rb +69 -0
  388. data/lib/rubocop/cop/hecks/monotonic_duration.rb +53 -0
  389. data/lib/rubocop/cop/hecks/no_wall_clock_in_runtime.rb +34 -0
  390. data/lib/rubocop/cop/hecks/raw_shell_interpolation.rb +79 -0
  391. data/lib/rubocop/cop/hecks/sleep_in_spec.rb +34 -0
  392. data/lib/rubocop/cop/hecks/swallowed_rescue.rb +37 -0
  393. data/lib/rubocop/cop/hecks/symbol_string_key_mix.rb +50 -0
  394. data/lib/rubocop/cop/hecks/untyped_raise.rb +37 -0
  395. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  396. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
  397. data/prebuilt/verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json +1 -0
  398. data/rust/.gitignore +6 -0
  399. data/rust/Cargo.toml +9 -1
  400. data/rust/build/src/binding_pass.rs +103 -0
  401. data/rust/build/src/cargo_sync.rs +6 -0
  402. data/rust/build/src/lineage_pass.rs +3 -3
  403. data/rust/build/src/main.rs +17 -5
  404. data/rust/build/src/pipeline.rs +48 -5
  405. data/rust/build/src/seams_pass.rs +123 -0
  406. data/rust/build/src/translation_pass.rs +167 -0
  407. data/rust/codegen/src/asks.rs +103 -0
  408. data/rust/codegen/src/bridging.rs +1 -1
  409. data/rust/codegen/src/commands.rs +13 -8
  410. data/rust/codegen/src/constraints.rs +25 -0
  411. data/rust/codegen/src/domain_generator.rs +11 -9
  412. data/rust/codegen/src/expr_emitter.rs +13 -0
  413. data/rust/codegen/src/json_codec.rs +54 -15
  414. data/rust/codegen/src/main.rs +2 -1
  415. data/rust/codegen/src/mutations.rs +174 -4
  416. data/rust/codegen/src/naming.rs +30 -0
  417. data/rust/codegen/src/queries.rs +11 -2
  418. data/rust/codegen/src/reactions.rs +1 -1
  419. data/rust/codegen/src/types.rs +166 -8
  420. data/rust/host/HECKS_RELEASE +1 -1
  421. data/rust/host/src/api.rs +4 -4
  422. data/rust/host/src/bin/validate_harness.rs +43 -0
  423. data/rust/host/src/boundary_fuzz/expr_json.rs +2 -2
  424. data/rust/host/src/boundary_fuzz/ir.rs +1 -0
  425. data/rust/host/src/boundary_fuzz/server.rs +1 -1
  426. data/rust/host/src/checkout.rs +35 -19
  427. data/rust/host/src/commerce_ir.rs +408 -2
  428. data/rust/host/src/expr_json.rs +118 -0
  429. data/rust/host/src/mint.rs +1 -1
  430. data/rust/host/src/payments/tests.rs +27 -1
  431. data/rust/host/src/payments.rs +12 -4
  432. data/rust/host/src/query_step.rs +22 -4
  433. data/rust/host/src/reference_validate.rs +653 -108
  434. data/rust/host/src/resend.rs +2 -0
  435. data/rust/host/src/server.rs +19 -6
  436. data/rust/host/src/ui_schema.rs +132 -5
  437. data/rust/host/src/web/newsletter.rs +7 -8
  438. data/rust/host/src/web/newsletter_send.rs +38 -13
  439. data/rust/host/src/web/registrations.rs +229 -26
  440. data/rust/host/src/web.rs +3 -3
  441. data/rust/parser/src/emit.rs +38 -6
  442. data/rust/parser/src/expr/ast_json.rs +7 -1
  443. data/rust/parser/src/expr/resolver.rs +171 -6
  444. data/rust/parser/src/ir.rs +7 -0
  445. data/rust/parser/src/keywords.rs +24 -0
  446. data/rust/parser/src/main.rs +3 -0
  447. data/rust/parser/src/parse/aggregate.rs +2 -1
  448. data/rust/parser/src/parse/chapter.rs +2 -2
  449. data/rust/parser/src/parse/command.rs +2 -0
  450. data/rust/parser/src/parse/hecksagon.rs +5 -2
  451. data/rust/parser/src/parse/lifecycle.rs +81 -0
  452. data/rust/parser/src/parse/mod.rs +15 -1
  453. data/rust/parser/src/parse/policy.rs +13 -2
  454. data/rust/parser/src/parse/value_object.rs +13 -8
  455. data/rust/parser/src/ruby_value.rs +1 -1
  456. data/rust/src/exemplar/commands.rs +4 -4
  457. data/rust/src/exemplar/constraints.rs +1 -1
  458. data/rust/src/exemplar/json.rs +43 -1
  459. data/rust/src/kernel/cli.rs +7 -5
  460. data/rust/src/kernel/dispatch.rs +2 -2
  461. data/rust/src/kernel/dispatch_guard_tests.rs +1 -1
  462. data/rust/src/kernel/expr.rs +73 -4
  463. data/rust/src/kernel/expression_operators/arithmetic.rs +130 -5
  464. data/rust/src/kernel/expression_operators/presence.rs +41 -3
  465. data/rust/src/kernel/expression_operators/text.rs +26 -2
  466. data/rust/src/kernel/json.rs +15 -0
  467. data/rust/src/kernel/mod.rs +1 -1
  468. data/rust/src/kernel/orchestrate.rs +54 -5
  469. data/rust/src/kernel/orchestrate_guard_tests.rs +67 -0
  470. data/rust/src/kernel/pattern.rs +60 -0
  471. data/rust/src/kernel/reference_lookup.rs +90 -7
  472. metadata +174 -36
  473. data/lib/hecks/doors/cli_door/nesting.rb +0 -34
  474. data/lib/hecks/doors/cli_door/shorthand.rb +0 -85
  475. data/lib/hecks/doors/cli_door.rb +0 -124
  476. data/lib/hecks/doors/cli_runner/answers.rb +0 -138
  477. data/lib/hecks/doors/cli_runner/resolution.rb +0 -146
  478. data/lib/hecks/doors/cli_runner/settling.rb +0 -115
  479. data/lib/hecks/doors/cli_runner/streaming.rb +0 -90
  480. data/lib/hecks/doors/cli_runner/suggestions.rb +0 -43
  481. data/lib/hecks/doors/cli_runner.rb +0 -66
  482. data/lib/hecks/doors/command_request.rb +0 -119
  483. data/lib/hecks/doors/handle.rb +0 -208
  484. data/lib/hecks/doors/json_door.rb +0 -132
  485. data/lib/hecks/doors/launcher_options.rb +0 -247
  486. data/lib/hecks/doors/mcp_door/properties.rb +0 -47
  487. data/lib/hecks/doors/mcp_door/tool_calls.rb +0 -113
  488. data/lib/hecks/doors/mcp_door/tool_definitions/auditing.rb +0 -51
  489. data/lib/hecks/doors/mcp_door/tool_definitions/calling.rb +0 -69
  490. data/lib/hecks/doors/mcp_door/tool_definitions/reading.rb +0 -97
  491. data/lib/hecks/doors/mcp_door/tool_definitions.rb +0 -15
  492. data/lib/hecks/doors/mcp_door.rb +0 -156
  493. data/lib/hecks/doors/mcp_door_scope/argument_policy.rb +0 -92
  494. data/lib/hecks/doors/mcp_door_scope/env_problems.rb +0 -55
  495. data/lib/hecks/doors/mcp_door_scope/presenter.rb +0 -78
  496. data/lib/hecks/doors/mcp_door_scope.rb +0 -201
  497. data/lib/hecks/doors/mcp_guide_cache.rb +0 -75
  498. data/lib/hecks/doors/ruby_door/aggregate_door.rb +0 -162
  499. data/lib/hecks/doors/ruby_door/chapter.rb +0 -89
  500. data/lib/hecks/doors/ruby_door.rb +0 -80
  501. data/lib/hecks/doors/usage_cache.rb +0 -139
  502. data/prebuilt/0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal +0 -0
  503. data/prebuilt/6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal +0 -0
  504. data/prebuilt/verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json +0 -1
@@ -0,0 +1,275 @@
1
+ __BANNER__
2
+
3
+ // The inputs of a form, derived from attributes. A value object is a group of its parts (one with a
4
+ // single part is just that input, named for the attribute); a `list_of` attribute is a run of
5
+ // repeatable rows (the rows it holds, then one blank row to fill, which the page's script lets the
6
+ // person add to, remove and reorder); a lifecycle state is not an input at all. Each input is named
7
+ // by its dotted path (`byline.name`, `body.blocks.0.text`), which input.ts reads back into the shape
8
+ // the command takes. Inputs are plain `input` elements, so a form posts with no script at all. A
9
+ // text a rule bounds above 200 characters is a writing box, a value object with a media key and a
10
+ // description is a picture (its picker opens from the group), and a list whose members hold a body,
11
+ // a picture or a list of their own is drawn as cards (see blocks.ts) so a new member is made from a
12
+ // template, not copied from the page.
13
+ import type { Aggregate, Attr } from "../schema.ts";
14
+ import { SCHEMA } from "../schema.ts";
15
+ import { esc, href } from "./html.ts";
16
+ import { icon } from "./icons.ts";
17
+ import { bodyToFields, bodyToHtml, emptyBody, fieldsToInputs } from "./body_model.js";
18
+ import { heavy } from "./block_list.js";
19
+ import { blockList } from "./blocks.ts";
20
+ import type { Problem } from "./blocks.ts";
21
+ import { isMoment, secondsOf } from "./moments.ts";
22
+ import { datalists, pickerControl } from "./pickers.ts";
23
+ import type { Choices } from "./pickers.ts";
24
+ import { label, limitsNote, singular } from "./words.ts";
25
+
26
+ type Value = unknown;
27
+
28
+ /** The field a refusal named and what it said, so the form can mark it and the script can focus it. */
29
+ export interface Invalid {
30
+ field: string;
31
+ message: string;
32
+ }
33
+
34
+ /** What a run of inputs needs to know beyond the attribute: whether to mark required, which field is invalid. */
35
+ interface Context {
36
+ mark: boolean;
37
+ invalid?: Invalid | null;
38
+ top: boolean;
39
+ /** The row's own heading already names the input, so its label is for assistive technology only. */
40
+ quiet?: boolean;
41
+ /** What is on offer for each picker (see pickers.ts); a picker with nothing on offer is a text box. */
42
+ choices?: Choices;
43
+ /** The body attribute that is written as a draft: its editor saves by itself (see browser/autosave.js). */
44
+ draft?: string;
45
+ /** The blocks of a list of mixed blocks that a refusal named (see blocks.ts). */
46
+ problems?: Problem[];
47
+ /** A list is drawn as plain rows, whatever its members hold: it is inside a card's group, one level in. */
48
+ rows?: boolean;
49
+ }
50
+
51
+ const inputType = (attr: Attr): string => (attr.kind === "integer" || attr.kind === "number" ? "number" : "text");
52
+
53
+ const member = (value: Value, key: string): Value => (value && typeof value === "object" ? (value as Record<string, Value>)[key] : undefined);
54
+
55
+ const idOf = (name: string): string => `f-${name.replace(/[^A-Za-z0-9]+/g, "-")}`;
56
+
57
+ function caption(attr: Attr, context: Context): string {
58
+ const text = esc(label(attr.name));
59
+ if (attr.optional) return `${text} <span class="ml-1 text-xs font-normal text-muted">optional</span>`;
60
+ return context.mark ? `${text} <span class="text-primary" title="Required" aria-hidden="true">*</span>` : text;
61
+ }
62
+
63
+ /**
64
+ * The words of the refusal that is about the field at `name`, or null: the one the command named for this
65
+ * attribute at the top of the form, or one that names this part of the value object it was about.
66
+ */
67
+ function refusalAt(attr: Attr, name: string, context: Context): string | null {
68
+ if (context.top && context.invalid?.field === attr.name) return context.invalid.message;
69
+ const named = context.problems?.find((one) => one.slots.some((part) => name === `${one.name}.${part}` || name.startsWith(`${one.name}.${part}.`)));
70
+ return named ? named.message : null;
71
+ }
72
+
73
+ const flagged = (attr: Attr, name: string, context: Context): boolean => refusalAt(attr, name, context) !== null;
74
+
75
+ /** The attributes of the control that is invalid: the state and the link to its message (and to its hint, when it has one). */
76
+ function invalidAttrs(name: string, attr: Attr, context: Context, hint = false): string {
77
+ const ids = [...(hint ? [`${idOf(name)}-hint`] : []), ...(flagged(attr, name, context) ? [`${idOf(name)}-error`] : [])];
78
+ const described = ids.length > 0 ? ` aria-describedby="${esc(ids.join(" "))}"` : "";
79
+ return `${flagged(attr, name, context) ? ` aria-invalid="true"` : ""}${described}`;
80
+ }
81
+
82
+ function problem(name: string, attr: Attr, context: Context): string {
83
+ const text = refusalAt(attr, name, context);
84
+ return text !== null ? `<p class="text-xs font-semibold text-error" id="${esc(idOf(name))}-error">${esc(text)}</p>` : "";
85
+ }
86
+
87
+ /** A moment's input: the seconds in a number input; the script swaps it for a native date input in the viewer's zone. */
88
+ function moment(attr: Attr, name: string, value: Value, context: Context): string {
89
+ const id = esc(idOf(name));
90
+ const seconds = secondsOf(value);
91
+ const classes = flagged(attr, name, context) ? "input input-error w-full max-w-xl" : "input w-full max-w-xl";
92
+ const required = context.mark && !attr.optional ? ` aria-required="true"` : "";
93
+ const input = `<input type="number" step="1" class="${classes}" id="${id}" name="${esc(name)}" value="${seconds ?? ""}" data-epoch-raw${required}${invalidAttrs(name, attr, context)}>`;
94
+ const note = `<p class="text-xs text-muted" data-epoch-note>Whole seconds since 1970, in UTC.</p>`;
95
+ return `<div class="my-4 grid gap-1" data-moment-field="${esc(attr.widget ?? "datetime")}"><label for="${id}" class="${context.quiet ? "sr-only" : "font-semibold"}">${caption(attr, context)}</label>${input}${note}${problem(name, attr, context)}</div>`;
96
+ }
97
+
98
+ /** The control a picker stands in for the text box, or null when the attribute has none. */
99
+ function picked(attr: Attr, name: string, value: Value, context: Context): string | null {
100
+ if (!attr.picker) return null;
101
+ const held = member(value, "value") ?? value ?? "";
102
+ const required = context.mark && !attr.optional ? ` aria-required="true"` : "";
103
+ const control = { id: idOf(name), name, value: String(held), optional: attr.optional, invalid: flagged(attr, name, context), extra: `${required}${invalidAttrs(name, attr, context)}` };
104
+ return pickerControl(attr.picker, context.choices ?? {}, control);
105
+ }
106
+
107
+ /** A closed set's members as a native select, with the held value chosen; an optional one may be left unchosen. */
108
+ function select(attr: Attr, name: string, value: Value, context: Context): string {
109
+ const held = String(member(value, "value") ?? value ?? "");
110
+ const id = esc(idOf(name));
111
+ const required = context.mark && !attr.optional ? ` aria-required="true"` : "";
112
+ const classes = flagged(attr, name, context) ? "select select-error w-full max-w-xl" : "select w-full max-w-xl";
113
+ const blank = attr.optional || held === "" ? `<option value=""${held === "" ? " selected" : ""}>${attr.optional ? "None" : "Choose"}</option>` : "";
114
+ const options = (attr.options ?? []).map((option) => `<option value="${esc(option)}"${option === held ? " selected" : ""}>${esc(label(option))}</option>`);
115
+ const input = `<select class="${classes}" id="${id}" name="${esc(name)}"${required}${invalidAttrs(name, attr, context)}>${blank}${options.join("")}</select>`;
116
+ return `<div class="my-4 grid gap-1"><label for="${id}" class="${context.quiet ? "sr-only" : "font-semibold"}">${caption(attr, context)}</label>${input}${problem(name, attr, context)}</div>`;
117
+ }
118
+
119
+ function scalar(attr: Attr, name: string, value: Value, context: Context): string {
120
+ if (isMoment(attr)) return moment(attr, name, value, context);
121
+ if (attr.options && !attr.list) return select(attr, name, value, context);
122
+ const text = esc(member(value, "value") ?? value ?? "");
123
+ const id = esc(idOf(name));
124
+ if (attr.kind === "boolean") {
125
+ return `<div class="my-4 flex items-center gap-2"><input type="checkbox" class="checkbox checkbox-sm" id="${id}" name="${esc(name)}"${value === true ? " checked" : ""}><label for="${id}" class="font-semibold">${caption(attr, context)}</label></div>`;
126
+ }
127
+ const step = attr.kind === "number" ? ` step="any"` : "";
128
+ const required = context.mark && !attr.optional ? ` aria-required="true"` : "";
129
+ const bad = flagged(attr, name, context);
130
+ const limit = attr.maxLength ? ` maxlength="${attr.maxLength}"` : "";
131
+ if (attr.multiline && !attr.list && attr.kind === "text") {
132
+ const classes = bad ? "textarea textarea-error w-full max-w-3xl" : "textarea w-full max-w-3xl";
133
+ const hint = `<p class="text-xs text-muted" id="${id}-hint">Up to ${attr.maxLength} characters.</p>`;
134
+ const box = `<textarea class="${classes}" rows="5" id="${id}" name="${esc(name)}"${limit}${required}${invalidAttrs(name, attr, context, true)}>\n${text}</textarea>`;
135
+ return `<div class="my-4 grid gap-1"><label for="${id}" class="${context.quiet ? "sr-only" : "font-semibold"}">${caption(attr, context)}</label>${box}${hint}${problem(name, attr, context)}</div>`;
136
+ }
137
+ const classes = bad ? "input input-error w-full max-w-xl" : "input w-full max-w-xl";
138
+ const chosen = picked(attr, name, value, context);
139
+ const input = chosen ?? `<input type="${inputType(attr)}"${step} class="${classes}" id="${id}" name="${esc(name)}" value="${text}"${limit}${required}${invalidAttrs(name, attr, context)}>`;
140
+ return `<div class="my-4 grid gap-1"><label for="${id}" class="${context.quiet ? "sr-only" : "font-semibold"}">${caption(attr, context)}</label>${input}${problem(name, attr, context)}</div>`;
141
+ }
142
+
143
+ /** A value object's one plain part as the attribute that holds it: it takes the attribute's name, picker and moment. */
144
+ function plainOf(attr: Attr, part: Attr, name: string): Attr {
145
+ return { ...part, name, optional: attr.optional, widget: attr.widget ?? part.widget, picker: attr.picker ?? part.picker, options: attr.options ?? part.options };
146
+ }
147
+
148
+ function parts(agg: Aggregate, attr: Attr, name: string, value: Value, context: Context): string {
149
+ const members = agg.valueObjects[attr.type] ?? [];
150
+ const inside = { ...context, top: false, mark: context.mark && !attr.optional };
151
+ return members.map((part) => field(agg, part, member(value, part.name), `${name}.`, inside)).join("\n");
152
+ }
153
+
154
+ /** The words of the refusal about the value object at `name` as a whole, or about one of its parts by name. */
155
+ function groupRefusal(attr: Attr, name: string, context: Context): string | null {
156
+ return refusalAt(attr, name, context) ?? context.problems?.find((one) => one.name === name)?.message ?? null;
157
+ }
158
+
159
+ /** The control a picture's media picker is opened from, or nothing when the editor has no pictures. */
160
+ function pickButton(): string {
161
+ return SCHEMA.media ? `<button type="button" class="btn btn-sm w-fit" data-action="pick-picture" data-media="${esc(href("media"))}">${icon("image")}<span>Choose a picture</span></button>` : "";
162
+ }
163
+
164
+ /**
165
+ * A value object that is a picture: its file, its description (required wherever a picture is) and its
166
+ * caption as one group with a button that opens the picker, in whatever depth it sits; any other part
167
+ * it has is drawn after it as it is anywhere else.
168
+ */
169
+ function pictureField(agg: Aggregate, attr: Attr, name: string, value: Value, context: Context): string {
170
+ const picture = attr.picture as NonNullable<Attr["picture"]>;
171
+ const members = agg.valueObjects[attr.type] ?? [];
172
+ const inside: Context = { ...context, top: false, mark: context.mark && !attr.optional };
173
+ const named = [picture.key, picture.alt, picture.caption].filter((part): part is string => part !== undefined);
174
+ const control = (part?: string): string => {
175
+ const found = members.find((one) => one.name === part);
176
+ return found ? field(agg, found, member(value, found.name), `${name}.`, inside) : "";
177
+ };
178
+ const others = members.filter((one) => !named.includes(one.name)).map((one) => field(agg, one, member(value, one.name), `${name}.`, inside));
179
+ const thumb = `<img class="size-20 shrink-0 border border-base-300 object-cover" alt="" width="80" height="80" data-picture-thumb hidden>`;
180
+ const said = groupRefusal(attr, name, context);
181
+ const bad = said !== null ? ` aria-invalid="true" aria-describedby="${esc(idOf(name))}-error"` : "";
182
+ const why = said !== null ? `<p class="text-xs font-semibold text-error" id="${esc(idOf(name))}-error">${esc(said)}</p>` : "";
183
+ return `<fieldset class="mt-6 [&>legend+*]:clear-both"${bad}><legend class="float-left mb-2 w-full border-t border-base-300 pt-4 text-lg font-semibold">${caption(attr, context)}</legend>
184
+ <div class="my-4 grid gap-1 border border-base-300 p-3" data-picture data-key="${esc(`${name}.${picture.key}`)}" data-alt="${esc(`${name}.${picture.alt}`)}">
185
+ <div class="flex flex-wrap items-start gap-3">${thumb}<div class="grid min-w-0 flex-1 gap-1 max-sm:basis-full">${control(picture.key)}${control(picture.alt)}${control(picture.caption)}${pickButton()}</div></div>
186
+ <p class="text-xs text-muted">A picture needs a description for people who cannot see it.</p>
187
+ </div>
188
+ ${others.join("\n")}${why}
189
+ </fieldset>`;
190
+ }
191
+
192
+ function one(agg: Aggregate, attr: Attr, name: string, value: Value, context: Context): string {
193
+ if (attr.kind !== "object") return scalar(attr, name, value, context);
194
+ const members = agg.valueObjects[attr.type] ?? [];
195
+ const hit = groupRefusal(attr, name, context);
196
+ if (members.length === 1 && members[0].kind !== "object" && !members[0].list) {
197
+ const own: Context = hit !== null ? { ...context, top: true, invalid: { field: attr.name, message: hit } } : context;
198
+ return scalar(plainOf(attr, members[0], attr.name), `${name}.${members[0].name}`, member(value, members[0].name), own);
199
+ }
200
+ if (attr.picture) return pictureField(agg, attr, name, value, context);
201
+ const invalid = hit !== null ? ` aria-invalid="true" aria-describedby="${esc(idOf(name))}-error"` : "";
202
+ const why = hit !== null ? `<p class="text-xs font-semibold text-error" id="${esc(idOf(name))}-error">${esc(hit)}</p>` : "";
203
+ return `<fieldset class="mt-6 [&>legend+*]:clear-both"${invalid}><legend class="float-left mb-2 w-full border-t border-base-300 pt-4 text-lg font-semibold">${caption(attr, context)}</legend>\n${parts(agg, attr, name, value, context)}\n${why}</fieldset>`;
204
+ }
205
+
206
+ function listed(agg: Aggregate, attr: Attr, name: string, value: Value, context: Context): string {
207
+ const held = Array.isArray(value) ? value : [];
208
+ const inside = { ...context, mark: false, top: false };
209
+ const members = agg.valueObjects[attr.type] ?? [];
210
+ const only = attr.kind === "object" && members.length === 1 && members[0].kind !== "object" && !members[0].list ? members[0] : null;
211
+ const word = attr.name.replace(/([^aeiou])ies$/, "$1y").replace(/s$/, "");
212
+ const rows = [...held, undefined].map((item, index) => {
213
+ const body = only
214
+ ? scalar({ ...plainOf(attr, only, word), optional: only.optional }, `${name}.${index}.${only.name}`, member(item, only.name), { ...inside, quiet: true })
215
+ : attr.kind === "object"
216
+ ? parts(agg, attr, `${name}.${index}`, item, inside)
217
+ : scalar({ ...attr, name: word, list: false, optional: false }, `${name}.${index}`, item, { ...inside, quiet: true });
218
+ return `<fieldset class="relative my-3 border-l-2 border-base-300 pl-4 [&>legend+*]:clear-both" data-row data-index="${index}"><legend class="float-left w-full text-xs font-semibold text-muted">${esc(label(word))} ${index + 1}</legend>\n${body}\n</fieldset>`;
219
+ });
220
+ const noun = esc(singular(word));
221
+ const invalid = flagged(attr, name, context) ? ` aria-invalid="true" aria-describedby="${esc(idOf(name))}-error"` : "";
222
+ const max = attr.max ? ` data-max="${attr.max}"` : "";
223
+ const limits = limitsNote(attr.min, attr.max);
224
+ const note = limits ? `\n<p class="clear-both mb-2 text-xs text-muted">${esc(limits)}</p>` : "";
225
+ return `<fieldset class="mt-6 [&>legend+*]:clear-both" data-repeat data-path="${esc(name)}" data-noun="${noun}"${max}${invalid}><legend class="float-left mb-2 w-full border-t border-base-300 pt-4 text-lg font-semibold">${caption(attr, context)}</legend>${note}\n<div data-rows>\n${rows.join("\n")}\n</div>${problem(name, attr, context)}\n</fieldset>`;
226
+ }
227
+
228
+ // The rich-text editor. The server renders the body as its read-only text and as the hidden inputs
229
+ // the form posts (the same dotted-path fields every value object and list uses), so a browser
230
+ // without the script still posts the body unchanged; the editor replaces the text with a writing
231
+ // surface and rewrites those inputs from the document as it is edited.
232
+ function body(attr: Attr, name: string, value: Value, context: Context): string {
233
+ const tree = value && typeof value === "object" ? value : emptyBody();
234
+ const inputs = fieldsToInputs(bodyToFields(tree, name));
235
+ const media = SCHEMA.media ? ` data-media="${esc(href("media"))}"` : "";
236
+ const draft = context.draft === attr.name ? " data-draft" : "";
237
+ const said = groupRefusal(attr, name, context);
238
+ const bad = said !== null ? ` aria-invalid="true" aria-describedby="${esc(idOf(name))}-error"` : "";
239
+ const why = said !== null ? `\n<p class="mt-2 text-sm font-semibold text-error" id="${esc(idOf(name))}-error">${esc(said)}</p>` : "";
240
+ return `<fieldset class="mt-6 [&>legend+*]:clear-both"${bad}><legend class="float-left mb-2 w-full border-t border-base-300 pt-4 text-lg font-semibold">${caption(attr, context)}</legend>
241
+ <div class="max-w-3xl" data-body-editor${draft} data-name="${esc(name)}" data-label="${esc(label(attr.name))}" data-body="${esc(JSON.stringify(tree))}"${media}>
242
+ <div class="prose border border-base-300 bg-base-100 p-6" data-body-fallback>${bodyToHtml(tree, 2)}</div>
243
+ <p class="mt-2 text-xs text-muted" data-needs-script>Editing this text needs scripts turned on in the browser.</p>
244
+ <div data-fields>
245
+ ${inputs}
246
+ </div>
247
+ </div>${why}
248
+ </fieldset>`;
249
+ }
250
+
251
+ /** A list of mixed blocks as cards; each slot is drawn by `field`, so a part is entered as it is anywhere else. */
252
+ function blocks(agg: Aggregate, attr: Attr, name: string, value: Value, context: Context): string {
253
+ const slot = (part: Attr, prefix: string, held: Value, message?: string, required?: boolean, rows?: boolean): string => {
254
+ const own: Context = { ...context, top: message !== undefined, invalid: message ? { field: part.name, message } : null, mark: required === true, quiet: false, draft: undefined, rows };
255
+ return field(agg, required ? { ...part, optional: false } : part, held, prefix, own);
256
+ };
257
+ const message = groupRefusal(attr, name, context) ?? undefined;
258
+ const rendering = { slot, draft: context.draft === attr.name, problems: context.problems ?? [], caption: caption(attr, context), media: SCHEMA.media ? href("media") : undefined, message };
259
+ return blockList(agg, attr, name, value, rendering);
260
+ }
261
+
262
+ /** One attribute's inputs, `prefix` being the dotted path of the object that holds it. */
263
+ export function field(agg: Aggregate, attr: Attr, value: Value, prefix = "", context: Context = { mark: true, top: true }): string {
264
+ const name = `${prefix}${attr.name}`;
265
+ if (attr.widget === "body") return body(attr, name, value, context);
266
+ if (attr.blockList && attr.list) return blocks(agg, attr, name, value, context);
267
+ if (attr.list && attr.kind === "object" && !context.rows && (attr.picture !== undefined || heavy(agg.valueObjects, attr.type))) return blocks(agg, attr, name, value, context);
268
+ return attr.list ? listed(agg, attr, name, value, context) : one(agg, attr, name, value, context);
269
+ }
270
+
271
+ /** The inputs for `attrs`, filled from `values` (an instance's state or a refused submission). */
272
+ export function fields(agg: Aggregate, attrs: Attr[], values: Record<string, Value> = {}, invalid: Invalid | null = null, mark = true, choices: Choices = {}, draft?: string, problems: Problem[] = []): string {
273
+ const inputs = attrs.map((attr) => field(agg, attr, values[attr.name], "", { mark, invalid, top: true, choices, draft, problems }));
274
+ return [...inputs, datalists(agg, attrs, choices)].filter((text) => text !== "").join("\n");
275
+ }
@@ -0,0 +1,61 @@
1
+ __BANNER__
2
+
3
+ // The small helpers every page shares. Pages are plain strings built on the server; every value that
4
+ // came from the domain or the person is escaped on its way in, so no text becomes markup.
5
+ //
6
+ // Every class name below is written out whole. The stylesheet is built by Tailwind, which finds
7
+ // classes by reading these files as text, so a class made by joining strings would not be found and
8
+ // would have no style.
9
+ import { EDITOR } from "../config.ts";
10
+ import type { Aggregate } from "../schema.ts";
11
+ import { icon } from "./icons.ts";
12
+ import { label } from "./words.ts";
13
+
14
+ /** Escapes text for an HTML body or a double-quoted attribute. */
15
+ export function esc(value: unknown): string {
16
+ return String(value ?? "").replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
17
+ }
18
+
19
+ /** A path under the editor's base path; each segment is URL-encoded. */
20
+ export function href(...segments: string[]): string {
21
+ return [EDITOR.basePath, ...segments.map(encodeURIComponent)].join("/");
22
+ }
23
+
24
+ /**
25
+ * The inline message a refused command shows above its form. The page's script moves focus to the
26
+ * first field marked invalid, or to this block when no field is named.
27
+ */
28
+ export function refusal(message: string | null): string {
29
+ return message ? `<div class="alert alert-error alert-soft mb-4 items-start" tabindex="-1" data-refusal>${icon("alert")}<p class="font-medium">${esc(message)}</p></div>` : "";
30
+ }
31
+
32
+ /** A badge's classes and mark by tone. Colour is never the only cue: each tone has its own shape and the state's words. */
33
+ const TONES: Record<string, [classes: string, mark: string]> = {
34
+ neutral: ["badge badge-soft gap-1 font-semibold", "dot"],
35
+ ok: ["badge badge-soft badge-success gap-1 font-semibold", "success"],
36
+ info: ["badge badge-soft badge-info gap-1 font-semibold", "info"],
37
+ warn: ["badge badge-soft badge-warning gap-1 font-semibold", "alert"],
38
+ danger: ["badge badge-soft badge-error gap-1 font-semibold", "alert"],
39
+ };
40
+
41
+ /** A status as a badge: its words, a shape and a colour. */
42
+ export function badge(status: unknown, tone = "neutral"): string {
43
+ if (!status) return "";
44
+ const [classes, mark] = TONES[tone] ?? TONES.neutral;
45
+ return `<span class="${classes}">${icon(mark)}${esc(label(String(status)))}</span>`;
46
+ }
47
+
48
+ /** The badge of an aggregate's lifecycle state, in the tone the schema gives that state. */
49
+ export function statusBadge(agg: Aggregate, status: unknown): string {
50
+ return badge(status, agg.lifecycle?.tones[String(status)] ?? "neutral");
51
+ }
52
+
53
+ /** A button that copies `value` to the clipboard (the page's script shows it and wires it; without a script it stays hidden). */
54
+ export function copyButton(value: string, what: string): string {
55
+ return `<button type="button" class="btn btn-ghost btn-square btn-xs text-muted" data-copy="${esc(value)}" title="Copy ${esc(what)}" aria-label="Copy ${esc(what)}" hidden>${icon("copy")}</button>`;
56
+ }
57
+
58
+ /** A value with a button that copies it. */
59
+ export function copyable(value: string, what: string): string {
60
+ return `<span class="inline-flex items-center gap-1"><span>${esc(value)}</span>${copyButton(value, what)}</span>`;
61
+ }
@@ -0,0 +1,15 @@
1
+ __BANNER__
2
+
3
+ // The editor's icons: one SVG sprite written into every page, and `icon(name)` to draw one. The
4
+ // symbols are the project's single source of icons (the generator's own list), so the browser code
5
+ // draws the same ones with `<use href="#i-name">` and nothing is fetched.
6
+
7
+ /** The sprite, placed once at the top of every page. It takes no room and is hidden from readers. */
8
+ export const SPRITE = `<svg class="absolute size-0 overflow-hidden" xmlns="http://www.w3.org/2000/svg" width="0" height="0" focusable="false" aria-hidden="true">
9
+ __ICONS__
10
+ </svg>`;
11
+
12
+ /** An icon that decorates a label next to it. */
13
+ export function icon(name: string): string {
14
+ return `<svg class="icon" aria-hidden="true" focusable="false"><use href="#i-${name}"/></svg>`;
15
+ }
@@ -0,0 +1,92 @@
1
+ __BANNER__
2
+
3
+ // A submitted form turned back into what a command takes. The form's inputs are named by dotted
4
+ // path (see fields.ts); this reads them against the same attributes, so a nested value object comes
5
+ // back nested and a run of repeatable rows comes back as a list. A row left entirely blank is not a
6
+ // row, and an optional part left blank is left out, as is an optional group with nothing in it; a
7
+ // required text part left blank in a group that has something in it is sent as empty text so that
8
+ // the domain's own rule refuses it in its own words. A rich-text body is read the
9
+ // same way: the widget posts its tree as the same dotted-path fields (`body.blocks.0.spans.1.text`),
10
+ // so there is no second encoding.
11
+ import type { Aggregate, Attr } from "../schema.ts";
12
+
13
+ export type Form = Map<string, string>;
14
+
15
+ /**
16
+ * The fields of an `application/x-www-form-urlencoded` body, the last of a repeated name winning.
17
+ * A browser sends a line break in a field as CR LF; it is read back as the single LF the domain
18
+ * holds, or a line break in a body would grow by a character each time the body is saved and
19
+ * opened again.
20
+ */
21
+ export function parseForm(body: string): Form {
22
+ const form: Form = new Map();
23
+ for (const [key, value] of new URLSearchParams(body)) form.set(key, value.replace(/\r\n?/g, "\n"));
24
+ return form;
25
+ }
26
+
27
+ const blank = (value: unknown): boolean =>
28
+ value === "" ||
29
+ value === undefined ||
30
+ value === null ||
31
+ (Array.isArray(value) && value.every(blank)) ||
32
+ (typeof value === "object" && !Array.isArray(value) && Object.values(value as object).every(blank));
33
+
34
+ function indices(form: Form, path: string): number[] {
35
+ const found = new Set<number>();
36
+ for (const key of form.keys()) {
37
+ const match = key.startsWith(`${path}.`) ? /^(\d+)(\.|$)/.exec(key.slice(path.length + 1)) : null;
38
+ if (match) found.add(Number(match[1]));
39
+ }
40
+ return [...found].sort((a, b) => a - b);
41
+ }
42
+
43
+ function text(attr: Attr, form: Form, path: string, optional: boolean): unknown {
44
+ const raw = form.get(path);
45
+ if (attr.kind === "boolean") return form.has(path);
46
+ if (raw === undefined || (raw === "" && optional)) return undefined;
47
+ if ((attr.kind === "integer" || attr.kind === "number") && raw.trim() !== "" && !Number.isNaN(Number(raw))) return Number(raw);
48
+ return raw;
49
+ }
50
+
51
+ /**
52
+ * A value object from its fields. One that is optional, or sits in an optional one, and was left
53
+ * entirely blank is left out; one with anything in it is read again with its required parts counted
54
+ * as required, so a part left blank is sent empty and the domain's own rule refuses it in its own words.
55
+ */
56
+ function object(agg: Aggregate, attr: Attr, form: Form, path: string, optional: boolean): unknown {
57
+ const read = (lenient: boolean): Record<string, unknown> => {
58
+ const built: Record<string, unknown> = {};
59
+ for (const part of agg.valueObjects[attr.type] ?? []) {
60
+ const value = build(agg, part, form, `${path}.`, lenient);
61
+ if (value !== undefined) built[part.name] = value;
62
+ }
63
+ return built;
64
+ };
65
+ const lenient = read(optional);
66
+ if (optional && blank(lenient)) return undefined;
67
+ const built = optional ? read(false) : lenient;
68
+ return Object.keys(built).length > 0 ? built : undefined;
69
+ }
70
+
71
+ function single(agg: Aggregate, attr: Attr, form: Form, path: string, optional: boolean): unknown {
72
+ return attr.kind === "object" ? object(agg, attr, form, path, optional) : text(attr, form, path, optional);
73
+ }
74
+
75
+ /** One attribute's value from the form, or undefined when it is to be left out. */
76
+ export function build(agg: Aggregate, attr: Attr, form: Form, prefix: string, optionalAbove = false): unknown {
77
+ const path = `${prefix}${attr.name}`;
78
+ const optional = optionalAbove || attr.optional;
79
+ if (!attr.list) return single(agg, attr, form, path, optional);
80
+ const rows = indices(form, path).map((index) => single(agg, attr, form, `${path}.${index}`, optional));
81
+ return rows.filter((row) => !blank(row));
82
+ }
83
+
84
+ /** The arguments a command takes, from a submitted form; `empty` names the list arguments sent empty. */
85
+ export function commandArguments(agg: Aggregate, attrs: Attr[], form: Form, empty: string[] = []): Record<string, unknown> {
86
+ const args: Record<string, unknown> = Object.fromEntries(empty.map((name) => [name, []]));
87
+ for (const attr of attrs) {
88
+ const value = build(agg, attr, form, "");
89
+ if (value !== undefined) args[attr.name] = value;
90
+ }
91
+ return args;
92
+ }
@@ -0,0 +1,100 @@
1
+ __BANNER__
2
+
3
+ // How one record reaches another. A record is named to the rest of the domain by a plain key of the
4
+ // form `<kind>:<slug>`, where the kind is its aggregate's snake-case name (`post:first-light`), so
5
+ // this file makes that key, fills an input named by dotted path with it, and keeps the way back: a
6
+ // page reached from a record's page carries `_back` (an address under the editor), and the form on it
7
+ // posts `__back`, so a save returns to the record the person came from.
8
+ import { EDITOR } from "../config.ts";
9
+ import type { Aggregate, Attr } from "../schema.ts";
10
+ import { esc } from "./html.ts";
11
+ import { label } from "./words.ts";
12
+
13
+ /** A record's names, as a key and its parts: `post:first-light` is kind `post`, slug `first-light`. */
14
+ export interface Parts {
15
+ key: string;
16
+ kind: string;
17
+ slug: string;
18
+ id: string;
19
+ }
20
+
21
+ /** The snake-case name a kind is spelled: "MediaItem" is "media_item". */
22
+ export const snake = (name: string): string => name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
23
+
24
+ /**
25
+ * A record's key and its parts. An identity that already reads `<kind>:<slug>` is that key; any
26
+ * other identity is the slug of a key whose kind is its aggregate's.
27
+ */
28
+ export function partsOf(agg: Aggregate, id: string): Parts {
29
+ const given = /^([a-z][a-z0-9_]*):(.+)$/.exec(id);
30
+ if (given) return { key: id, kind: given[1], slug: given[2], id };
31
+ const kind = snake(agg.name);
32
+ return { key: `${kind}:${id}`, kind, slug: id, id };
33
+ }
34
+
35
+ /** The value object's one plain part that an attribute is entered as, or null when it has several. */
36
+ function onlyPart(agg: Aggregate, attr: Attr): Attr | null {
37
+ const parts = attr.kind === "object" ? (agg.valueObjects[attr.type] ?? []) : [];
38
+ return parts.length === 1 && parts[0].kind !== "object" && !parts[0].list ? parts[0] : null;
39
+ }
40
+
41
+ /** The name of the input that holds an attribute of one plain value, as a form posts it. */
42
+ export function plainName(agg: Aggregate, attr: Attr): string {
43
+ const part = onlyPart(agg, attr);
44
+ return part ? `${attr.name}.${part.name}` : attr.name;
45
+ }
46
+
47
+ /** `text` as a value of the attribute, wrapped in its value object when it has one. */
48
+ export function plainValue(agg: Aggregate, attr: Attr, text: string): unknown {
49
+ const part = onlyPart(agg, attr);
50
+ return part ? { [part.name]: text } : text;
51
+ }
52
+
53
+ /** The text an attribute of one plain value holds. */
54
+ export function plainText(value: unknown): string {
55
+ if (value && typeof value === "object") return plainText(Object.values(value as object).find((part) => part !== null && part !== undefined));
56
+ return value === null || value === undefined ? "" : String(value);
57
+ }
58
+
59
+ /** Where a page was reached from: the address to go back to, and the words for the link. */
60
+ export interface Back {
61
+ path: string;
62
+ label: string;
63
+ }
64
+
65
+ /**
66
+ * The way back a request names, or null. Only an address under the editor's base path is one, so a
67
+ * link written by anyone cannot send a person off the editor; a control character or a backslash is
68
+ * refused as well.
69
+ */
70
+ export function backOf(raw: string | null | undefined): Back | null {
71
+ if (!raw || raw.length > 400 || /[\u0000-\u001f\\]/.test(raw)) return null;
72
+ if (raw !== EDITOR.basePath && !raw.startsWith(`${EDITOR.basePath}/`)) return null;
73
+ const segments = raw.split(/[?#]/)[0].split("/").filter(Boolean);
74
+ const at = segments.indexOf("id");
75
+ const named = at >= 0 && segments[at + 1] ? segments[at + 1] : (segments.at(-1) ?? "");
76
+ return { path: raw, label: decoded(named) };
77
+ }
78
+
79
+ function decoded(text: string): string {
80
+ try {
81
+ return decodeURIComponent(text);
82
+ } catch {
83
+ return text;
84
+ }
85
+ }
86
+
87
+ /** `path` with the way back added to its query. */
88
+ export const withBack = (path: string, back: string): string => `${path}${path.includes("?") ? "&" : "?"}_back=${encodeURIComponent(back)}`;
89
+
90
+ /** The hidden input a form posts so that a save returns to `back`. */
91
+ export const backInput = (back: Back | null): string => (back ? `<input type="hidden" name="__back" value="${esc(back.path)}">` : "");
92
+
93
+ /** The link at the top of a page that leads back to the record it was reached from. */
94
+ export function backLink(back: Back | null): string {
95
+ if (!back) return "";
96
+ return `<p class="mb-3"><a class="btn btn-ghost btn-sm" href="${esc(back.path)}">Back to ${esc(back.label)}</a></p>`;
97
+ }
98
+
99
+ /** What a record is called in a heading, such as "Search wording" or "Pictures", by the aggregate's own words. */
100
+ export const titled = (agg: Aggregate): string => label(agg.name);
@@ -0,0 +1,44 @@
1
+ __BANNER__
2
+
3
+ // Moments in time. An integer the bluebook names as a date or a time is whole seconds since 1970 UTC.
4
+ // The server does not know the viewer's time zone, so it writes the moment in UTC inside a `time`
5
+ // element that carries the seconds, and the page's script (browser/moments.js) rewrites it in the
6
+ // viewer's own zone with how long ago or from now it is. A form carries the seconds in a plain
7
+ // number input that works without a script; the script replaces it with a native date or
8
+ // date-and-time input and keeps the seconds in step.
9
+ import type { Attr } from "../schema.ts";
10
+ import { esc } from "./html.ts";
11
+
12
+ /** Whether the attribute is a moment. */
13
+ export const isMoment = (attr: Attr): boolean => attr.widget === "date" || attr.widget === "datetime";
14
+
15
+ /** The whole seconds a stored value holds: the number itself, or the single part of a value object. */
16
+ export function secondsOf(value: unknown): number | null {
17
+ const held = value && typeof value === "object" && !Array.isArray(value) ? Object.values(value as object)[0] : value;
18
+ if (held === null || held === undefined || held === "" || typeof held === "boolean") return null;
19
+ const seconds = Number(held);
20
+ return Number.isFinite(seconds) && Math.abs(seconds) <= 8_640_000_000_000 ? Math.trunc(seconds) : null;
21
+ }
22
+
23
+ /** A moment in UTC, for a page read with no script: `YYYY-MM-DD`, or `YYYY-MM-DD HH:MM UTC` with a time. */
24
+ export function utcText(seconds: number, mode: string): string {
25
+ const iso = new Date(seconds * 1000).toISOString();
26
+ return mode === "date" ? iso.slice(0, 10) : `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
27
+ }
28
+
29
+ /** A stored moment as a `time` element the script restates in the viewer's zone. */
30
+ export function momentTime(seconds: number, mode: string): string {
31
+ const iso = new Date(seconds * 1000).toISOString();
32
+ return `<time class="whitespace-nowrap" datetime="${esc(iso)}" data-moment="${esc(mode)}" data-epoch="${seconds}">${esc(utcText(seconds, mode))}</time>`;
33
+ }
34
+
35
+ /** A stored value (or a list of them) of a moment attribute, shown read-only. */
36
+ export function momentValue(attr: Attr, value: unknown): string {
37
+ const mode = attr.widget ?? "datetime";
38
+ const seconds = (item: unknown): string => {
39
+ const held = secondsOf(item);
40
+ return held === null ? `<span class="text-muted">Not set</span>` : momentTime(held, mode);
41
+ };
42
+ if (Array.isArray(value)) return value.length === 0 ? `<span class="text-muted">None</span>` : value.map(seconds).join(", ");
43
+ return seconds(value);
44
+ }