Everything the editor renders, derived from {document, palette, findings} (ADR-0005 decisions 9, 10, 11, 12, 13).
This is the load-bearing module decision 13 names: it is where
resolution, migration, validation, and palette_entry/0 lookup happen,
and it produces a structure in which every block already carries its
declared slots, its form fields, its presentation metadata, and its
findings routed to the position that renders them. It lives outside
StatifierBlocks.Editor.* despite being an editor concern, and it names
no LiveView module - the components downstream of it are close to
mechanical, reading a view model and emitting markup with no palette
lookups and no callback invocations of their own.
Two derived finding sources, and no compiler adapter
build/3's third argument is a caller-supplied [StatifierBlocks.Finding.t()].
This module derives exactly two sources of its own, because decision 13
puts resolution, migration and validation inside ViewModel rather than
upstream of it:
:resolution-Palette.resolve/2failing on a block (:unknown_block_type,:block_type_too_new,:migration_failed), anchored{:block, id}, severity:error.:config-validate_config/1on a resolved block, one per{key, message}pair, anchored{:config, id, key}, severity:error.
:arity, :assignability and :lint findings are never produced here;
their producers live elsewhere - sb-da9 (palette-aware arity and
undeclared-slot checks, not yet built), Assignability.validate/3, and
the compiler's invoke-type lint respectively - and this module does not
adapt StatifierBlocks.Compiler.Finding into StatifierBlocks.Finding
to manufacture them. That adapter is a real, mechanical possibility
(Compiler.Finding carries block_id and config_key, which map onto
{:config, id, key} / {:block, id} cleanly) and it is deliberately not
built in this bead - see the plan's Open Questions for where it belongs
instead. Derived and caller-supplied findings are concatenated - derived
first - into one list, which is both t().findings and the
document-level panel's source.
Routing, and the case that must not vanish
Each finding in the concatenated list is placed by its anchor:
| Anchor | Position in the view model |
|---|---|
| any anchor naming a block id not in the document | t().orphan_findings |
{:block, id} | that node's findings |
{:slot, id, name} | that slot's findings (a slot name the node does not carry falls back to the node's findings) |
{:config, id, key}, key matches a config_schema/1 field | that field's findings |
{:config, id, key}, key matches no field | that node's form.unrouted |
The fourth row exists because Core.Branch.config_schema/1 keys one
field per arm by the arm's own slot name, but validate_config/1 also
emits findings keyed "arms" - a key that matches no field, because
adding or removing an arm is a document edit, not a form value
(core/branch.ex). form.unrouted is rendered at the head of the
config form for exactly this case, and an unresolvable node - which has
no form at all - folds the same case into its own findings instead of
discarding it.
No route drops a finding: every arm of the table above lands somewhere,
and t().findings_count accumulates the same placements. A node's
findings_count covers its whole subtree - its own findings, its slots'
findings, its form's field and unrouted findings, plus every child's own
findings_count - so a collapsed subtree can carry a count badge
(decision 11's last sentence) without walking back down into it.
d10's defaults
palette_entry/0 is optional, and every one of its eight keys has a
default (decision 10) so a block type that implements none of it still
renders: label defaults to the type name, group to "Other",
description to "", icon to nil, keywords to [], order to
0, layout to :stack, slot_style to %{}.
d12: unresolvable nodes
A block whose type does not resolve renders with its type name, a
status: {:unresolvable, reason} carrying Palette.resolve/2's own
error term, form: nil, its config as canonical-JSON text in
raw_config_json (there is no config_schema/1 to drive a form and
inventing one would be guessing), a :resolution finding, and its
existing children rendered normally, recursively with raw slot names -
the document's slots map preserved every one of them, decoding never
having consulted a registry.
d9: the form is a projection, never cached
A resolved node's form.fields come from module.config_schema/1
called against the block's current config, every time build/3
runs. Nothing here memoizes a schema across an edit: a branch that gains
an arm gains a field the very next time build/3 is called, because the
schema is a function of config (ADR-0002 decision 7), not a cache of one.
Summary
Functions
Builds the view model. Derives :resolution and :config findings from
{document, palette}, concatenates findings after them, routes every
one of the combined list per the moduledoc's table, and groups
palette's types into palette_groups.
Types
@type ctx() :: {StatifierBlocks.Palette.t(), %{optional(StatifierBlocks.Block.id()) => [StatifierBlocks.Finding.t()]}}
Threaded through the recursive walk instead of two positional arguments.
@type t() :: %StatifierBlocks.ViewModel{ document_id: StatifierBlocks.Document.id(), findings: [StatifierBlocks.Finding.t()], orphan_findings: [StatifierBlocks.Finding.t()], palette_groups: [StatifierBlocks.ViewModel.PaletteGroup.t()], revision: non_neg_integer(), root: StatifierBlocks.ViewModel.Node.t() }
Functions
@spec build(StatifierBlocks.Document.t(), StatifierBlocks.Palette.t(), [ StatifierBlocks.Finding.t() ]) :: t()
Builds the view model. Derives :resolution and :config findings from
{document, palette}, concatenates findings after them, routes every
one of the combined list per the moduledoc's table, and groups
palette's types into palette_groups.