Louise Sections
Louise Sections are the preconfigured-blocks model: a page is an ordered list of typed items that your own components render, so a bespoke design stays pixel-perfect while editors still add, reorder, and edit it. Where the Louise Builder stores sanitized HTML and inline fields edit one value at a time, sections store structured JSON and render through your components.
The shape
Section titled “The shape”A page carries a sections array—ordered items, each a _type discriminant
plus its field values:
[ { "_type": "hero", "heading": "Louise Toolkit", "tagline": "…", "ctaHref": "/docs" }, { "_type": "featureGrid", "items": [{ "title": "…", "body": "…" }] }]The site owns rendering (a bespoke component per _type); Louise owns
editing only. No markup is ever authored in the editor, so the design can’t
drift.
The catalog
Section titled “The catalog”A SectionCatalog describes each type’s editable fields—schema only, no
markup:
import type { SectionCatalog } from "louise-toolkit/client";
export const SECTIONS: SectionCatalog = { hero: { label: "Hero", fields: { heading: { type: "text" }, tagline: { type: "textarea" }, ctaLabel: { type: "text" }, // No visible text on the page → edited in the inspector, not in place. ctaHref: { type: "text", inline: false }, }, }, featureGrid: { label: "Feature grid", fields: { items: { type: "array", itemLabel: "Feature", itemFields: { title: { type: "text" }, body: { type: "textarea" } }, }, }, },};Field types are text, textarea, array (repeatable, with itemFields), and
image. Plain text is edited in place; array and image are edited in the
inspector (an image gets Upload + Choose from media + clear controls, so
it always resolves to a media asset,
never a pasted URL), as is any field you mark inline: false (for example, a link URL
with no visible text). Pass mediaBase to assertValidSections and a section
image that isn’t media-hosted is rejected on write (422).
Rendering + edit markers
Section titled “Rendering + edit markers”Map each item’s _type to its component. In edit mode a render stamps two
kinds of marker, and they do different jobs—a site that stamps only one gets
half an editor.
The boundary: data-louise-node
Section titled “The boundary: data-louise-node”One attribute marks every editable node—the thing the on-canvas chrome
rings and hangs a toolbar on. Its value is that node’s path into the sections
array, and nothing else:
<!-- a section: item i of the page --><div data-louise-node={`${i}`}>…</div>
<!-- a block: item j of section i's `blocks` --><article data-louise-node={`${i}.blocks.${j}`}>…</article>
<!-- a value: one field of section i --><a data-louise-node={`${i}.ctaHref`} href={ctaHref}>Book now</a>Three shapes, one grammar:
"0"—a section. It has a position in the page’s list, so its toolbar gets move up / down, delete, and a+to add a sibling after it."0.blocks.1"—a block: block1of section0, ordered within its section.blocksis the reserved structural key."0.ctaHref"—a value: a node with no position and no children, so its toolbar is a wrench only. That wrench opens an inspector scoped to that field, rather than its section’s whole panel.
The render never declares what a node is—it says only where the node lives.
The editor resolves the path against your catalog and the chrome draws whatever
capabilities come back: an ordered node gets move/delete, a node that holds
children gets an add, a node with configurable fields gets a wrench (and no
wrench at all when there’s nothing to configure). A section that declares
blocks and currently has none draws its own Add the first one +.
Astroid’s <Section> dispatcher stamps the boundary for
you, at every depth—a block is the same component recursing with a deeper
base, so a type that renders as a section renders unchanged as a block.
Fields carry the same marker
Section titled “Fields carry the same marker”There is only one attribute. A field is marked exactly like a section or a block—its own path, one level deeper:
<h1 data-louise-node={`${i}.heading`}>{heading}</h1><p data-louise-node={`${i}.tagline`}>{tagline}</p><a data-louise-node={`${i}.ctaHref`} href={ctaHref}> <span data-louise-node={`${i}.ctaLabel`}>{ctaLabel}</span></a>The catalog decides what happens to each one. A text, textarea or
richText field is edited in place, so its node becomes contenteditable and gets
no chrome of its own—hovering it rings whatever contains it. Anything else is
edited in the wrench, so its node rings and gets a toolbar. You don’t say which;
the field’s type already did.
That’s why the CTA above nests: the anchor is the destination (wrench), the span inside it is the label (typed on the page). Hovering the words walks outward to ring the button.
Render empty fields too (in edit mode) so there’s something to click into. A
textarea field keeps newlines and gets browser spellcheck; a text one doesn’t—again from the type, not from a stamped attribute.
Editing: mountSections
Section titled “Editing: mountSections”import { mountSections } from "louise-toolkit/client";
mountSections(el, { catalog: SECTIONS, pageId, initial });// Auto-save is on by default; opt out with:mountSections(el, { catalog: SECTIONS, pageId, initial, autoSave: false });el is the wrapper around the server-rendered sections. The UX is hybrid,
and entirely on the canvas—there is no floating panel:
- Text is edited in place on the live design—a marked node whose field the
catalog says is inline becomes
contenteditable, writing into a shared fine-grained store (a keystroke updates only that leaf, so rows never tear down). - Structure is the on-canvas toolbar. Hovering (or tabbing to) a
data-louise-noderings the tightest node under the pointer and floats its toolbar at the top-right: move up / down, delete, and+to add. Exactly one node is active at a time—a value beats the block it sits in, which beats the section around that. - Everything you can’t point at is behind the wrench—array items, images,
and any
inline: falsefield, plus a section’s layout and settings. On a value node the wrench opens just that field.
Save draft, Publish, and the save status live on the shared edit bar, not on the sections editor.
The save contract
Section titled “The save contract”When the page is wired for drafts & publishing (a versions
collection), a save stages a draft version without touching the live page,
and Publish promotes it.
- Text edits stage a draft—no reload (the DOM already shows the change); the live page is unchanged until you Publish. With auto-save on (the default) this happens on an idle debounce, so the edit bar shows only Publish—no Save draft button, and no routine saved/unsaved status; a failed save still surfaces. Auto-save never publishes.
- Structural changes save a draft and then reload, so the server re-renders the new shape (which comes back inline-editable). In edit mode the page resumes your latest draft; view mode always shows the published version.
Opt out with autoSave: false to bring back the manual Save draft button.
Store sections as a JSON column on your pages table and add it to your
pagesRoute fields allowlist (metadata/create/delete)—the draft/publish surface is versionsRoute.
Validation
Section titled “Validation”The stored JSON is validated server-side before every write. Give pagesRoute a
validate hook that runs assertValidSections against your catalog:
import { assertValidSections } from "louise-toolkit/content";import { SECTIONS } from "./sections/catalog";
pagesRoute({ table: pages, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"], validate: async (data, ctx) => { if ("sections" in data) await assertValidSections(SECTIONS, data.sections, ctx); },});validateSections (the non-throwing form) checks that the value is an array, that
every item’s _type is a known catalog entry, and that each field matches its
declared shape (text/textarea → string; array → objects whose itemFields are
validated in turn). A field can also carry a validation chain—the same
Rule builder collection fields use, for example,
heading: { type: "text", validation: (r) => r.required().max(80) }.
assertValidSections throws LouiseValidationError on any error-severity
violation, which pagesRoute turns into a 422 { error, violations }—the edit
bar surfaces the first violation as the save-failure reason.
Search
Section titled “Search”Because sections is a json field, its content is full-text searchable: list
it in the collection’s search.fields and the FTS index flattens every string
leaf (headings, feature text…) into the index. Mount
searchRoute and the Settings’ Pages panel gains a search
box. Only published content is indexed; run POST /api/louise/pages/reindex once
after adding the FTS table to backfill existing rows.