SignalMesh / editor /builder.py
KyklosIO's picture
Claude Opus 5
feat(vocabulary): record the element names that are proven, name none that are not
113e711
Raw History Blame Contribute Delete
8.87 kB
"""Building new YOOtheme layout, rather than only editing what is already there.
The editor could read a layout, rewrite any prop and move whole sections, but it
could not *make* anything: a new section was a clone of an existing one, and
there was no way to add an element at all. This is the missing half.
Two sources of truth, neither of them invented here:
* the element shapes are YOOtheme's own — `type` plus a `props` dict, sections
holding rows holding columns holding elements. Get the nesting wrong and the
builder renders an empty band, so `place` refuses it rather than writing it.
* the presets are real sections lifted whole from sites that shipped. A preset
beats a constructor for anything with more than a couple of elements in it,
which is why the catalogue leads with them.
Everything returns a plain node. Nothing here writes; the caller splices and
saves through the one write path, same as every other edit.
"""
import json
from pathlib import Path
import vocabulary
PRESETS = Path(__file__).parent / "presets"
# What may contain what. The builder's own contract, and the reason a section
# full of elements with no column renders as nothing at all.
CONTAINS = {
"layout": {"section"},
"section": {"row"},
"row": {"column"},
# A column takes any element, and also a row — nested grids are ordinary
# YOOtheme, which the shipped hero presets use for their button rows. What it
# cannot hold is a section or another column.
"column": None,
}
NOT_IN_COLUMN = {"section", "column", "layout"}
WIDTHS = {1: ["1-1"], 2: ["1-2", "1-2"], 3: ["1-3", "1-3", "1-3"],
4: ["1-4", "1-4", "1-4", "1-4"]}
def section(props=None, children=None):
return {"type": "section", "props": props or {}, "children": children or []}
def row(props=None, children=None):
return {"type": "row", "props": props or {"gutter": "medium"}, "children": children or []}
def column(width="1-1", props=None, children=None):
props = dict(props or {})
props.setdefault("width_default", width)
return {"type": "column", "props": props, "children": children or []}
def headline(content, element="h2", style="heading-large"):
return {"type": "headline", "props": {"content": content,
"title_element": element,
"title_style": style}}
def text(content, cls=""):
props = {"content": content}
if cls:
props["class"] = cls
return {"type": "text", "props": props}
def button(label, link="#", style="primary"):
return {"type": "button", "props": {"content": label, "link": link,
"button_style": style}}
def image(src, alt=""):
return {"type": "image", "props": {"image": src, "image_alt": alt}}
def panel(title, content, style="card-default"):
return {"type": "panel", "props": {"title": title, "content": content,
"panel_style": style, "hover": True}}
def divider():
return {"type": "divider", "props": {}}
ELEMENTS = {"headline": headline, "text": text, "button": button,
"image": image, "panel": panel, "divider": divider}
def grid(columns, gutter="medium", widths=None):
"""A row of columns from a list of children-lists. Widths default to even."""
widths = widths or WIDTHS.get(len(columns), ["1-1"] * len(columns))
return row({"gutter": gutter},
[column(widths[i] if i < len(widths) else "1-1", children=kids)
for i, kids in enumerate(columns)])
def element(kind, **props):
"""One element by name, with the constructor's own defaults."""
if kind not in ELEMENTS:
raise ValueError(f"unknown element {kind!r} — have: {', '.join(sorted(ELEMENTS))}")
make = ELEMENTS[kind]
try:
return make(**props)
except TypeError as e:
raise ValueError(f"{kind}: {e}") from None
# ── presets ───────────────────────────────────────────────────────────────────
def _manifest():
path = PRESETS / "presets.manifest.json"
if not path.exists():
return {}
try:
return json.loads(path.read_text())
except (ValueError, OSError):
return {}
def catalogue():
"""Everything that can be built, described. This is what a builder needs to
know before it starts, and it is generated from what is actually on disk —
a preset listed but missing is worse than one that was never offered.
`constructors` are what this module can build directly. `known` is every
element proven by a real layout, with its real prop keys, which `edit_page`
can write even where there is no constructor. `documented` is named by
YOOtheme but never yet seen, so its slug is unverified — look for it, do not
assume it."""
man = _manifest()
out = {"elements": sorted(ELEMENTS),
"constructors": sorted(ELEMENTS),
"known": {k: vocabulary.props(k) for k in vocabulary.confirmed()},
"documented": sorted(vocabulary.documented()),
"sections": [], "templates": []}
for group, key in (("sections", "sections"), ("templates", "full_templates")):
for entry in man.get(key) or []:
if (PRESETS / entry.get("file", "")).exists():
out[group].append({"name": entry.get("name"),
"category": entry.get("category", ""),
"description": entry.get("description", "")})
return out
def preset(name):
"""A preset section, fresh each time — callers mutate what they get back."""
man = _manifest()
for key in ("sections", "full_templates"):
for entry in man.get(key) or []:
if entry.get("name") == name:
path = PRESETS / entry["file"]
if not path.exists():
raise KeyError(f"preset {name!r} is listed but its file is missing")
return json.loads(path.read_text())
raise KeyError(f"no preset {name!r}")
# ── placement ─────────────────────────────────────────────────────────────────
def check(parent, node):
"""Whether `node` may live inside `parent`. Refusing here is the point.
A headline dropped straight into a section is not an error YOOtheme reports
— it renders nothing, and the operator is left looking at a blank band
wondering which of their changes broke the page.
"""
if parent.get("type") not in CONTAINS:
# A composite element — a button holding button_items, an accordion
# holding panels, a gallery holding images. Its children are its own
# contract, not this one's, and inventing a rule for an element we do not
# model would reject layouts YOOtheme renders perfectly well.
return True
allowed = CONTAINS[parent["type"]]
if allowed is None: # a column
if node.get("type") in NOT_IN_COLUMN:
raise ValueError(f"a {node.get('type')} cannot go inside a column")
return True
kind = node.get("type")
if kind not in allowed:
raise ValueError(
f"a {kind} cannot go directly inside a {parent.get('type')} — "
f"that holds {', '.join(sorted(allowed))}")
return True
def place(parent, node, index=None):
"""Insert a node into a parent, at the end unless told otherwise."""
check(parent, node)
kids = parent.setdefault("children", [])
kids.insert(len(kids) if index is None else max(0, min(index, len(kids))), node)
return node
def move(parent, from_index, to_index):
"""Reorder within one parent — the sibling move the section planner never
covered, because it only ever rebuilt the top level."""
kids = parent.get("children") or []
if not 0 <= from_index < len(kids):
raise ValueError(f"no child at index {from_index}")
node = kids.pop(from_index)
kids.insert(max(0, min(to_index, len(kids))), node)
return node
def wrap(node):
"""Give a bare element the row and column it needs to render.
Convenience with a purpose: the common request is "add a button to this
section", and the honest answer is that a button needs a column, so build
the column rather than refusing the request.
"""
if node.get("type") == "section":
return node
if node.get("type") == "row":
return section(children=[node])
if node.get("type") == "column":
return section(children=[row(children=[node])])
return section(children=[row(children=[column(children=[node])])])