hecks 3.10.0 → 3.12.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 (542) 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 +158 -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 +70 -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/newcomer.rb +50 -0
  40. data/lib/hecks/adapters/driving/ruby/aggregate_module.rb +171 -0
  41. data/lib/hecks/adapters/driving/ruby/chapter.rb +91 -0
  42. data/lib/hecks/adapters/driving/ruby.rb +83 -0
  43. data/lib/hecks/adapters/driving/usage_cache.rb +144 -0
  44. data/lib/hecks/{doors.rb → adapters/driving.rb} +10 -6
  45. data/lib/hecks/behaviors/expectations/boot.rb +1 -1
  46. data/lib/hecks/bench/ruby_runner.rb +1 -1
  47. data/lib/hecks/bluebook/ask_resolution.rb +130 -0
  48. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +8 -2
  49. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  50. data/lib/hecks/bluebook/assembly/contracts/rules.rb +4 -2
  51. data/lib/hecks/bluebook/assembly/contracts/structure.rb +3 -1
  52. data/lib/hecks/bluebook/assembly/marks.rb +4 -15
  53. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  54. data/lib/hecks/bluebook/attribute.rb +1 -1
  55. data/lib/hecks/bluebook/behaviour/command.rb +13 -8
  56. data/lib/hecks/bluebook/behaviour/policy.rb +30 -0
  57. data/lib/hecks/bluebook/capabilities/attribute_values.rb +72 -0
  58. data/lib/hecks/bluebook/capabilities.rb +91 -9
  59. data/lib/hecks/bluebook/domain_port.rb +16 -4
  60. data/lib/hecks/bluebook/dsl/adapter_builder.rb +18 -6
  61. data/lib/hecks/bluebook/dsl/binding_proxy.rb +13 -0
  62. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/computed_sources.rb +98 -0
  63. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/fan_out_rows.rb +42 -0
  64. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/projected_fields.rb +14 -2
  65. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/provisions.rb +69 -3
  66. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/with_projections.rb +17 -10
  67. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +5 -0
  68. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  69. data/lib/hecks/bluebook/dsl/command_builder/mutations.rb +8 -1
  70. data/lib/hecks/bluebook/dsl/command_builder/operands.rb +121 -0
  71. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -0
  72. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +10 -1
  73. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +45 -2
  74. data/lib/hecks/bluebook/dsl/policy_builder.rb +47 -10
  75. data/lib/hecks/bluebook/dsl/rule_reference.rb +7 -4
  76. data/lib/hecks/bluebook/dsl/world_builder.rb +1 -1
  77. data/lib/hecks/bluebook/expression/ast_json/emitters.rb +6 -0
  78. data/lib/hecks/bluebook/expression/ast_json.rb +9 -6
  79. data/lib/hecks/bluebook/expression/ast_reader.rb +14 -9
  80. data/lib/hecks/bluebook/expression/projection.json +36 -0
  81. data/lib/hecks/bluebook/expression/resolver/operations.rb +31 -0
  82. data/lib/hecks/bluebook/expression/resolver/parsing.rb +38 -5
  83. data/lib/hecks/bluebook/expression/resolver/scanning.rb +22 -5
  84. data/lib/hecks/bluebook/expression/resolver/values.rb +10 -0
  85. data/lib/hecks/bluebook/expression/resolver.rb +20 -1
  86. data/lib/hecks/bluebook/hecksagon.rb +23 -3
  87. data/lib/hecks/bluebook/lifecycle.rb +22 -3
  88. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +13 -1
  89. data/lib/hecks/bluebook/meta_validator/modes.rb +4 -1
  90. data/lib/hecks/bluebook/meta_validator/readings.rb +24 -1
  91. data/lib/hecks/bluebook/meta_validator/reconstruction/heads.rb +2 -1
  92. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  93. data/lib/hecks/bluebook/meta_validator/shapes.rb +5 -0
  94. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +1 -1
  95. data/lib/hecks/bluebook/meta_validator.rb +12 -12
  96. data/lib/hecks/bluebook/model_check/ask_checks.rb +71 -0
  97. data/lib/hecks/bluebook/model_check/policy_checks.rb +2 -1
  98. data/lib/hecks/bluebook/model_check/reference_checks.rb +70 -0
  99. data/lib/hecks/bluebook/model_check.rb +8 -2
  100. data/lib/hecks/bluebook/policy.rb +4 -2
  101. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  102. data/lib/hecks/bluebook/synthesizer.rb +2 -0
  103. data/lib/hecks/bluebook.rb +2 -0
  104. data/lib/hecks/cli/command_table.rb +1 -1
  105. data/lib/hecks/cli/console.rb +2 -1
  106. data/lib/hecks/cli/document.rb +1 -1
  107. data/lib/hecks/cli/launcher_words.rb +4 -4
  108. data/lib/hecks/cli/mcp.rb +4 -4
  109. data/lib/hecks/cli/project_cli/launcher_source.rb +2 -2
  110. data/lib/hecks/cli/project_cli/templates.rb +5 -5
  111. data/lib/hecks/cli/project_cli.rb +9 -6
  112. data/lib/hecks/cli/run.rb +2 -2
  113. data/lib/hecks/cli/smoke_http/checks.rb +4 -1
  114. data/lib/hecks/cli/smoke_http/transport.rb +3 -3
  115. data/lib/hecks/cli/smoke_http.rb +3 -0
  116. data/lib/hecks/codemod.rb +5 -2
  117. data/lib/hecks/corpus/routes.rb +7 -0
  118. data/lib/hecks/corpus/rust.rb +3 -2
  119. data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -4
  120. data/lib/hecks/doc/reference/rendering.rb +6 -3
  121. data/lib/hecks/forms/app/commands.rb +8 -7
  122. data/lib/hecks/forms/app/queries.rb +2 -1
  123. data/lib/hecks/forms/app.rb +1 -1
  124. data/lib/hecks/forms/record_renderer.rb +1 -1
  125. data/lib/hecks/framework/bluebook/privacy.bluebook +11 -0
  126. data/lib/hecks/fuzzing/concurrent_dispatch.rb +4 -1
  127. data/lib/hecks/fuzzing/failure_triage.rb +2 -1
  128. data/lib/hecks/fuzzing/mutation/run.rb +11 -4
  129. data/lib/hecks/fuzzing/properties/catalog.rb +6 -4
  130. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +18 -11
  131. data/lib/hecks/fuzzing/properties/guards.rb +1 -1
  132. data/lib/hecks/fuzzing/self_consistency/saga_redelivery.rb +1 -1
  133. data/lib/hecks/fuzzing/self_consistency.rb +6 -2
  134. data/lib/hecks/fuzzing/shrinker.rb +3 -1
  135. data/lib/hecks/fuzzing/value_generator.rb +19 -5
  136. data/lib/hecks/gate/stages.yml +33 -7
  137. data/lib/hecks/grammar/evolve.rb +1 -1
  138. data/lib/hecks/grammar/expression_operators.json +372 -0
  139. data/lib/hecks/grammar/operators.rb +3 -1
  140. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +10 -10
  141. data/lib/hecks/hecks/adapters/codebase/inspection/rubycritic.rb +35 -0
  142. data/lib/hecks/hecks/adapters/codebase/inspection.rb +104 -0
  143. data/lib/hecks/hecks/adapters/codebase/publishing.rb +2 -1
  144. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +12 -2
  145. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +2 -1
  146. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +1 -1
  147. data/lib/hecks/hecks/adapters/cost_explorer.rb +8 -5
  148. data/lib/hecks/hecks/adapters/deploy_toolchain/statuses.rb +2 -1
  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 +133 -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/terminal.rb +13 -11
  173. data/lib/hecks/hecks/codebase.behaviors +13 -1
  174. data/lib/hecks/hecks/codebase.bluebook +239 -5
  175. data/lib/hecks/hecks/custodian.behaviors +24 -6
  176. data/lib/hecks/hecks/custodian.bluebook +6 -1227
  177. data/lib/hecks/hecks/deploy.behaviors +1 -1
  178. data/lib/hecks/hecks/deploy.world +1 -3
  179. data/lib/hecks/hecks/hecks.behaviors +1 -1
  180. data/lib/hecks/hecks/hecks.hecksagon +53 -18
  181. data/lib/hecks/hecks/hecks.world +13 -10
  182. data/lib/hecks/hecks/rehearsal.bluebook +477 -0
  183. data/lib/hecks/hecks/site.behaviors +1 -1
  184. data/lib/hecks/hecks/site.world +0 -1
  185. data/lib/hecks/hecks/tooling.bluebook +1238 -0
  186. data/lib/hecks/indifferent_key.rb +20 -0
  187. data/lib/hecks/language/adapter.bluebook +13 -3
  188. data/lib/hecks/language/bluebook/aggregate.bluebook +23 -0
  189. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  190. data/lib/hecks/language/bluebook/bluebook.bluebook +15 -0
  191. data/lib/hecks/language/bluebook/entity.bluebook +18 -0
  192. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  193. data/lib/hecks/language/bluebook/policy.bluebook +9 -1
  194. data/lib/hecks/language/bluebook/query.bluebook +1 -1
  195. data/lib/hecks/language/bluebook/vocabulary.bluebook +63 -4
  196. data/lib/hecks/language/hecksagon/domain_port.bluebook +1 -1
  197. data/lib/hecks/language/hecksagon/hecksagon.bluebook +22 -0
  198. data/lib/hecks/language/oidc.json +10 -0
  199. data/lib/hecks/language/port.bluebook +4 -4
  200. data/lib/hecks/literal.rb +12 -3
  201. data/lib/hecks/ports/key_vault.rb +7 -2
  202. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +21 -10
  203. data/lib/hecks/ports/persistence/plugins/era/postgres_era/boot.rb +4 -2
  204. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +2 -1
  205. data/lib/hecks/ports/persistence/repository_factory.rb +6 -3
  206. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  207. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  208. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  209. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  210. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  211. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  212. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  213. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  214. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  215. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  216. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  217. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  218. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  219. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +32 -0
  220. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +1 -1
  221. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  222. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  223. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  224. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  225. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  226. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  227. data/lib/hecks/projections/deploy/box.rb +15 -3
  228. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  229. data/lib/hecks/projections/deploy/shared.rb +5 -2
  230. data/lib/hecks/projections/model/deviations.rb +2 -2
  231. data/lib/hecks/projections/model.rb +2 -2
  232. data/lib/hecks/projections/oidc.rb +13 -1
  233. data/lib/hecks/projections/shape.rb +5 -2
  234. data/lib/hecks/projections/site/cms_editor/attributes.rb +26 -6
  235. data/lib/hecks/projections/site/cms_editor/blocks.rb +157 -0
  236. data/lib/hecks/projections/site/cms_editor/checks.rb +14 -1
  237. data/lib/hecks/projections/site/cms_editor/clearing.rb +70 -0
  238. data/lib/hecks/projections/site/cms_editor/color.rb +124 -0
  239. data/lib/hecks/projections/site/cms_editor/composition.rb +42 -0
  240. data/lib/hecks/projections/site/cms_editor/destructive.rb +61 -0
  241. data/lib/hecks/projections/site/cms_editor/drafts.rb +102 -0
  242. data/lib/hecks/projections/site/cms_editor/hints.rb +74 -0
  243. data/lib/hecks/projections/site/cms_editor/icons.rb +86 -0
  244. data/lib/hecks/projections/site/cms_editor/key_kinds.rb +31 -0
  245. data/lib/hecks/projections/site/cms_editor/limits.rb +72 -0
  246. data/lib/hecks/projections/site/cms_editor/media.rb +1 -1
  247. data/lib/hecks/projections/site/cms_editor/moment.rb +34 -0
  248. data/lib/hecks/projections/site/cms_editor/palette.rb +115 -0
  249. data/lib/hecks/projections/site/cms_editor/pickers.rb +85 -0
  250. data/lib/hecks/projections/site/cms_editor/preview.rb +91 -0
  251. data/lib/hecks/projections/site/cms_editor/relations.rb +92 -0
  252. data/lib/hecks/projections/site/cms_editor/scale_checks.rb +66 -0
  253. data/lib/hecks/projections/site/cms_editor/scheduling.rb +150 -0
  254. data/lib/hecks/projections/site/cms_editor/schema.rb +96 -19
  255. data/lib/hecks/projections/site/cms_editor/setting.rb +52 -9
  256. data/lib/hecks/projections/site/cms_editor/targets.rb +86 -0
  257. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +18 -1
  258. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +251 -57
  259. data/lib/hecks/projections/site/cms_editor/templates/src/assets.ts.tmpl +98 -0
  260. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +32 -24
  261. data/lib/hecks/projections/site/cms_editor/templates/src/browser/app.css.tmpl +412 -0
  262. data/lib/hecks/projections/site/cms_editor/templates/src/browser/autosave.js.tmpl +144 -0
  263. data/lib/hecks/projections/site/cms_editor/templates/src/browser/blocks.js.tmpl +500 -0
  264. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_doc.js.tmpl +175 -0
  265. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_editor.js.tmpl +418 -0
  266. data/lib/hecks/projections/site/cms_editor/templates/src/browser/epoch.js.tmpl +61 -0
  267. data/lib/hecks/projections/site/cms_editor/templates/src/browser/forms.js.tmpl +210 -0
  268. data/lib/hecks/projections/site/cms_editor/templates/src/browser/lists.js.tmpl +40 -0
  269. data/lib/hecks/projections/site/cms_editor/templates/src/browser/main.js.tmpl +26 -0
  270. data/lib/hecks/projections/site/cms_editor/templates/src/browser/media_picker.js.tmpl +232 -0
  271. data/lib/hecks/projections/site/cms_editor/templates/src/browser/moments.js.tmpl +73 -0
  272. data/lib/hecks/projections/site/cms_editor/templates/src/browser/pickers.js.tmpl +152 -0
  273. data/lib/hecks/projections/site/cms_editor/templates/src/browser/preview.js.tmpl +98 -0
  274. data/lib/hecks/projections/site/cms_editor/templates/src/browser/shell.js.tmpl +140 -0
  275. data/lib/hecks/projections/site/cms_editor/templates/src/browser/toast.js.tmpl +66 -0
  276. data/lib/hecks/projections/site/cms_editor/templates/src/choices.ts.tmpl +99 -0
  277. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +78 -24
  278. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +6 -0
  279. data/lib/hecks/projections/site/cms_editor/templates/src/flash.ts.tmpl +52 -0
  280. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +19 -2
  281. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +31 -8
  282. data/lib/hecks/projections/site/cms_editor/templates/src/names.ts.tmpl +36 -0
  283. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +131 -2
  284. data/lib/hecks/projections/site/cms_editor/templates/src/ui/availability.ts.tmpl +15 -0
  285. data/lib/hecks/projections/site/cms_editor/templates/src/ui/block_list.js.tmpl +213 -0
  286. data/lib/hecks/projections/site/cms_editor/templates/src/ui/blocks.ts.tmpl +275 -0
  287. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +8 -5
  288. data/lib/hecks/projections/site/cms_editor/templates/src/ui/detail.ts.tmpl +88 -0
  289. data/lib/hecks/projections/site/cms_editor/templates/src/ui/dialogs.ts.tmpl +43 -0
  290. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +229 -34
  291. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +40 -81
  292. data/lib/hecks/projections/site/cms_editor/templates/src/ui/icons.ts.tmpl +15 -0
  293. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +29 -12
  294. data/lib/hecks/projections/site/cms_editor/templates/src/ui/links.ts.tmpl +100 -0
  295. data/lib/hecks/projections/site/cms_editor/templates/src/ui/moments.ts.tmpl +44 -0
  296. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +53 -17
  297. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +178 -88
  298. data/lib/hecks/projections/site/cms_editor/templates/src/ui/paging.ts.tmpl +82 -0
  299. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pickers.ts.tmpl +110 -0
  300. data/lib/hecks/projections/site/cms_editor/templates/src/ui/preview.ts.tmpl +87 -0
  301. data/lib/hecks/projections/site/cms_editor/templates/src/ui/related.ts.tmpl +97 -0
  302. data/lib/hecks/projections/site/cms_editor/templates/src/ui/schedule.ts.tmpl +160 -0
  303. data/lib/hecks/projections/site/cms_editor/templates/src/ui/shell.ts.tmpl +211 -0
  304. data/lib/hecks/projections/site/cms_editor/templates/src/ui/table.ts.tmpl +151 -0
  305. data/lib/hecks/projections/site/cms_editor/templates/src/ui/theme.js.tmpl +16 -0
  306. data/lib/hecks/projections/site/cms_editor/templates/src/ui/values.ts.tmpl +69 -0
  307. data/lib/hecks/projections/site/cms_editor/templates/src/ui/words.ts.tmpl +97 -0
  308. data/lib/hecks/projections/site/cms_editor/templates/src/ui/world.ts.tmpl +35 -0
  309. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +2 -1
  310. data/lib/hecks/projections/site/cms_editor/theme.rb +29 -0
  311. data/lib/hecks/projections/site/cms_editor/theme_checks.rb +57 -0
  312. data/lib/hecks/projections/site/cms_editor.rb +49 -23
  313. data/lib/hecks/projections/site/role_probe.rb +2 -0
  314. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  315. data/lib/hecks/projector/cli_audience.rb +22 -1
  316. data/lib/hecks/projector/cli_projector/listing.rb +1 -1
  317. data/lib/hecks/projector/cli_projector/option_specs.rb +1 -1
  318. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  319. data/lib/hecks/projector/docs_projector/closing.rb +1 -1
  320. data/lib/hecks/projector/docs_projector.rb +1 -1
  321. data/lib/hecks/projector/exporter/bindings.rb +40 -10
  322. data/lib/hecks/projector/exporter/capability_values.rb +55 -0
  323. data/lib/hecks/projector/narrate_projector/reactions.rb +1 -1
  324. data/lib/hecks/projector/target.rb +11 -0
  325. data/lib/hecks/projector.rb +32 -5
  326. data/lib/hecks/quality_control/adapters/github_checks.rb +12 -8
  327. data/lib/hecks/quality_control/adapters/github_issues.rb +8 -4
  328. data/lib/hecks/quality_control/cli/qa_open_pr.rb +2 -2
  329. data/lib/hecks/quality_control/cli/qa_sweep/checks/shrinking.rb +1 -1
  330. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +1 -1
  331. data/lib/hecks/quality_control/cli/qa_sweep.rb +1 -1
  332. data/lib/hecks/quality_control/quality_control.bluebook +6 -2
  333. data/lib/hecks/release/approval.rb +64 -0
  334. data/lib/hecks/router/{door.rb → aggregate_reader.rb} +5 -4
  335. data/lib/hecks/router/namespace_installer.rb +16 -16
  336. data/lib/hecks/router.rb +1 -1
  337. data/lib/hecks/runtime/best_effort.rb +22 -0
  338. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -2
  339. data/lib/hecks/runtime/command_interpreter.rb +3 -2
  340. data/lib/hecks/runtime/command_rules/emission.rb +1 -1
  341. data/lib/hecks/runtime/dispatcher.rb +4 -4
  342. data/lib/hecks/runtime/entity_element/location.rb +1 -1
  343. data/lib/hecks/runtime/event.rb +5 -0
  344. data/lib/hecks/runtime/identity.rb +7 -6
  345. data/lib/hecks/runtime/interpreting.rb +2 -1
  346. data/lib/hecks/runtime/invocation_builder.rb +1 -1
  347. data/lib/hecks/runtime/loader/boot_steps.rb +8 -5
  348. data/lib/hecks/runtime/loader.rb +10 -10
  349. data/lib/hecks/runtime/policy_interpreter/arguments.rb +7 -4
  350. data/lib/hecks/runtime/policy_interpreter/fan_out.rb +3 -3
  351. data/lib/hecks/runtime/policy_interpreter.rb +6 -6
  352. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -1
  353. data/lib/hecks/runtime/reaction_invocation.rb +1 -1
  354. data/lib/hecks/runtime/rebuild_sweep.rb +11 -1
  355. data/lib/hecks/runtime/registry/ask_verification.rb +35 -0
  356. data/lib/hecks/runtime/registry/hecksagon_rules.rb +10 -0
  357. data/lib/hecks/runtime/registry/merging.rb +1 -1
  358. data/lib/hecks/runtime/registry/providers.rb +16 -0
  359. data/lib/hecks/runtime/registry/read_repository.rb +6 -4
  360. data/lib/hecks/runtime/registry/verification.rb +4 -1
  361. data/lib/hecks/runtime/registry/world_defaults.rb +29 -0
  362. data/lib/hecks/runtime/registry.rb +1 -1
  363. data/lib/hecks/runtime/saga_interpreter/compensation.rb +1 -1
  364. data/lib/hecks/runtime/saga_interpreter/delivery.rb +4 -4
  365. data/lib/hecks/runtime/saga_interpreter/transitions.rb +1 -1
  366. data/lib/hecks/runtime/saga_interpreter.rb +3 -3
  367. data/lib/hecks/runtime/tenant_check.rb +5 -4
  368. data/lib/hecks/runtime/value/coercion.rb +2 -2
  369. data/lib/hecks/runtime/value/validation.rb +16 -4
  370. data/lib/hecks/runtime.rb +9 -9
  371. data/lib/hecks/rust_build/host.rb +11 -4
  372. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  373. data/lib/hecks/rust_build/project_rust/target_ir.rb +1 -1
  374. data/lib/hecks/rust_build/wasm.rb +1 -1
  375. data/lib/hecks/rust_build/wasm_browser.rb +1 -1
  376. data/lib/hecks/rust_build.rb +11 -0
  377. data/lib/hecks/served_options.rb +53 -0
  378. data/lib/hecks/site/bluebook/site.bluebook +1 -0
  379. data/lib/hecks/storehouse/audit.rb +1 -1
  380. data/lib/hecks/storehouse/dispatching.rb +8 -6
  381. data/lib/hecks/storehouse/introspection.rb +2 -2
  382. data/lib/hecks/storehouse/querying.rb +6 -4
  383. data/lib/hecks/storehouse.rb +4 -4
  384. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  385. data/lib/hecks/tools/ci_gates.rb +20 -1
  386. data/lib/hecks/tools/comment_style/source_file.rb +4 -1
  387. data/lib/hecks/tools/deploy_recipe_lint/fixtures.rb +4 -1
  388. data/lib/hecks/tools/lanes/files.rb +8 -0
  389. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  390. data/lib/hecks/tools/lanes/rows.rb +6 -0
  391. data/lib/hecks/tools/lanes.rb +1 -0
  392. data/lib/hecks/tools/oidc_manifests.rb +4 -5
  393. data/lib/hecks/tools/regeneration_run.rb +11 -4
  394. data/lib/hecks/tools/release_only.rb +111 -0
  395. data/lib/hecks/tools/site_routes/editor_files.rb +12 -5
  396. data/lib/hecks/tools/tenant_provisioning.rb +2 -2
  397. data/lib/hecks/tools/tools_doc.rb +4 -4
  398. data/lib/hecks/version.rb +1 -1
  399. data/lib/hecks/vocabulary.rb +19 -5
  400. data/lib/hecks.rb +15 -15
  401. data/lib/rubocop/cop/hecks/env_dump.rb +69 -0
  402. data/lib/rubocop/cop/hecks/monotonic_duration.rb +53 -0
  403. data/lib/rubocop/cop/hecks/no_wall_clock_in_runtime.rb +34 -0
  404. data/lib/rubocop/cop/hecks/raw_shell_interpolation.rb +79 -0
  405. data/lib/rubocop/cop/hecks/sleep_in_spec.rb +34 -0
  406. data/lib/rubocop/cop/hecks/swallowed_rescue.rb +37 -0
  407. data/lib/rubocop/cop/hecks/symbol_string_key_mix.rb +50 -0
  408. data/lib/rubocop/cop/hecks/untyped_raise.rb +37 -0
  409. data/prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal +0 -0
  410. data/prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal +0 -0
  411. data/prebuilt/verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json +1 -0
  412. data/rust/.gitignore +6 -0
  413. data/rust/Cargo.toml +6 -0
  414. data/rust/build/src/binding_pass.rs +103 -0
  415. data/rust/build/src/cargo_sync.rs +6 -0
  416. data/rust/build/src/lineage_pass.rs +3 -3
  417. data/rust/build/src/main.rs +17 -5
  418. data/rust/build/src/pipeline.rs +48 -5
  419. data/rust/build/src/seams_pass.rs +123 -0
  420. data/rust/build/src/translation_pass.rs +167 -0
  421. data/rust/codegen/src/asks.rs +103 -0
  422. data/rust/codegen/src/commands.rs +13 -8
  423. data/rust/codegen/src/constraints.rs +25 -0
  424. data/rust/codegen/src/domain_generator.rs +2 -0
  425. data/rust/codegen/src/expr_emitter.rs +13 -0
  426. data/rust/codegen/src/json_codec.rs +54 -15
  427. data/rust/codegen/src/main.rs +1 -0
  428. data/rust/codegen/src/mutations.rs +128 -3
  429. data/rust/codegen/src/queries.rs +11 -2
  430. data/rust/codegen/src/reactions.rs +1 -1
  431. data/rust/codegen/src/types.rs +166 -8
  432. data/rust/host/HECKS_RELEASE +1 -1
  433. data/rust/host/src/api.rs +4 -4
  434. data/rust/host/src/bin/validate_harness.rs +43 -0
  435. data/rust/host/src/boundary_fuzz/expr_json.rs +2 -2
  436. data/rust/host/src/boundary_fuzz/ir.rs +1 -0
  437. data/rust/host/src/boundary_fuzz/server.rs +1 -1
  438. data/rust/host/src/checkout.rs +35 -19
  439. data/rust/host/src/commerce_ir.rs +408 -2
  440. data/rust/host/src/expr_json.rs +118 -0
  441. data/rust/host/src/extension.rs +13 -1
  442. data/rust/host/src/lib.rs +1 -0
  443. data/rust/host/src/mint.rs +1 -1
  444. data/rust/host/src/payments/tests.rs +27 -1
  445. data/rust/host/src/payments.rs +12 -4
  446. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  447. data/rust/host/src/platform_signin/config.rs +512 -0
  448. data/rust/host/src/platform_signin/cookie.rs +237 -0
  449. data/rust/host/src/platform_signin/extension.rs +190 -0
  450. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  451. data/rust/host/src/platform_signin/handler.rs +251 -0
  452. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  453. data/rust/host/src/platform_signin/jti.rs +196 -0
  454. data/rust/host/src/platform_signin/jwks.rs +542 -0
  455. data/rust/host/src/platform_signin/jws.rs +249 -0
  456. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  457. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  458. data/rust/host/src/platform_signin/mod.rs +75 -0
  459. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  460. data/rust/host/src/platform_signin/pages.rs +204 -0
  461. data/rust/host/src/platform_signin/query.rs +233 -0
  462. data/rust/host/src/platform_signin/session.rs +151 -0
  463. data/rust/host/src/platform_signin/settings.rs +385 -0
  464. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  465. data/rust/host/src/platform_signin/test_support.rs +235 -0
  466. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  467. data/rust/host/src/platform_signin/verify.rs +604 -0
  468. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  469. data/rust/host/src/query_step.rs +22 -4
  470. data/rust/host/src/reference_validate.rs +653 -108
  471. data/rust/host/src/resend.rs +2 -0
  472. data/rust/host/src/server.rs +19 -6
  473. data/rust/host/src/ui_schema.rs +132 -5
  474. data/rust/host/src/web/newsletter.rs +7 -8
  475. data/rust/host/src/web/newsletter_send.rs +38 -13
  476. data/rust/host/src/web/registrations.rs +229 -26
  477. data/rust/host/src/web.rs +53 -9
  478. data/rust/parser/src/emit.rs +38 -6
  479. data/rust/parser/src/expr/ast_json.rs +7 -1
  480. data/rust/parser/src/expr/resolver.rs +171 -6
  481. data/rust/parser/src/ir.rs +7 -0
  482. data/rust/parser/src/keywords.rs +24 -0
  483. data/rust/parser/src/main.rs +3 -0
  484. data/rust/parser/src/parse/aggregate.rs +2 -1
  485. data/rust/parser/src/parse/chapter.rs +2 -2
  486. data/rust/parser/src/parse/command.rs +2 -0
  487. data/rust/parser/src/parse/hecksagon.rs +5 -2
  488. data/rust/parser/src/parse/lifecycle.rs +81 -0
  489. data/rust/parser/src/parse/mod.rs +15 -1
  490. data/rust/parser/src/parse/policy.rs +13 -2
  491. data/rust/parser/src/parse/value_object.rs +34 -17
  492. data/rust/parser/src/ruby_value.rs +1 -1
  493. data/rust/src/exemplar/commands.rs +4 -4
  494. data/rust/src/exemplar/constraints.rs +1 -1
  495. data/rust/src/exemplar/json.rs +43 -1
  496. data/rust/src/kernel/cli.rs +7 -5
  497. data/rust/src/kernel/dispatch.rs +2 -2
  498. data/rust/src/kernel/dispatch_guard_tests.rs +1 -1
  499. data/rust/src/kernel/expr.rs +66 -3
  500. data/rust/src/kernel/expression_operators/arithmetic.rs +130 -5
  501. data/rust/src/kernel/expression_operators/text.rs +26 -2
  502. data/rust/src/kernel/json.rs +15 -0
  503. data/rust/src/kernel/mod.rs +1 -1
  504. data/rust/src/kernel/orchestrate.rs +54 -5
  505. data/rust/src/kernel/orchestrate_guard_tests.rs +67 -0
  506. data/rust/src/kernel/pattern.rs +60 -0
  507. data/rust/src/kernel/reference_lookup.rs +90 -7
  508. metadata +178 -38
  509. data/lib/hecks/doors/cli_door/nesting.rb +0 -34
  510. data/lib/hecks/doors/cli_door/shorthand.rb +0 -85
  511. data/lib/hecks/doors/cli_door.rb +0 -124
  512. data/lib/hecks/doors/cli_runner/answers.rb +0 -138
  513. data/lib/hecks/doors/cli_runner/resolution.rb +0 -146
  514. data/lib/hecks/doors/cli_runner/settling.rb +0 -115
  515. data/lib/hecks/doors/cli_runner/streaming.rb +0 -90
  516. data/lib/hecks/doors/cli_runner/suggestions.rb +0 -43
  517. data/lib/hecks/doors/cli_runner.rb +0 -66
  518. data/lib/hecks/doors/command_request.rb +0 -119
  519. data/lib/hecks/doors/handle.rb +0 -208
  520. data/lib/hecks/doors/json_door.rb +0 -132
  521. data/lib/hecks/doors/launcher_options.rb +0 -247
  522. data/lib/hecks/doors/mcp_door/properties.rb +0 -47
  523. data/lib/hecks/doors/mcp_door/tool_calls.rb +0 -113
  524. data/lib/hecks/doors/mcp_door/tool_definitions/auditing.rb +0 -51
  525. data/lib/hecks/doors/mcp_door/tool_definitions/calling.rb +0 -69
  526. data/lib/hecks/doors/mcp_door/tool_definitions/reading.rb +0 -97
  527. data/lib/hecks/doors/mcp_door/tool_definitions.rb +0 -15
  528. data/lib/hecks/doors/mcp_door.rb +0 -156
  529. data/lib/hecks/doors/mcp_door_scope/argument_policy.rb +0 -92
  530. data/lib/hecks/doors/mcp_door_scope/env_problems.rb +0 -55
  531. data/lib/hecks/doors/mcp_door_scope/presenter.rb +0 -78
  532. data/lib/hecks/doors/mcp_door_scope.rb +0 -201
  533. data/lib/hecks/doors/mcp_guide_cache.rb +0 -75
  534. data/lib/hecks/doors/ruby_door/aggregate_door.rb +0 -162
  535. data/lib/hecks/doors/ruby_door/chapter.rb +0 -89
  536. data/lib/hecks/doors/ruby_door.rb +0 -80
  537. data/lib/hecks/doors/usage_cache.rb +0 -139
  538. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +0 -282
  539. data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +0 -109
  540. data/prebuilt/c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal +0 -0
  541. data/prebuilt/cb375cba61ca0dbe8ddb9739c306510fe05e5f4b7a9b6955caff8224c63ea0e0.marshal +0 -0
  542. data/prebuilt/verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json +0 -1
@@ -0,0 +1,500 @@
1
+ __BANNER__
2
+
3
+ // What a list of mixed blocks does with a script (the markup is ui/blocks.ts). Cards open and close;
4
+ // a new card is made from the template the page carries for its kind; cards move up and down by
5
+ // buttons, with the result announced in a polite live region and focus kept on the button that was
6
+ // pressed; a card is removed after a question in a native dialog; and the cards are numbered again
7
+ // after each change, renaming their fields so the form posts the list in the order it is shown.
8
+ // A body inside a card gets its writing surface only when the card is first opened, so a page of
9
+ // forty cards holds a handful of editors. A list written as a draft saves by itself (autosave.js),
10
+ // and holds back while a card still lacks what its kind needs.
11
+ //
12
+ // The same script serves a page whose working copy is a value object (`data-draft-form` on the form):
13
+ // the form as a whole saves by itself, whichever of its lists, pictures, writing surfaces and plain
14
+ // inputs the person touches, and a picture outside any list opens the picker from its own group.
15
+ import { retarget, safe } from "../ui/block_list.js";
16
+ import { startDraft } from "./autosave.js";
17
+ import { enhanceRows, firstProblem } from "./forms.js";
18
+ import { mountMoments } from "./moments.js";
19
+ import { mountPickers } from "./pickers.js";
20
+
21
+ /** Loads the picture picker, or is null when the editor has no pictures to choose from. */
22
+ const loadPicker = __PICKER_LOADER__;
23
+
24
+ const pluralOf = (noun) => (/[^aeiou]y$/.test(noun) ? `${noun.slice(0, -1)}ies` : /(s|x|z|ch|sh)$/.test(noun) ? `${noun}es` : `${noun}s`);
25
+
26
+ /** The attributes whose text names a field of a card, and so changes when the card's index does. */
27
+ const NAMED = ["name", "id", "for", "aria-controls", "aria-describedby", "aria-labelledby", "href", "data-path", "data-name", "data-key", "data-alt"];
28
+
29
+ /** Renames the fields of `card` (and the cards templates it carries) from index `was` to `at`. */
30
+ function renumberFields(card, path, was, at) {
31
+ for (const element of [card, ...card.querySelectorAll("*")]) {
32
+ if (element.localName === "template") element.innerHTML = retarget(element.innerHTML, path, was, at);
33
+ for (const name of NAMED) {
34
+ if (element.hasAttribute(name)) element.setAttribute(name, retarget(element.getAttribute(name), path, was, at));
35
+ }
36
+ }
37
+ }
38
+
39
+ /** The cards of a group, in the order shown. */
40
+ const cardsOf = (group) => [...group.querySelector(":scope > [data-cards]").children];
41
+
42
+ /** The group a card is in. */
43
+ const groupOf = (card) => card.closest("[data-group]");
44
+
45
+ /** The list a group belongs to (the outermost group). */
46
+ const listOf = (node) => node.closest("[data-block-list]");
47
+
48
+ /** The working copies saved by the form as a whole, by form. */
49
+ const formDrafts = new WeakMap();
50
+
51
+ /** The polite line that speaks for `root`: a list's own, or a form's (made when it has none yet). */
52
+ function lineOf(root) {
53
+ if (!root.matches("form")) return root.querySelector("[data-block-say]");
54
+ let line = root.querySelector(":scope > [data-form-say]");
55
+ if (!line) {
56
+ line = Object.assign(document.createElement("p"), { className: "sr-only" });
57
+ line.setAttribute("role", "status");
58
+ line.setAttribute("aria-live", "polite");
59
+ line.dataset.formSay = "";
60
+ root.append(line);
61
+ }
62
+ return line;
63
+ }
64
+
65
+ const say = (root, text) => {
66
+ const line = lineOf(root);
67
+ line.textContent = "";
68
+ setTimeout(() => { line.textContent = text; }, 30);
69
+ };
70
+
71
+ /** The controls of a card that are its own: not those of a card nested in it, nor a writing surface's fields. */
72
+ const ownControls = (card) =>
73
+ [...card.querySelectorAll("input, select, textarea")].filter((control) => control.closest("[data-card]") === card && !control.closest("[data-body-editor]"));
74
+
75
+ /** The one line that stands for a card: its first required text, else its longest text. */
76
+ function summaryOf(card) {
77
+ const prefix = `${groupOf(card).dataset.path}.${card.dataset.index}.`;
78
+ const requires = (card.dataset.requires ?? "").split(" ").filter(Boolean).map((slot) => `${prefix}${slot}`);
79
+ const alt = card.dataset.summaryAlt ? `${prefix}${card.dataset.summaryAlt}` : null;
80
+ const named = (control) => requires.includes(control.name) || requires.some((slot) => control.name === `${slot}.value`);
81
+ const texts = ownControls(card)
82
+ .filter((control) => (control.type === "text" || control.type === "textarea") && control.value.trim() !== "" && (!control.closest("[data-picture]") || named(control) || control.name === alt))
83
+ .map((control) => ({ text: control.value.trim(), required: named(control), alt: control.name === alt }));
84
+ const chosen = texts.find((one) => one.alt) ?? texts.find((one) => one.required) ?? texts.reduce((best, one) => (!best || one.text.length > best.text.length ? one : best), null);
85
+ if (chosen) return chosen.text.length > 80 ? `${chosen.text.slice(0, 79)}…` : chosen.text;
86
+ const counts = [...card.querySelectorAll(":scope > [data-card-panel] > [data-group]")]
87
+ .map((group) => [group.dataset.noun, cardsOf(group).length])
88
+ .filter(([, count]) => count > 0)
89
+ .map(([noun, count]) => `${count} ${count === 1 ? noun : pluralOf(noun)}`);
90
+ return counts.join(", ");
91
+ }
92
+
93
+ const titleOf = (card) => {
94
+ const kind = card.querySelector(":scope > [data-card-head] [data-kind-badge]")?.textContent;
95
+ const number = card.querySelector(":scope > [data-card-head] [data-card-number]")?.textContent;
96
+ const noun = groupOf(card).dataset.noun;
97
+ const summary = card.querySelector(":scope > [data-card-head] [data-card-summary]")?.textContent;
98
+ return `${noun} ${number}${kind ? `, ${kind}` : ""}${summary ? `: ${summary}` : ""}`;
99
+ };
100
+
101
+ /** Sets the words that name a card to the tools and the toggle (they say which card they move, open or remove). */
102
+ function nameTools(card) {
103
+ const noun = groupOf(card).dataset.noun;
104
+ const number = card.querySelector(":scope > [data-card-head] [data-card-number]").textContent;
105
+ const summary = card.querySelector(":scope > [data-card-head] [data-card-summary]").textContent;
106
+ const kind = card.querySelector(":scope > [data-card-head] [data-kind-badge]")?.textContent;
107
+ const what = `${noun} ${number}${kind ? `, ${kind}` : ""}${summary ? `, ${summary}` : ""}`;
108
+ const words = { up: "Move up", down: "Move down", remove: "Remove" };
109
+ for (const button of card.querySelectorAll(":scope > [data-card-head] [data-tools] button")) {
110
+ const label = `${words[button.dataset.action]} ${what}`;
111
+ button.setAttribute("aria-label", label);
112
+ button.title = label;
113
+ }
114
+ }
115
+
116
+ /** Numbers the group's cards again, and says which tools no longer apply. */
117
+ function renumber(group) {
118
+ const path = group.dataset.path;
119
+ const cards = cardsOf(group);
120
+ cards.forEach((card, at) => {
121
+ if (card.dataset.index !== String(at)) {
122
+ renumberFields(card, path, card.dataset.index, String(at));
123
+ card.dataset.index = String(at);
124
+ }
125
+ card.querySelector(":scope > [data-card-head] [data-card-number]").textContent = String(at + 1);
126
+ const static_ = card.querySelector(":scope > [data-card-head] [data-card-static] span");
127
+ if (static_) static_.textContent = `${group.dataset.noun.charAt(0).toUpperCase()}${group.dataset.noun.slice(1)} ${at + 1}`;
128
+ card.querySelector('[data-action="up"]')?.setAttribute("aria-disabled", String(at === 0));
129
+ card.querySelector('[data-action="down"]')?.setAttribute("aria-disabled", String(at === cards.length - 1));
130
+ nameTools(card);
131
+ });
132
+ const count = group.querySelector(":scope > legend [data-count]");
133
+ if (count) count.textContent = `(${cards.length})`;
134
+ const empty = group.querySelector(":scope > [data-empty]");
135
+ if (empty) empty.hidden = cards.length > 0;
136
+ const max = Number(group.dataset.max ?? 0);
137
+ for (const add of group.querySelectorAll(":scope > [data-adder] [data-action=add], :scope > [data-action=add]")) {
138
+ add.setAttribute("aria-disabled", String(max > 0 && cards.length >= max));
139
+ }
140
+ }
141
+
142
+ /** Opens or closes a card. Opening mounts the writing surfaces inside it that are not yet mounted. */
143
+ function toggle(card, open) {
144
+ const button = card.querySelector(':scope > [data-card-head] [data-action="toggle"]');
145
+ const panel = card.querySelector(":scope > [data-card-panel]");
146
+ const was = !panel.hidden;
147
+ const now = open ?? !was;
148
+ panel.hidden = !now;
149
+ button.setAttribute("aria-expanded", String(now));
150
+ button.querySelector("use")?.setAttribute("href", `#i-${now ? "chevron-down" : "chevron-right"}`);
151
+ if (now) mountEditors(panel);
152
+ }
153
+
154
+ /** Gives each visible rich-text body inside `scope` its writing surface, once. */
155
+ function mountEditors(scope) {
156
+ const pending = [...scope.querySelectorAll("[data-body-editor]:not([data-mounted])")];
157
+ if (pending.length === 0) return;
158
+ for (const root of pending) {
159
+ const card = root.closest("[data-card]");
160
+ const noun = groupOf(card).dataset.noun;
161
+ root.dataset.base ??= root.dataset.label;
162
+ root.dataset.label = `${root.dataset.base} of ${noun} ${Number(card.dataset.index) + 1}`;
163
+ }
164
+ import("./body_editor.js").then((editor) => editor.mountWithin(scope));
165
+ }
166
+
167
+ /** Makes a card's script-only parts live: its toggle, its tools, and the cards nested in it. */
168
+ function dress(card) {
169
+ card.querySelector(":scope > [data-card-head] [data-card-static]")?.setAttribute("hidden", "");
170
+ for (const node of card.querySelectorAll(":scope > [data-card-head] [data-tools], :scope > [data-card-head] [data-action=toggle]")) node.hidden = false;
171
+ const button = card.querySelector(':scope > [data-card-head] [data-action="toggle"]');
172
+ const open = card.hasAttribute("data-invalid") || card.hasAttribute("data-open");
173
+ const panel = card.querySelector(":scope > [data-card-panel]");
174
+ panel.hidden = !open;
175
+ button.setAttribute("aria-expanded", String(open));
176
+ button.querySelector("use")?.setAttribute("href", `#i-${open ? "chevron-down" : "chevron-right"}`);
177
+ for (const group of card.querySelectorAll(":scope > [data-card-panel] > [data-group]")) {
178
+ for (const add of group.querySelectorAll(":scope > [data-action=add]")) add.hidden = false;
179
+ for (const nested of cardsOf(group)) dress(nested);
180
+ renumber(group);
181
+ }
182
+ }
183
+
184
+ /** A new card for `group`, from the template of `kind` ("" for a nested group). */
185
+ function create(group, kind) {
186
+ const template = [...group.querySelectorAll(":scope > template[data-card-template]")].find((one) => one.dataset.kind === kind);
187
+ if (!template) return null;
188
+ const cards = group.querySelector(":scope > [data-cards]");
189
+ const token = group.dataset.depth === "0" ? "ZZI0ZZ" : "ZZI1ZZ";
190
+ cards.insertAdjacentHTML("beforeend", template.innerHTML.split(token).join(String(cardsOf(group).length)));
191
+ const card = cards.lastElementChild;
192
+ dress(card);
193
+ enhanceRows(card);
194
+ mountPickers(card);
195
+ mountMoments(card);
196
+ return card;
197
+ }
198
+
199
+ /** Whether a card still lacks something its kind requires or a picture's description, with what. */
200
+ function lacks(card) {
201
+ const prefix = `${groupOf(card).dataset.path}.${card.dataset.index}.`;
202
+ const names = (card.dataset.requires ?? "").split(" ").filter(Boolean);
203
+ const held = (slot) => [...card.querySelectorAll("input, select, textarea")].some((control) => {
204
+ const own = control.name === `${prefix}${slot}` || control.name.startsWith(`${prefix}${slot}.`);
205
+ return own && control.type !== "checkbox" && control.value.trim() !== "";
206
+ });
207
+ const missing = names.filter((slot) => !held(slot));
208
+ const own = [...card.querySelectorAll("[data-picture]")].filter((picture) => picture.closest("[data-card]") === card);
209
+ if (own.some(undescribed) && !missing.includes("alt")) missing.push("alt");
210
+ return missing;
211
+ }
212
+
213
+ /** Whether a picture names its file and has no description. */
214
+ function undescribed(picture) {
215
+ const form = picture.closest("form") ?? document;
216
+ const key = form.querySelector(`[name="${CSS.escape(picture.dataset.key)}"]`);
217
+ const alt = form.querySelector(`[name="${CSS.escape(picture.dataset.alt)}"]`);
218
+ return Boolean(key?.value.trim()) && alt !== null && alt.value.trim() === "";
219
+ }
220
+
221
+ /** The card, and the nested cards under it, that lack something. */
222
+ function incomplete(list) {
223
+ return [...list.querySelectorAll("[data-card]")].filter((card) => lacks(card).length > 0);
224
+ }
225
+
226
+ /** The pictures under `scope` that are in no list's card: a picture slot of a value object. */
227
+ const loose = (scope) => [...scope.querySelectorAll("[data-picture]")].filter((picture) => !picture.closest("[data-card]"));
228
+
229
+ /** Marks a picture's alt control invalid, with the line that says what to do, or clears the mark. */
230
+ function markAlt(picture, show) {
231
+ const alt = picture.querySelector(`[name="${picture.dataset.alt}"]`);
232
+ if (!alt) return;
233
+ const id = `${alt.id}-error`;
234
+ picture.querySelector(`#${CSS.escape(id)}`)?.remove();
235
+ alt.removeAttribute("aria-invalid");
236
+ if (!show) return;
237
+ alt.setAttribute("aria-invalid", "true");
238
+ alt.setAttribute("aria-describedby", id);
239
+ alt.after(Object.assign(document.createElement("p"), { id, className: "text-xs font-semibold text-error", textContent: "Describe the picture for people who cannot see it." }));
240
+ }
241
+
242
+ /** The registered pictures' addresses by key, fetched once for the thumbnails. */
243
+ let known = null;
244
+ function pictures(url) {
245
+ known ??= fetch(url, { headers: { Accept: "application/json" }, credentials: "same-origin" })
246
+ .then((response) => (response.ok ? response.json() : { pictures: [] }))
247
+ .then((answer) => new Map(answer.pictures.map((picture) => [picture.key, picture.url])))
248
+ .catch(() => new Map());
249
+ return known;
250
+ }
251
+
252
+ /** Shows the thumbnail of the picture a card names, when it is a registered one. */
253
+ async function thumb(picture) {
254
+ const url = picture.querySelector("[data-action=pick-picture]")?.dataset.media;
255
+ const image = picture.querySelector("[data-picture-thumb]");
256
+ const key = picture.querySelector(`[name="${picture.dataset.key}"]`)?.value.trim();
257
+ if (!url || !image) return;
258
+ const found = key ? (await pictures(url)).get(key) : null;
259
+ image.hidden = !found;
260
+ if (found) {
261
+ image.addEventListener("error", () => { image.hidden = true; }, { once: true });
262
+ image.src = found;
263
+ }
264
+ }
265
+
266
+ /** Opens the picture picker for a card's picture and puts the choice in its key and alt text. */
267
+ async function pick(picture, list, afterwards) {
268
+ const button = picture.querySelector("[data-action=pick-picture]");
269
+ const key = picture.querySelector(`[name="${CSS.escape(picture.dataset.key)}"]`);
270
+ const alt = picture.querySelector(`[name="${CSS.escape(picture.dataset.alt)}"]`);
271
+ if (!loadPicker) return;
272
+ const { openPicker } = await loadPicker();
273
+ openPicker(picture, picture, {
274
+ url: button.dataset.media,
275
+ current: { mediaRef: key.value, alt: alt?.value ?? "" },
276
+ onPick: (chosen, text) => {
277
+ key.value = chosen;
278
+ if (alt) alt.value = text;
279
+ key.dispatchEvent(new Event("input", { bubbles: true }));
280
+ markAlt(picture, false);
281
+ known = null;
282
+ thumb(picture);
283
+ say(list, "Picture chosen.");
284
+ key.dispatchEvent(new Event("change", { bubbles: true }));
285
+ },
286
+ onClose: () => { button.focus(); afterwards(); },
287
+ });
288
+ }
289
+
290
+ function enhance(list) {
291
+ const form = list.closest("form");
292
+ let draft = null;
293
+ let asking = null;
294
+ const dialog = list.querySelector(":scope > [data-remove-dialog]");
295
+ const touch = () => (draft ?? formDrafts.get(form))?.touch();
296
+
297
+ const refresh = (card) => {
298
+ const line = card.querySelector(":scope > [data-card-head] [data-card-summary]");
299
+ if (line) line.textContent = summaryOf(card);
300
+ nameTools(card);
301
+ for (const parent of [card.parentElement?.closest("[data-card]")]) if (parent) refresh(parent);
302
+ };
303
+
304
+ const finish = (group, card, words) => {
305
+ renumber(group);
306
+ say(listOf(group), words);
307
+ touch();
308
+ return card;
309
+ };
310
+
311
+ const add = (group, kind) => {
312
+ const max = Number(group.dataset.max ?? 0);
313
+ const noun = group.dataset.noun;
314
+ if (max > 0 && cardsOf(group).length >= max) return say(list, `At most ${max} ${noun}s fit here.`);
315
+ const card = create(group, kind);
316
+ if (!card) return null;
317
+ renumber(group);
318
+ toggle(card, true);
319
+ refresh(card);
320
+ card.querySelector("input:not([type=hidden]), select, textarea")?.focus();
321
+ card.scrollIntoView({ block: "nearest" });
322
+ return finish(group, card, `Added ${titleOf(card)}. ${cardsOf(group).length} in the list.`);
323
+ };
324
+
325
+ const move = (card, step) => {
326
+ const group = groupOf(card);
327
+ const cards = cardsOf(group);
328
+ const at = cards.indexOf(card);
329
+ const to = at + step;
330
+ if (to < 0 || to >= cards.length) return say(list, `${titleOf(card)} is already ${step < 0 ? "first" : "last"}.`);
331
+ if (step < 0) cards[to].before(card);
332
+ else cards[to].after(card);
333
+ card.querySelector(`[data-action="${step < 0 ? "up" : "down"}"]`).focus();
334
+ finish(group, card, `Moved ${titleOf(card)} to position ${to + 1} of ${cards.length}.`);
335
+ };
336
+
337
+ const remove = (card) => {
338
+ const group = groupOf(card);
339
+ const cards = cardsOf(group);
340
+ const at = cards.indexOf(card);
341
+ const words = titleOf(card);
342
+ const next = cards[at + 1] ?? cards[at - 1];
343
+ card.remove();
344
+ renumber(group);
345
+ const target = next?.querySelector(':scope > [data-card-head] [data-action="toggle"]') ?? group.querySelector(":scope > [data-adder] select, :scope > [data-action=add]");
346
+ target?.focus();
347
+ finish(group, card, `Removed ${words}. ${cardsOf(group).length} left in the list.`);
348
+ };
349
+
350
+ list.addEventListener("click", (event) => {
351
+ const button = event.target.closest("[data-action]");
352
+ if (!button || listOf(button) !== list) return;
353
+ const card = button.closest("[data-card]");
354
+ const action = button.dataset.action;
355
+ if (action === "toggle") return toggle(card);
356
+ if (action === "up") return move(card, -1);
357
+ if (action === "down") return move(card, 1);
358
+ if (action === "pick-picture") return pick(button.closest("[data-picture]"), list, () => {});
359
+ if (action === "remove") {
360
+ asking = { card, opener: button };
361
+ dialog.querySelector("[data-remove-title]").textContent = `Remove ${titleOf(card)}?`;
362
+ return dialog.showModal();
363
+ }
364
+ if (action === "remove-cancel") return dialog.close();
365
+ if (action === "remove-confirm") {
366
+ const chosen = asking;
367
+ asking = null;
368
+ dialog.close();
369
+ return remove(chosen.card);
370
+ }
371
+ if (action === "add") {
372
+ const group = button.closest("[data-group]");
373
+ const kind = group.querySelector(":scope > [data-adder] [data-add-kind]")?.value ?? "";
374
+ add(group, kind);
375
+ }
376
+ });
377
+
378
+ form?.addEventListener("input", (event) => {
379
+ const card = event.target.closest?.("[data-card]");
380
+ if (card) refresh(card);
381
+ const picture = event.target.closest?.("[data-picture]");
382
+ if (picture && event.target.name === picture.dataset.key) thumb(picture);
383
+ touch();
384
+ });
385
+ form?.addEventListener("change", touch);
386
+ form?.addEventListener("editor:changed", (event) => {
387
+ const card = event.target.closest?.("[data-card]");
388
+ if (card) refresh(card);
389
+ touch();
390
+ });
391
+ form?.addEventListener("focusout", (event) => {
392
+ const picture = event.target.closest?.("[data-picture]");
393
+ if (picture && event.target.name === picture.dataset.alt) markAlt(picture, undescribed(picture));
394
+ (draft ?? formDrafts.get(form))?.blur();
395
+ });
396
+
397
+ dialog.addEventListener("close", () => {
398
+ if (asking) asking.opener.focus();
399
+ asking = null;
400
+ });
401
+ for (const card of cardsOf(list)) dress(card);
402
+ for (const node of list.querySelectorAll(":scope > [data-adder]")) node.hidden = false;
403
+ for (const group of [list, ...list.querySelectorAll("[data-group]")]) renumber(group);
404
+ for (const card of list.querySelectorAll("[data-card]")) refresh(card);
405
+ for (const picture of list.querySelectorAll("[data-picture]")) thumb(picture);
406
+ for (const open of list.querySelectorAll("[data-card][data-invalid] > [data-card-panel], [data-card][data-open] > [data-card-panel]")) mountEditors(open);
407
+
408
+ if (list.hasAttribute("data-draft")) {
409
+ draft = startDraft(list, {
410
+ form,
411
+ blocked: () => {
412
+ const first = incomplete(list)[0];
413
+ return first ? `${titleOf(first)} needs more before it can be saved. Complete it, or use Save draft to see what the domain says.` : null;
414
+ },
415
+ refused: (problems) => reveal(list, problems),
416
+ });
417
+ }
418
+ }
419
+
420
+ /** Opens the cards a refusal names, and the cards that hold them, so what is wrong can be seen. */
421
+ function reveal(scope, problems) {
422
+ for (const problem of problems) {
423
+ let card = problem.name ? scope.querySelector(`#blk-${safe(problem.name)}`) : null;
424
+ while (card) {
425
+ toggle(card, true);
426
+ card = card.parentElement?.closest("[data-card]");
427
+ }
428
+ }
429
+ }
430
+
431
+ /** A form whose working copy is a value object saves as a whole, whichever part of it is touched. */
432
+ function enhanceForm(form) {
433
+ const draft = startDraft(form, {
434
+ form,
435
+ blocked: () => {
436
+ const first = incomplete(form)[0];
437
+ if (first) return `${titleOf(first)} needs more before it can be saved. Complete it, remove it, or use Save draft to see what the domain says.`;
438
+ return loose(form).some(undescribed) ? "A picture needs its description before it can be saved." : null;
439
+ },
440
+ refused: (problems) => reveal(form, problems),
441
+ });
442
+ formDrafts.set(form, draft);
443
+ form.addEventListener("input", () => draft.touch());
444
+ form.addEventListener("change", () => draft.touch());
445
+ form.addEventListener("editor:changed", () => draft.touch());
446
+ form.addEventListener("focusout", () => draft.blur());
447
+ }
448
+
449
+ /**
450
+ * A form is not sent while a picture names its file and has no description: the group says so beside
451
+ * the field, the card that holds it opens, and focus goes there. (The domain refuses the same, in its
452
+ * own words, when this is not in the way, as for a request that does not come from this page.)
453
+ */
454
+ function guardPictures(form) {
455
+ form.addEventListener("submit", (event) => {
456
+ const missing = [...form.querySelectorAll("[data-picture]")].filter(undescribed);
457
+ if (missing.length === 0) return;
458
+ event.preventDefault();
459
+ event.stopImmediatePropagation();
460
+ for (const picture of missing) {
461
+ let card = picture.closest("[data-card]");
462
+ while (card) {
463
+ toggle(card, true);
464
+ card = card.parentElement?.closest("[data-card]");
465
+ }
466
+ markAlt(picture, true);
467
+ }
468
+ const first = missing[0].querySelector(`[name="${missing[0].dataset.alt}"]`);
469
+ first?.scrollIntoView({ block: "center" });
470
+ first?.focus({ preventScroll: true });
471
+ say(form, "A picture needs its description before it can be saved.");
472
+ }, true);
473
+ }
474
+
475
+ /** A picture that is no card's (a slot of a value object) opens the picker from its own group. */
476
+ function enhanceLoose(form) {
477
+ const own = (picture) => picture && !picture.closest("[data-card]");
478
+ if (loose(form).length === 0) return;
479
+ for (const picture of loose(form)) thumb(picture);
480
+ form.addEventListener("click", (event) => {
481
+ const picture = event.target.closest?.("[data-action=pick-picture]")?.closest("[data-picture]");
482
+ if (own(picture)) pick(picture, form, () => {});
483
+ });
484
+ form.addEventListener("input", (event) => {
485
+ const picture = event.target.closest?.("[data-picture]");
486
+ if (own(picture) && event.target.name === picture.dataset.key) thumb(picture);
487
+ });
488
+ form.addEventListener("focusout", (event) => {
489
+ const picture = event.target.closest?.("[data-picture]");
490
+ if (own(picture) && event.target.name === picture.dataset.alt) markAlt(picture, undescribed(picture));
491
+ });
492
+ }
493
+
494
+ export function enhanceBlocks() {
495
+ for (const form of document.querySelectorAll("form[data-draft-form]")) enhanceForm(form);
496
+ for (const list of document.querySelectorAll("[data-block-list]")) enhance(list);
497
+ for (const form of document.querySelectorAll("form")) enhanceLoose(form);
498
+ for (const form of document.querySelectorAll("form[data-form]")) guardPictures(form);
499
+ if (document.querySelector("[data-card][data-open], [data-card][data-invalid]")) firstProblem();
500
+ }
@@ -0,0 +1,175 @@
1
+ __BANNER__
2
+
3
+ // The domain's document body and the rich-text editor's document, converted one into the other. This
4
+ // is the only place the two meet: the editor (body_editor.js) holds a Tiptap document while a person
5
+ // writes, and every change is turned back into the body the domain takes and posted as the same
6
+ // dotted-path form fields as any other value object. The editor's own format is never sent or stored.
7
+ //
8
+ // Plain JavaScript with no dependencies and no DOM, so the conversion runs the same in node (where
9
+ // the specs hold it) and in the browser. The two models agree on lists because the editor's lists are
10
+ // flat: each item is a block of its own with an `indent`, as a body's list item has a `depth`, so an
11
+ // item converts to an item. A body list block becomes a run of items, and a run of adjacent items
12
+ // with the same alignment and shift becomes one list block again (its kind is that of its first
13
+ // item, and an item of the other kind carries `list_kind`). Where the editor's document holds
14
+ // something the body cannot, it is reduced to the nearest thing the body holds.
15
+ import { ALIGNS, MARKS, MAX_DEPTH, MAX_INDENT, safeHref } from "../ui/body_model.js";
16
+
17
+ /** The editor's list item node for each kind of body list, and back. */
18
+ const ITEMS = { bullet_list: "flatListItemUnordered", numbered_list: "flatListItemOrdered" };
19
+ const KINDS = { flatListItemUnordered: "bullet_list", flatListItemOrdered: "numbered_list" };
20
+
21
+ const whole = (value, low, high) => (Number.isInteger(Number(value)) && value !== "" && value !== null && value !== undefined ? Math.min(high, Math.max(low, Number(value))) : null);
22
+
23
+ const list = (value) => (Array.isArray(value) ? value : []);
24
+
25
+ const markName = (mark) => (typeof mark === "string" ? mark : mark?.name);
26
+
27
+ /** The layout attributes of a block, as the editor's nodes carry them (`shift` is the block's indent). */
28
+ function layoutAttrs(block) {
29
+ return { align: ALIGNS.includes(block.align) ? block.align : null, shift: whole(block.indent, 0, MAX_INDENT) };
30
+ }
31
+
32
+ /** The inline nodes of `spans`: text carrying its marks and address, and a hard break for a line break. */
33
+ export function spansToInline(spans) {
34
+ const out = [];
35
+ for (const span of list(spans)) {
36
+ const names = MARKS.filter((name) => list(span?.marks).map(markName).includes(name));
37
+ const href = safeHref(span?.href);
38
+ const marks = [...names.map((type) => ({ type })), ...(href ? [{ type: "link", attrs: { href } }] : [])];
39
+ String(span?.text ?? "").split("\n").forEach((part, at) => {
40
+ if (at > 0) out.push({ type: "hardBreak" });
41
+ if (part !== "") out.push({ type: "text", text: part, ...(marks.length ? { marks } : {}) });
42
+ });
43
+ }
44
+ return out;
45
+ }
46
+
47
+ const paragraph = (spans, attrs = {}) => ({ type: "paragraph", attrs, content: spansToInline(spans) });
48
+
49
+ /** The items of a body list block as flat items, numbering ordered ones as the editor does. */
50
+ function itemsToNodes(block) {
51
+ const kind = block.kind;
52
+ const layout = layoutAttrs(block);
53
+ const counts = [];
54
+ let above = -1;
55
+ return list(block.items).map((item) => {
56
+ const depth = Math.min(whole(item?.depth, 0, MAX_DEPTH) ?? 0, above + 1);
57
+ above = depth;
58
+ counts.length = depth + 1;
59
+ const own = item?.list_kind in ITEMS ? item.list_kind : kind;
60
+ counts[depth] = own === "numbered_list" ? (counts[depth] ?? 0) + 1 : 0;
61
+ const attrs = { ...layout, ...(depth > 0 ? { indent: depth } : {}), ...(own === "numbered_list" ? { counter: counts[depth] } : {}) };
62
+ return { type: ITEMS[own], attrs, content: spansToInline(item?.spans) };
63
+ });
64
+ }
65
+
66
+ function blockToNodes(block) {
67
+ const kind = block?.kind;
68
+ if (kind === "divider") return [{ type: "horizontalRule" }];
69
+ if (kind === "image") return [{ type: "image", attrs: { mediaRef: block.media_ref ?? "", alt: block.alt ?? "", caption: block.caption ?? "" } }];
70
+ if (kind in ITEMS) return itemsToNodes(block);
71
+ const layout = layoutAttrs(block ?? {});
72
+ if (kind === "quote") return [{ type: "blockquote", attrs: layout, content: [paragraph(block.spans)] }];
73
+ if (kind === "heading") return [{ type: "heading", attrs: { level: whole(block.level, 1, 4) ?? 2, ...layout }, content: spansToInline(block.spans) }];
74
+ return [paragraph(block?.spans, layout)];
75
+ }
76
+
77
+ /** The editor's document for a body: a body with no blocks is one empty paragraph, as the editor needs. */
78
+ export function bodyToDoc(body) {
79
+ const content = list(body?.blocks).flatMap(blockToNodes);
80
+ return { type: "doc", content: content.length ? content : [{ type: "paragraph", attrs: {} }] };
81
+ }
82
+
83
+ /** The mark names of an inline node, in the body's own order, and its address. */
84
+ function marksOf(node) {
85
+ const held = list(node.marks);
86
+ const names = MARKS.filter((name) => held.some((mark) => mark.type === name));
87
+ const link = held.find((mark) => mark.type === "link");
88
+ return { marks: names.map((name) => ({ name })), href: safeHref(link?.attrs?.href) ?? undefined };
89
+ }
90
+
91
+ /** The spans of inline nodes: adjacent spans of the same marks and address become one, empty ones go. */
92
+ export function inlineToSpans(nodes) {
93
+ const out = [];
94
+ for (const node of list(nodes)) {
95
+ const span = node.type === "hardBreak" ? { text: "\n", marks: [] } : node.type === "text" ? { text: node.text ?? "", ...marksOf(node) } : null;
96
+ if (!span || span.text === "") continue;
97
+ const last = out[out.length - 1];
98
+ const same = last && last.text !== "\n" && span.text !== "\n" && last.href === span.href && JSON.stringify(last.marks) === JSON.stringify(span.marks);
99
+ if (same) last.text += span.text;
100
+ else out.push({ text: span.text, marks: span.marks, ...(span.href ? { href: span.href } : {}) });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ const withLayout = (node) => {
106
+ const attrs = node.attrs ?? {};
107
+ const shift = whole(attrs.shift, 0, MAX_INDENT);
108
+ return { ...(ALIGNS.includes(attrs.align) ? { align: attrs.align } : {}), ...(shift !== null && shift > 0 ? { indent: shift } : {}) };
109
+ };
110
+
111
+ const layoutKey = (node) => JSON.stringify(withLayout(node));
112
+
113
+ /** Groups the run of flat items that starts `nodes[from]` into one list block; returns it and where it ends. */
114
+ function listBlock(nodes, from) {
115
+ const first = nodes[from];
116
+ const kind = KINDS[first.type];
117
+ const items = [];
118
+ let at = from;
119
+ while (at < nodes.length && nodes[at].type in KINDS && layoutKey(nodes[at]) === layoutKey(first)) {
120
+ const node = nodes[at];
121
+ const above = items.length === 0 ? -1 : items[items.length - 1].depth;
122
+ const depth = Math.min(whole(node.attrs?.indent, 0, MAX_DEPTH) ?? 0, above + 1);
123
+ items.push({ spans: inlineToSpans(node.content), depth, ...(KINDS[node.type] !== kind ? { list_kind: KINDS[node.type] } : {}) });
124
+ at += 1;
125
+ }
126
+ return [{ kind, ...withLayout(first), items }, at];
127
+ }
128
+
129
+ function blockFromNode(node) {
130
+ switch (node.type) {
131
+ case "horizontalRule":
132
+ return [{ kind: "divider" }];
133
+ case "image":
134
+ return [{ kind: "image", ...(node.attrs?.mediaRef ? { media_ref: node.attrs.mediaRef } : {}), ...(node.attrs?.alt ? { alt: node.attrs.alt } : {}), ...(node.attrs?.caption ? { caption: node.attrs.caption } : {}) }];
135
+ case "heading":
136
+ return [{ kind: "heading", level: whole(node.attrs?.level, 1, 4) ?? 2, ...withLayout(node), spans: inlineToSpans(node.content) }];
137
+ case "blockquote":
138
+ return list(node.content).filter((child) => child.type === "paragraph").map((child) => ({ kind: "quote", ...withLayout(node), spans: inlineToSpans(child.content) }));
139
+ default:
140
+ return [{ kind: "paragraph", ...withLayout(node), spans: inlineToSpans(node.content) }];
141
+ }
142
+ }
143
+
144
+ const emptyParagraph = (block) => block.kind === "paragraph" && block.spans.length === 0 && !("align" in block) && !("indent" in block);
145
+
146
+ /** The body for an editor document. Trailing empty paragraphs, which the editor keeps for typing, are not blocks. */
147
+ export function docToBody(doc) {
148
+ const nodes = list(doc?.content);
149
+ const blocks = [];
150
+ for (let at = 0; at < nodes.length;) {
151
+ if (nodes[at].type in KINDS) {
152
+ const [made, next] = listBlock(nodes, at);
153
+ blocks.push(made);
154
+ at = next;
155
+ } else {
156
+ blocks.push(...blockFromNode(nodes[at]));
157
+ at += 1;
158
+ }
159
+ }
160
+ while (blocks.length > 0 && emptyParagraph(blocks[blocks.length - 1])) blocks.pop();
161
+ return { blocks };
162
+ }
163
+
164
+ /**
165
+ * What loading a body into the editor changes, so the editor can say so: a list item more than one
166
+ * level below the one above it is moved up, because the editor's lists cannot skip a level.
167
+ */
168
+ export function reductions(body) {
169
+ const notes = [];
170
+ for (const block of list(body?.blocks)) {
171
+ const depths = list(block.items).map((item) => whole(item?.depth, 0, MAX_DEPTH) ?? 0);
172
+ if (depths.some((depth, at) => depth > (at === 0 ? 0 : depths[at - 1] + 1))) notes.push("a list item more than one level below the one above it was moved up to one level below");
173
+ }
174
+ return notes;
175
+ }