Skip to content

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.

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.

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 dock, 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 dock (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 (e.g. 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).

Map each item’s _type to its component. In edit mode, stamp a data-louise-sfield marker on every visible text node so the client can make it editable in place. The path is "<index>.<field>", or "<index>.<key>.<itemIndex>.<subField>" for array items:

<h1 data-louise-sfield={`${i}.heading`}>{heading}</h1>
<p data-louise-sfield={`${i}.tagline`} data-louise-multiline>{tagline}</p>

Render empty fields too (in edit mode) so there’s something to click into; data-louise-multiline keeps newlines for textarea-backed fields.

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:

  • Text is edited in place on the live design — each data-louise-sfield node becomes contenteditable, writing into a shared fine-grained store (a keystroke updates only that leaf, so rows never tear down).
  • A floating control dock handles what you can’t point at: add / reorder / remove sections, array-item add/remove, and any inline: false field.

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 dock shows only a live status and Publish — no Save draft button. 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.

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, e.g. 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 on-page dock surfaces the first violation as the save-failure reason.

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.