MishkaCmsCore.Runtime.PageBuilder.PageForms.Naming (Mishka CMS Core v0.0.1)

Copy Markdown View Source

One seed per form, and the five names derived from it.

derivedfrom seed contactwith token k7m2q
DOM idcontact-formcontact-form
param nameas={:contact}as={:contact}
page assignfor={assigns[:contact_form]}the same
change eventcontact_validatecontact-k7m2q-validate
submit eventcontact_submitcontact-k7m2q-submit

Tokens

A form carries a random 5-character token in both event names. A rename keeps it, so a handler stored under the old event name is still found by token and kind (event_key/1). A form arriving in the tree gets a token when it has none or shares one, and any form whose token an earlier form already holds gets a fresh one (dedupe/2). A form without a token keeps the untagged names until it is renamed or placed again; a markup edit of an ancestor is neither (dedupe/3).

There is no control anywhere in the editor that edits one of the five alone, so they cannot disagree: the Inspector's Form name control writes attrs/2 in one batch, and every other route into the tree goes through dedupe/1. Two forms on one page are therefore impossible to collide rather than merely reported as colliding.

Everything here is a string

A seed is a binary, a field key is a binary, and nothing in this module calls String.to_atom/1. What the editor is producing is SOURCE TEXT — as={:contact} and field={f[:email]} are markup, and the atom in them is a literal the page compiler interns once, not a value this module needs. Minting an atom per label typed into a text box would leak the atom table for no gain, so a caller writing a key into markup interpolates it as ":" <> key.

The param name is written as as={:contact} rather than as="contact" because that is what a hand-written LiveView form carries, and it is what Phoenix.Component.form/1 documents; MishkaCmsCore.Runtime.PageBuilder.PageForms.param_name/1 reads both forms back.

Why a seed is re-minted on the way out

attrs/1 slugs whatever it is handed. A seed that reached the tree some other way — hand-typed into the code drawer, pasted from another page — could otherwise produce as={:2 items}, which is not an atom literal and does not compile, or for={assigns[:my form_form]}, which is not an assign. Slugging is idempotent, so a seed that is already legal survives untouched.

A seed already in the page is minted the same way before it is compared, so what a rename writes is measured against what every other form would be called — as={:"my form"} cannot be renumbered into a name a second form is already using.

Why renumbering runs at the op layer

dedupe/2 is applied to the whole tree after a mutating op rather than at one insert route, because paste (kind: :insert, built by MishkaCmsCore.Runtime.PageBuilder.EditorSession.paste/2) and duplicate (kind: :duplicate) never pass through the insert funnel — and "duplicate this form" is how an author actually makes the second one. MishkaCmsCore.Runtime.PageBuilder.Tree.duplicate/2 re-mints NODE ids and copies every attribute verbatim, so the copy arrives carrying the original's seed in all five places.

rekey/2 is the same rule one level down: a duplicated FIELD arrives carrying the key its original owns, and several inputs bound to one key overwrite each other on submit. Both take the ids the op just created, and in both the arrival is what gets renamed — see dedupe/2 for what renaming the wrong one costs.

Gaps are filled, not skipped

A page whose forms are contact and contact_3 mints contact_2 next. Numbering is a search for the lowest free member of a family, not a counter, so deleting the second of three forms does not push the next one to contact_4.

See also MishkaCmsCore.Runtime.PageBuilder.PageForms for what declares a form in the first place.

Summary

Functions

The five attributes a form node carries with the untagged event names, in the order a reader expects them, ready for MishkaCmsCore.Runtime.PageBuilder.Tree.set_attr/4.

The five attributes with token inside both event names: contact-k7m2q-validate and contact-k7m2q-submit. A nil token writes the untagged names, as attrs/1 does.

Renumbers every form that shares a seed with one that claimed it first, rewriting all five derived names in one pass.

Which attributes a rename touches — read by the Inspector so it can batch them as one op.

One event name: contact-k7m2q-submit with a token, contact_submit with nil.

{token, kind} of a tagged event name, or nil for any other name.

A field key minted from a label, unique within its form.

The token a form carries in its phx-submit, else in its phx-change, or nil.

The form's own token when no other form in tree carries it; otherwise, or when it has none, a fresh one. What a renumbered form is written with.

people_name/1 for a name an author typed rather than a seed: "Contáct Us" is "Contact us", and a name that leaves no seed is "Form 1".

A fresh token no event name in tree carries: 5 characters of a-z2-7.

attrs/2 for the form node, and its phx-trigger-action under the new seed when it sends to another address (PageForms.sends_elsewhere?/1), so a rename or a renumber keeps that working. What every writer of the five names writes.

The name of form number number, as a person reads it: "Form 3".

The name a site admin reads for a form's seed: form is "Form 1", form_3 is "Form 3", and any other seed is its words with a capital ("contact_us" is "Contact us"). nil for nil.

Re-keys a field that arrived carrying a key the form it landed in already uses.

The event attributes of node rewritten to carry token, ready for MishkaCmsCore.Runtime.PageBuilder.Tree.set_attr/4.

Whether two event names carry the same token and the same kind, whatever their seeds.

Whether two event names carry the same token, so they belong to the same form.

The seed a label mints: lowercased, accents folded away, every other run of characters turned into a single _, and never starting with a digit.

Every form already in this tree, as {node id, seed} in document order.

The token inside a tagged event name, or nil.

The phx-trigger-action a form sending to another address carries: the page assign MishkaCmsCore.Runtime.PageForm.submit/5 sets once its answers are checked, assigns[:contact_send] for the seed contact.

A seed based on base that no form in tree is using.

Functions

attrs(name)

@spec attrs(String.t() | nil) :: [{String.t(), {:string | :expr, String.t()}}]

The five attributes a form node carries with the untagged event names, in the order a reader expects them, ready for MishkaCmsCore.Runtime.PageBuilder.Tree.set_attr/4.

attrs(name, token)

@spec attrs(String.t() | nil, String.t() | nil) :: [
  {String.t(), {:string | :expr, String.t()}}
]

The five attributes with token inside both event names: contact-k7m2q-validate and contact-k7m2q-submit. A nil token writes the untagged names, as attrs/1 does.

dedupe(tree, arrived \\ [], carried \\ [])

Renumbers every form that shares a seed with one that claimed it first, rewriting all five derived names in one pass.

arrived is the node ids the op just created, and THEY ARE THE ONES THAT YIELD. Claiming in document order renamed whichever form came first, so dropping a second Contact form ABOVE the first rewrote the form already on the page — its param name, its page assign and both event names — and the contact_submit clause its author had written on the Events tab was left bound to nothing while the page went on compiling. A form nobody has typed a handler for yet is the only one that can be renamed for free.

With no arrivals named, document order decides, which is what a whole document arriving at once (MishkaCmsCore.Runtime.PageBuilder.EditorSession.put_tree/3) wants: there is no incumbent there.

Tokens follow the same order. A renumbered form keeps its token unless an earlier form holds it, and gets a fresh one otherwise. A form whose token an earlier form holds gets a fresh one. An arriving form with no token gets one (retokened/2). An incumbent form with no token is left alone.

carried is the event names the page's forms carried before the change. An arriving form with no token that answers one of them is the same form parsed again (a markup edit of an ancestor), so it keeps its untagged names unless its seed is taken.

Answers the tree it was given when nothing needs a new name or token.

derived()

@spec derived() :: [String.t()]

Which attributes a rename touches — read by the Inspector so it can batch them as one op.

event(seed, token, kind)

@spec event(String.t(), String.t() | nil, :validate | :submit) :: String.t()

One event name: contact-k7m2q-submit with a token, contact_submit with nil.

event_key(name)

@spec event_key(term()) :: {String.t(), :validate | :submit} | nil

{token, kind} of a tagged event name, or nil for any other name.

field_key(label, taken)

@spec field_key(String.t() | nil, [atom() | String.t()]) :: String.t()

A field key minted from a label, unique within its form.

taken is the keys already bound in that form, as atoms or as binaries — the parsed side of the editor reads them out of f[:email] expressions as atoms, and this compares them as text.

form_token(node)

@spec form_token(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil) ::
  String.t() | nil

The token a form carries in its phx-submit, else in its phx-change, or nil.

kept_token(tree, node)

The form's own token when no other form in tree carries it; otherwise, or when it has none, a fresh one. What a renumbered form is written with.

label(name)

@spec label(String.t() | nil) :: String.t()

people_name/1 for a name an author typed rather than a seed: "Contáct Us" is "Contact us", and a name that leaves no seed is "Form 1".

mint_token(tree)

A fresh token no event name in tree carries: 5 characters of a-z2-7.

names_for(node, name, token)

@spec names_for(
  MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(),
  String.t() | nil,
  String.t() | nil
) ::
  [{String.t(), {:string | :expr, String.t()}}]

attrs/2 for the form node, and its phx-trigger-action under the new seed when it sends to another address (PageForms.sends_elsewhere?/1), so a rename or a renumber keeps that working. What every writer of the five names writes.

numbered_name(number)

@spec numbered_name(pos_integer() | String.t()) :: String.t()

The name of form number number, as a person reads it: "Form 3".

people_name(seed)

@spec people_name(String.t() | nil) :: String.t() | nil

The name a site admin reads for a form's seed: form is "Form 1", form_3 is "Form 3", and any other seed is its words with a capital ("contact_us" is "Contact us"). nil for nil.

rekey(tree, arrived)

Re-keys a field that arrived carrying a key the form it landed in already uses.

The same rule forms get, applied to the keys inside one. MishkaCmsCore.Runtime.PageBuilder.Tree copies every attribute verbatim on a duplicate or a paste, so duplicating a field gave several inputs one key: all of them render name="contact[email]", typing in one shows that value in all of them, only the last one submitted survives, and the spec carries a single field — with nothing in the audit rail to say so. Duplicating a field is how a non-programmer builds the second one.

Every node bound to the colliding key moves together, because a label and its input are one field and re-keying the input alone would point the label at a field that no longer exists.

A whole form arriving is not a collision. Each form scopes its own params, so the copy keeps every key it had and only dedupe/2 renames it.

retokened(node, token)

@spec retokened(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), String.t()) :: [
  {String.t(), {:string, String.t()}}
]

The event attributes of node rewritten to carry token, ready for MishkaCmsCore.Runtime.PageBuilder.Tree.set_attr/4.

Only names this module writes change: a tagged name keeps its own seed and kind, and an untagged <seed>_validate/<seed>_submit of the form's own seed gets the token. Any other name is a developer's and is left out.

same_event?(a, b)

@spec same_event?(term(), term()) :: boolean()

Whether two event names carry the same token and the same kind, whatever their seeds.

same_form?(a, b)

@spec same_form?(term(), term()) :: boolean()

Whether two event names carry the same token, so they belong to the same form.

seed(label)

@spec seed(String.t() | nil) :: String.t()

The seed a label mints: lowercased, accents folded away, every other run of characters turned into a single _, and never starting with a digit.

A label that leaves nothing usable — blank, punctuation only, or written in a script that has no ASCII form — falls back to form, because every one of the five derived names has to exist.

seeds(tree)

Every form already in this tree, as {node id, seed} in document order.

The seeds are PARSED off the nodes rather than counted, so a page whose only form is contact_2 answers contact_2 and the next one minted is not the same name again.

A node is a form when MishkaCmsCore.Runtime.PageBuilder.PageForms.form?/1 says so — it holds the page assign its values live in. Asking only for an as would take as at its HTML word, and as is an ordinary attribute of an ordinary tag:

<link rel="preload" as="font" href="/inter.woff2" />

Two of those on a page read as two forms both seeded font, and dedupe/1 renames the second by writing five form attributes onto a <link> — breaking the preload, minting a form nobody placed, and raising a duplicate-seed blocker about it. It runs on every insert, duplicate, wrap AND on every save, draft restore and history revert, so the wreckage is what gets stored.

token(name)

@spec token(term()) :: String.t() | nil

The token inside a tagged event name, or nil.

trigger(name)

@spec trigger(String.t() | nil) :: {String.t(), {:expr, String.t()}}

The phx-trigger-action a form sending to another address carries: the page assign MishkaCmsCore.Runtime.PageForm.submit/5 sets once its answers are checked, assigns[:contact_send] for the seed contact.

unique(tree, base)

A seed based on base that no form in tree is using.

base may be an author's label; it is minted through seed/1 first, so the answer is always a legal name.