For AI agents

Building lazysite themes and layouts programmatically - the full technical contract.

This is the reference an agent needs to build a lazysite theme or layout over the WebDAV / control-API path. It assumes you can already write files to the docroot. The server is authoritative - verify everything against whoami and the live docs; treat this page as a map, not gospel.

Connect, then verify

Exchange your lzp_ pairing key for an lzs_ token, then use HTTP Basic auth (username = partner id, password = token) for WebDAV and the control API. Check your grant before assuming anything:

POST /cgi-bin/lazysite-manager-api.pl?action=whoami

Theming needs manage_themes + manage_layouts; setting config needs manage_config; the nav needs manage_nav. Capabilities are read from the account per request - a fresh grant applies with no new token. They are independent of any admin-group status.

The model (D013)

  • Layout = layout.tt + layout.json at lazysite/layouts/NAME/. Structural HTML only; brand-neutral.
  • Theme = theme.json + assets/main.css at lazysite/layouts/LAYOUT/themes/THEME/. Declares layouts[].
  • Everything under lazysite/ is internal; you write there with manage_themes/manage_layouts, not as ordinary content.

theme.json schema

{
  "name": "x",            // matches the dir; sanitised to [A-Za-z0-9_-]
  "version": "1.0.0",     // semver
  "description": "...",
  "author": "...",
  "layouts": ["x"],       // REQUIRED, non-empty. Theme ignored if active layout not listed
  "config": { "colours": { "accent": "#4F46E5" }, "fonts": { "body": "Inter" } }
}

config is group -> key -> value; values must be strings, and the characters ; { } < > are stripped from them. The processor emits each as --theme-GROUP-KEY at :root via the [% theme_css %] variable; your main.css consumes them with var(--theme-GROUP-KEY). Refs and config entries must match 1:1.

Assets and the mirror

A theme's assets/ subtree is served at /lazysite-assets/LAYOUT/THEME/ (this is [% theme_assets %]). Reference fonts/images from main.css with relative paths. The mirror is populated at install / activation, not by a raw WebDAV PUT - so link that path in layout.tt and activate once to build it; it persists thereafter.

layout.tt - the TT variables

Variable Notes
content Rendered page body HTML
page_title, page_subtitle Front-matter title / subtitle
page_modified, page_modified_iso File mtime
request_uri Current path, e.g. /about
nav Array from nav.conf; items have label, url, children
site_name, site_url From lazysite.conf
layout_name, theme_name Active names (theme_name unset if no compatible theme)
theme Parsed theme.json hash: theme.config.GROUP.KEY
theme_css <style>:root{ --theme-*-*: ...; }</style> (empty if no theme)
theme_assets /lazysite-assets/LAYOUT/THEME (unset if no theme)
authenticated, auth_user, auth_name, auth_groups Identity
year 4-digit current year

Any other front-matter key is also exposed as a TT variable in the layout (e.g. a page that sets current_page: can be read as [% current_page %]).

Template Toolkit and extended functions

Standard TT works: [% FOREACH x IN list %], [% IF a == b %], [% MACRO m(x) BLOCK %], and string virtual methods - [% url.split('/').last %], [% s.replace('a','b') %], [% list.size %]. lazysite adds page-scoped data via front matter:

tt_page_var:
  posts: scan:/blog/*.md sort=date desc   # array of page objects
  ver:   url:https://example.com/VERSION  # fetched + cached with the page
  • scan: returns page objects exposing url, title, subtitle, date, tags (array) - not arbitrary front-matter keys. Loop them and derive a slug with [% t.url.split('/').last %].
  • url: fetches and caches a remote value (good for a version badge).
  • raw: true outputs the body without the layout (fragments/partials); api: true serves it as JSON; query_params: exposes the query string as [% query %].
  • register: adds a page to sitemap.xml / llms.txt / feeds.
  • Fenced divs become styled boxes: ::: widebox / textbox / examplebox / marginbox. :::form builds a form bound to a delivery handler. ::: include path inlines a partial.

The nav is not a WebDAV file (lazysite/nav.conf is 403). Edit it through the control API, gated by manage_nav:

POST .../lazysite-manager-api.pl?action=nav-read
POST .../lazysite-manager-api.pl?action=nav-save   { "items": [ ... ] }

An item with no url is a section heading; children make a sub-menu. A layout renders it with [% FOREACH item IN nav %].

Gotchas (learned the hard way)

These are the failure modes that cost the most time. Internalise them.

  • Per-page override is layout: only - not theme:. A page previewed via a per-page layout: gets no theme_css, so a self-contained look needs its own :root fallbacks (var(--theme-colours-x, #literal)) in that theme's CSS.
  • Markdown mangles inline <style> in content. Any CSS line that starts with # becomes an <h1>. Write div#id { } so the line starts with a letter. Raw HTML blocks: keep them de-indented and contiguous (a blank line or _/* can inject <p>/<em> and break wrappers). Hide JS-only data blocks by attribute ([data-x]{display:none}), not by a container that markdown might split.
  • The active layout is write-locked. A PUT to it returns 403. Stage a new layout dir (or deactivate first), then activate.
  • Cache. A content write busts that page; a theme/layout activation clears the cache site-wide. nav-save alone may not re-render cached pages - touch a page or activate.
  • Provisioning. WebDAV needs the app webdav_enabled and an nginx /dav/ route (unauth GET /dav/ should be 401, not 404). config-read/config-set are limited for token clients. A transient 502 mid-batch can cascade - re-stage with status checks.

Minimal working theme

theme.json   -> { "name":"x","version":"1.0.0","description":"x","author":"x",
                  "layouts":["x"], "config":{ "colours":{"accent":"#4F46E5"} } }
assets/main.css -> :root{ --accent: var(--theme-colours-accent,#4F46E5) }
                   a{ color: var(--accent) }
layout.tt    -> ...[% theme_css %]<link href="/lazysite-assets/x/x/main.css">...
                <nav>[% FOREACH i IN nav %]<a href="[% i.url %]">[% i.label %]</a>[% END %]</nav>
                <main>[% content %]</main>

Stage the files under lazysite/layouts/x/ (+ themes/x/), activate (action=layout-activate&path=x&theme=x), confirm the mirror returns 200, and verify the live page. For more, see the on-site docs and lazysite.io.