This document briefs an AI assistant helping a user author content on a lazysite site. It covers the page format, front matter, Markdown extensions, and URL conventions. For view/theme authoring, see AI briefing - layouts. For configuration, see AI briefing - configuration. For how a page fits the content/layout/theme model - and why ordinary pages must not use raw mode - read AI briefing - building sites first.
Every page is a plain text file ending in .md. Pages begin with a
YAML front matter block delimited by ---, followed by the page body
in Markdown:
---
title: Page Title
subtitle: Optional subtitle shown below title
register:
- llms.txt
- sitemap.xml
---
Page content in Markdown.
The content body is converted to HTML and inserted into the site's
view template at [% content %]. The view wraps it with navigation,
header, and footer.
title<title> tag and
the page <h1>. The browser <title> renders as "page title - site
name", so on the home page make this differ from site_name in
lazysite.conf - an identical title and site name read as
"My Site - My Site".
subtitle<meta name="description"> and its description in llms.txt, unless
meta_desc overrides them.
meta_desc<meta name="description"> and its llms.txt description,
without printing anything on the page. Optional; falls back to
subtitle.
Use it on a page whose design does not want a visible subheading - a page with a hero section, for instance, where a subtitle renders directly above the hero. Before this existed the author had to choose between a subheading they had not designed for and no description at all for search engines and AI clients, and the page most likely to face that choice is the home page.
meta_title<title> tag, when it should differ from the on-page title. Optional;
falls back to title.
ttl.md file edit. Example: ttl: 300.
registerlazysite/templates/registries/ without the .tt
extension. Common values: llms.txt, sitemap.xml, feed.rss,
feed.atom.
dateYYYY-MM-DD format. Used in feed entries.
Falls back to file mtime if not set.
tt_page_varurl: (remote text),
scan: (page list), json: (local JSON file -> decoded data structure), and
${ENV} prefixes. Page variables override site variables.
tt_page_var:
hero_image: /img/landing.jpg
latest_release: url:https://raw.githubusercontent.com/example/repo/main/VERSION
blog_posts: scan:/blog/*.md sort=date desc
matrix: json:/data/comparison.json # then [% FOREACH r IN matrix.rows %]…[% END %]
Any OTHER top-level key is yours. A scan of the page can read it as
[% p.status %], and the page's own layout reads the same value as
[% page_status %] - one fact, written once, usable in both places. Values
arrive HTML-escaped, which is why they are prefixed rather than bare: it keeps
them from colliding with a site variable and guarantees every layout emits them
safely without needing a | html filter. Scalars only; use tt_page_var when
you want a list or a structure.
---
title: Ship the loader
status: in-progress # [% p.status %] in an index, [% page_status %] here
owner: alice
---
To build a data-driven table/matrix from a JSON file, use json: - it gives the
body a real data structure to loop. ([% USE JSON %] needs the optional
Template::Plugin::JSON module, which is not bundled; prefer json:.)
rawraw: true outputs the converted body without the view wrapper.
Useful for fragments, AJAX partials, or API-style endpoints.
apiapi: true serves the page as a JSON API endpoint. Default content
type is application/json; charset=utf-8. Combine with
tt_page_var and query_params for dynamic JSON.
content_typecontent_type: text/csv; charset=utf-8. Script-capable types
(text/html, application/xhtml+xml, image/svg+xml) are refused on a
raw/api page (downgraded to text/plain at serve time) because such a
page is served verbatim and unescaped - serve HTML through a layout, or
as a static file.
layoutlazysite/layouts/.
Useful for previewing a staged layout on one page before activation.
authrequired, optional, or none
(default).
auth_groupspaymentquery_params[% query.NAME %] and bypass the cache.
tagssearchtrue or false. Controls whether the page appears in search.
Defaults to the site's search_default setting.
formlazysite/forms/NAME.conf.
# H1 is reserved - the page title is rendered by the view template.
Start content headings at ##.
Standard Markdown. Internal links should be extensionless:
Docs not Docs.
Standard GFM pipe tables are supported.
Fenced code blocks with language identifiers produce highlighted output:
```bash
curl https://example.com/
```
Inline code and fenced code blocks are protected from Template Toolkit
processing - [% tags %] inside code appear literally.
Wrap content in a named CSS class:
::: classname
Content here. Standard Markdown works inside.
:::
Produces <div class="classname">...</div>. Class names must contain
only word characters and hyphens.
Common classes in the default theme:
widebox - full-width coloured bandtextbox - 60% width highlighted boxmarginbox - pull quote in the marginexamplebox - evidence or example highlightIf the active layout ships a component by that name, a ::: block is rendered
through it (Markdown in, layout-supplied HTML out) rather than becoming a plain
div - the way to get a hero/feature-grid/etc. without hand-writing HTML:
::: hero eyebrow="Generative"
# A site that's *alive*.
::: actions
Get started
:::
:::
The inner Markdown is the component's content; key="value" on the opening
line are attrs; a nested ::: <name> block is a named slot. A name with no
matching component falls back to <div class="name">. Available components are
layout-specific. For all-structure pages a layout may also read a front-matter
sections: list and compose the page from components. How to WRITE a
component, and the sections: shape, are in /docs/ai-briefing-layouts.
An opening fence with no closing ::: is left in the page as text:
validate_page reports it as component-fence-unmatched with the line.
::: oembed
https://www.youtube.com/watch?v=abc123
:::
Works with YouTube, Vimeo, SoundCloud, and any oEmbed provider.
Inline local or remote content at render time:
::: include
partials/note.md
:::
.md files have their front matter stripped. Code files are wrapped in
syntax-highlighted code blocks. .html files are inserted bare.
Includes are single-pass - includes inside included files are not processed.
A local include path is resolved inside your site's content root, and cannot reach outside it. On a multi-domain instance that means a domain includes its own partials and never another domain's - the confinement is not a setting you can relax. (SM275: the shipped behaviour is stricter than earlier wording implied, and a reader who believed the looser version would design a shared-partials layout that cannot work.)
Use the multi-line fenced form above. A one-line ::: include path is left
as literal text, not expanded. Keep consecutive includes contiguous.
You can drop raw HTML into a page, but Markdown's block rules bite - both of these silently mangle it:
<p>. Keep a run of
HTML contiguous - no blank lines inside it.When a fragment is awkward to keep flush and contiguous, move it into a .md
include partial and pull it in with ::: include; the partial's HTML is
inserted as-is. (This is also why an activation cache-clear that removes
generated <page>.html never touches your author partials - keep reusable
chrome in .md/.html partials.)
TT variables are expanded in the page content before Markdown
conversion. Site variables come from lazysite.conf, page variables
from tt_page_var. Automatic variables (page_title, page_subtitle,
content) are set by the processor.
Current version: [% latest_release %]
[% IF beta %]
<div class="textbox">
<p>This feature is in beta.</p>
</div>
[% END %]
Inline code and fenced code blocks are protected from TT. Put TT tags outside code blocks if you want them to render.
json:) - worked end to end (GS12)A page can loop over structured data instead of repeating Markdown by hand.
Put the data in a local JSON file, bind it with json: in tt_page_var,
and loop with Template Toolkit in the body. The whole gallery is three
pieces, and adding a painting is one more object in the file:
/gallery/paintings.json:
{
"paintings": [
{ "title": "Harbour, dusk", "file": "harbour.jpg",
"year": 2024, "medium": "oil on board", "sold": false },
{ "title": "Two chairs", "file": "chairs.jpg",
"year": 2023, "medium": "acrylic", "sold": true }
]
}
/gallery/index.md:
---
title: Paintings
tt_page_var:
art: json:/gallery/paintings.json
---
[% FOREACH p IN art.paintings %]
::: card
<img src="/gallery/img/[% p.file %]" alt="[% p.title %]">
**[% p.title %]** - [% p.medium %], [% p.year %][% IF p.sold %] - *sold*[% END %]
:::
[% END %]
Markdown image syntax cannot carry a template expression (SM498). The
body becomes HTML first and TT runs second, over the rendered HTML - so
![[% p.title %]](...) meets the image converter as raw text, fails to
match across the TT tag, and renders a literal ! and a link instead of an
image. When any part of an image - alt or src - is templated, write the raw
<img> tag as above. Loops are unaffected: the fence has already become one
<div>, and the surrounding [% FOREACH %] multiplies that rendered HTML -
one card per row, as here. The same fact bounds what a loop CAN do: since
the fence is gone before TT runs, a loop cannot compute a fence's name
(::: [% p.kind %] is literal text, never a component) or emit a fence
conditionally - put the [% IF %] around the fence's CONTENT, or emit the
raw HTML the component would have produced. If the layout ships a card component those
blocks render through it, otherwise each becomes <div class="card">. The file path is resolved
against the page's content root, then the docroot, and must stay inside
the docroot; a missing or invalid file makes art empty (the loop renders
nothing) and the build logs a WARN. Values arrive as data, so they are
escaped by TT's output as text - do not wrap them in raw HTML.
json: is right for a list the author edits as a file. For a table that
others edit through the manager, that changes often, or that needs types,
keys and per-row access rules, bind db:<table> instead - see
/docs/ai-briefing-data. The body loop is identical.
theme_assets, theme_name, theme, theme_css and layout_name resolve in a
page body as well as in layout.tt. So this works, and is the right way to
reference a themed asset from a page:
<img src="[% theme_assets %]/hero.jpg">
theme_assets expands to /lazysite-assets/LAYOUT/THEME, so the path follows
the site when its layout or theme changes. Prefer it over writing
/lazysite-assets/<layout>/<theme>/hero.jpg literally: a hard-coded path is
correct only until someone activates a different theme, and then it 404s with no
indication of why.
They resolve on ordinary pages, which are the ones with a layout. A raw page
(raw: true / api: true) has no layout, so it has no theme either and these
are empty there - which is the correct answer for a page that renders no chrome.
If the asset belongs to the design rather than to this one page, it still belongs in the layout or the theme's CSS rather than in a page body.
On an older engine. Before 0.10.5 these were layout-scope only, and a page
body using one got the empty string with no error - [% theme_assets %]/hero.jpg
rendered as /hero.jpg and 404'd. If you are working against an older engine, or
reading guidance that says to write the path literally, that is why.
No randomness primitive. There is no random/shuffle helper in the template
context, so a "random quote" or "random image" cannot be computed per request.
Either pick one at author time, or render the set as a carousel/cycle in the
page or layout. For list-driven pages (reviews, a homepage highlight strip),
scan: + a FOREACH loop give you the data deterministically.
Markdown link URLs do not reliably resolve TT variables (the Markdown
parser processes links before TT runs). Use HTML <a> tags when the
href contains a TT variable:
<a href="[% download_base %]/release-[% version %].tar.gz">Download</a>
Page URLs derive from file paths, always without extension:
DOCROOT/index.md -> /
DOCROOT/about.md -> /about
DOCROOT/docs/install.md -> /docs/install
DOCROOT/docs/index.md -> /docs/
Always use extensionless URLs for internal links.
A .url file contains a single URL. The processor fetches the Markdown
from that URL, processes it through the full pipeline, and caches the
result.
# File: docs/install.url
https://raw.githubusercontent.com/example/repo/main/docs/INSTALL.md
The remote file should include YAML front matter. Cache TTL defaults to one hour.
scan:/path/*.md returns an array of page metadata. Use in
lazysite.conf or tt_page_var:
tt_page_var:
blog_posts: scan:/blog/*.md sort=date desc
[% FOREACH post IN blog_posts %]
## [% post.title %]
[% post.subtitle %] - [% post.date %]
[% post.url %]
[% END %]
Each item has url, title, subtitle, date, and path.
Pages are searchable by default. Set search: false in front matter to
exclude a page from search. The site default is controlled by
search_default in lazysite.conf.
Pages declare which registry files they appear in via register:.
Common registries: sitemap.xml, llms.txt, feed.rss, feed.atom.
Each name maps to a Template Toolkit template in
lazysite/templates/registries/. Registries regenerate after their
TTL expires (default 4 hours).
DOCROOT/PATH.md (e.g. /docs/install becomes
DOCROOT/docs/install.md).title: and optionally subtitle:.sitemap.xml, llms.txt, and
feed.rss or feed.atom if it is a dated article.##.append_brief (MCP) or brief-append
(API): purpose, sections, tone, image/content sources, and a "To change
this page…" line. Never write a <file>.brief sidecar - on a site with
the briefs plugin enabled that write is refused, naming the replacement.
The brief is the source of intent, the .md is the build: when the owner
appends a change request, refactor the page to match it. See Document
your intent: briefs in AI briefing - publishing
for the full structure and workflow./blog/.DOCROOT/blog/index.md with tt_page_var:---
title: Blog
tt_page_var:
posts: scan:/blog/*.md sort=date desc
---
[% FOREACH p IN posts %]
## [[% p.title %]]([% p.url %])
[% p.subtitle %] - [% p.date %]
[% END %]
lazysite/auth/groups.---
title: Members area
auth: required
auth_groups:
- members
---
Protected pages are never cached.