"""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])])])