Skip to content

Instantly share code, notes, and snippets.

@tipiirai
Last active September 9, 2026 08:02
Show Gist options
  • Select an option

  • Save tipiirai/4ba6168f4ca79ee4def4a9dfe536d9eb to your computer and use it in GitHub Desktop.

Select an option

Save tipiirai/4ba6168f4ca79ee4def4a9dfe536d9eb to your computer and use it in GitHub Desktop.
Hertta site format

Hertta site format

hertta-site/v1 is Hertta's only authoring format: a content-first, normalized multi-file source tree. AI focuses on content and structure; presentation (theme, layout mechanics, CSS) is handled separately. It is not one JSON page document and not unrestricted YAML.

Two page formats live inside a site: YAML for rich, sectioned pages (landing pages, pricing, features) and Markdown for content-dominant pages (blog posts, documentation).

The platform-owned SITE_FORMAT_V1 registry in integration/generator/registry.ts is the executable source of truth for themes, components, properties, limits, routes, examples, and hash framing. Call site.format to get the same description live rather than trusting a static copy of this doc — the registry is what actually validates.

site.format also returns a model-facing authoring guide: an ordered workflow, page-mode and site-shell decisions, an exhaustive component guide with source fragments, composition recipes, quality and truthfulness checks, common diagnostic fixes, and a complete examples.qualitySite source tree. Its compact JSON text fallback preserves the authoring guide and smaller examples for MCP hosts that do not expose structuredContent, while omitting the large qualitySite example, canonical hashing, and machine input schemas. Structured output includes the complete result.

Site structure & routing

A site is a tree of files, not a single document.

  • site.yaml is required and holds site-wide metadata and layout. It needs a non-empty title and exactly one theme.
  • Exactly one .yaml or .md page must resolve to /.
  • index.yaml and index.md resolve to their containing directory.
  • Other page files resolve to their path with the extension removed.
  • A trailing-slash request uses the same canonical route as without one.
  • Two source files can't resolve to the same route.
  • A page route can't shadow another page's generated file path or an asset's public path.
  • @shared/** is reserved for generator output.
Source path Route Generated file
index.yaml / index.html
about.md /about about/index.html
docs/index.md /docs docs/index.html
docs/setup.md /docs/setup docs/setup/index.html

Paths are normalized relative POSIX paths. Absolute paths, ../ traversal, backslashes, unsupported characters, duplicates after normalization, and reserved output paths are all rejected.

Minimal site:

# site.yaml
title: Example site
theme: notion
# index.yaml
title: A small site
desc: Built from Hertta source.

site.yaml

Site-wide config: header, navigation, footer, and metadata that every page inherits.

# page header. logotype is header-only
header:
  logotype: xy
  links:
    Docs  /docs
    CLI  /cli
    Library  /library
  buttons:
    (Install).primary /install

# burger menu on narrow screens
drawer:
  nav:
    Product
      Features  /features
      Pricing  /pricing
    Company
      About  /about
      Contact  /contact

# sidebar for /docs area
sidebar/docs:
  nav:
    Sessions
      CLI commands  /docs/cli
      Slash commands  /docs/slash-commands
      Usage and costs  /docs/usage
      Troubleshooting  /docs/troubleshooting

# page footer
footer:
  nav:
    Product
      Intake  /intake
      Plan  /plan
      Build  /build
    Features
      Agents  /agents
      Smart triage  /smart-triage

Full set of site-wide properties: title, theme, brand, favicon, og, robots, header, footer, drawer, sidebar, sidebar/<route-root>, cta.

brand is accepted only for legacy compatibility and currently has no rendering effect; new sites should omit it. A drawer needs a header, because the drawer's open trigger is emitted there. A global sidebar selects wiki layout and replaces the normal header, drawer, and footer. A route-scoped sidebar/<route-root> selects documentation layout for that route family; those pages must be Markdown and should contain a visible # Title followed by ## sections.

Theme is exactly one of:

notion  linear  geist
  • notion is light. use by default
  • linear is dark
  • geist is monospaced and nerdy

There's no custom site.css in v1. The release bundles the selected theme, self-hosted fonts, logo, and only the runtime modules the rendered media actually needs.

YAML page format

Syntax rules

Hertta YAML is a deliberately constrained indentation-based language, not general YAML:

  • key: value sets an inline scalar.
  • An empty key: owns an indented object or multiline text below it.
  • Unquoted true, false, and numbers are typed. Quote characters are ordinary content, not string delimiters.
  • An unindented --- starts a new page section.
  • An indented --- separates records inside a component collection (grid cells, expandable items, etc).
  • Dot suffixes add modifier classes, e.g. grid.three.bordered.
  • One balanced parenthesized root-relative route wraps a component, e.g. card(/about).

These are errors: tabs, inconsistent indentation, duplicate keys, orphan content, an inline value with children under it, an unknown component, an empty value where one is required.

Sections

Pages are sections separated by ---:

title: H1 level heading
desc: Subtitle
image: /img/hero.png
---
title: H2 level heading
desc: Subtitle

Content properties

Properties render in the order given:

eyebrow: Optional leading before title (always before title!)
title: Section title (h1 in first section, h2 in rest)
desc: Description
small: Small details
image: /img/hello.png
video: /video/hello.mp4

# Table cells separated with 2+ spaces
table:
  Head1      Head2
  ===
  Cell1      Cell2
  Cell3      Cell4

# Like tables but with optional URL
# Cells separated with 2+ spaces
list:
  Title1     Desc1     /url1
  Title2     Desc2     /url2

# Every button needs an internal destination
buttons:
  Login /login
  (Contact us).primary /contact

# Use explicit destinations for reliable navigation
links:
  Home  /
  Product  /product
  About  /about

# Quote and cite separated with 1+ newlines
quote:
  Quote goes here

  Supplied source / Role or context

# Multiline markdown content
content:
  Markdown content block

  ### h3 level headings only
  Any *formatting* works `here`

# Small side note, styled distinctly
note:
  Small note

# Emphasized/callout block
emphasis:
  Emphasized content

# Syntax highlighted code blocks
code.js:
  function hello() {
    return 'world'
  }

Images

# Editable placeholder with a fixed height
image: 500

# Editable placeholder with width and height
image: 700 x 300

# Editable placeholder with an aspect ratio
image: 16 / 9

# Attached file only (no dimensions)
image: /img/hero.png

# Full properties
image:
  src: /img/hero.png
  width: 300
  height: 400
  caption: Becomes figcaption

The three source-less forms reserve an empty media slot that a user can fill later. They need no attached asset. Keep spaces around x and /; values such as 700x300 are media paths, not placeholder syntax. Structured media can also omit src when width and/or height deliberately size a placeholder.

Videos

# Editable placeholder with a fixed height
video: 500

# Editable placeholder with width and height
video: 700 x 300

# Editable placeholder with an aspect ratio
video: 16 / 9

# Attached file only
video: /video/hero.mp4

# Full properties (loop, muted, autoplay, playsinline default true; controls false)
video:
  src: /video/hero.mp4
  width: 300
  height: 400
  controls: true
  caption: Becomes figcaption

Video placeholders follow the same source-less sizing rules as image placeholders.

See Assets for allowed file types and path rules.

Modifier classes

Some properties accept dot-notation modifiers:

# push last element right
desc.spread: Description [*New* Loops](/blog/loops)

# table modifiers
table.spacious      # add vertical whitespace
table.compact       # reduce whitespace
table.titled        # when first row is a title
table.bordered      # add bottom borders
table.select-second # highlight second column

Other portable modifiers are columns.three, columns.four, grid.two, grid.three, grid.four, grid.bordered, grid.stats, and card.accent. Arbitrary safe class names may parse but have no reliable presentation contract.

Layout properties

Columns

columns:
  Markdown content in two columns

columns.three
columns.four

Grid

Grid cells separated with ---. All content properties supported.

grid:
  cols: 2
  title: Hello
  desc: World
  image: /img/sky.png
  ---
  title: Another
  desc: Cell
  image: /img/flower.png

grid.three
grid.four

Use cols and rows properties on items to span multiple cells.

Split

Two items side by side:

split:
  title: First cell
  desc: Description
  ---
  image:
    src: /img/hero.png
    width: 300
    height: 400

Aside

Shortcut split syntax. Properties before aside go left; aside goes right:

title: Section title
desc: Description
aside:
  note: Supporting context belongs on the right.

quote: This continues below the split

Stats grid

grid.stats:
  eyebrow: Visitors
  title: 500k
  ---
  eyebrow: Conversions
  title: 450

Card

Single container with card styling:

card:
  title: Hello
  desc: World

card.accent

Pricing tiers as a grid with class: card:

grid.three:
  class: card
  eyebrow: Free
  title: $0
  desc: Free forever
  list:
    Feat 1
    Feat 2
  buttons: Select /starter
  ---
  class: card accent
  eyebrow: Pro
  title: $20
  desc: Per month
  list:
    Feat 1
    Feat 2
  buttons: Select /team

Expandable

Summary/details blocks for FAQs. title goes in the summary:

expandables:
  open: true
  title: Hello
  content: First block
  ---
  title: World
  content: Second block

Inline formatting

All property values support markdown: strong, em, links, and code.

title: Hello *World*

Links can carry a class (currently new):

[**New** Loops →](/blog/loops).new

Hashtags render as tags:

desc: #technology #design #marketing

Link wrapper

Wrap a component with a root-relative route to make it clickable. Do this only when its contents have no buttons, links, Markdown links, or nested url fields; otherwise the output contains invalid nested links.

grid(/customers):
  title: Our customers
  desc: See who uses us
  ---
  image: /img/customers.png

quote(/article):
  A supplied quotation belongs here.

  Supplied source

Markdown page format

For content-dominant pages: documentation, blog posts, articles. Mix standard markdown with fenced code and Hertta components.

Supported markdown subset: optional Hertta YAML front matter, headings levels 1–4, paragraphs, ordered and unordered lists, blockquotes, fenced code, thematic breaks, links, emphasis, and allowlisted component blocks. Raw HTML and custom elements are not accepted — HTML-looking text inside a YAML scalar is escaped and shown literally, it never becomes markup.

Markdown pipe tables are supported. Use the Hertta [table] component when you need its bundled modifiers (see Components in markdown).

Hero section

Pages can start with YAML front matter:

---
eyebrow: Eyebrow
title: Title
desc: Description
---

## Content starts here
Regular markdown

Fenced code

Use numbered for line numbers:

function hello() {
  return 'world'
}

Components in markdown

All Hertta components are available via bracket notation. A component body is either inline text after [component] or an indented block below it:

[table.titled]
  Alvar Aalto   Humanist
  Tadao Ando    Minimalist

[image] /img/attached-example.svg

[image] 19 / 6

[list]
  Feature 1
  Feature 2

Special blocks

[emphasis]
  Emphasized content

[note]
  Small note

Full example

---
title: Notes
---

## Main idea

A paragraph with **bold** text.

[table.titled]
  Name    Role
  Ada     Engineer

[note]
  Keep this concise.

Component reference

Canonical component names, per the registry:

buttons   card     columns  content   desc    emphasis   eyebrow
expandables grid   image    links     list    logotype
nav       note     quote    small     split    table     video

Other page fields are title, hero, aside, class, and code.<language>; structured items additionally accept url, cols, and rows. Inside structured image/video, use src, caption, width, height, and video controls; src may be omitted for an intentionally sized placeholder. Direct source-less placeholders use a positive height, width x height, or width / height, with spaces around x or /. open belongs on an expandable record. hero is accepted for legacy compatibility but ignored, because page mode and section position select hero treatment automatically.

Writing rules

Focus on content

  • Have a clear point and write confidently about it. Think hard about the title and description. They set the direction for everything else.
  • Give the article a structure with a beginning, middle, and end.
  • Never leave instructions or filler in the output, like "Replace this with..." or "Hertta eyebrow". Pick a real topic and write about it.
  • Write like a human wrote it. Hertta exists to avoid AI slop, in visuals and especially in content.
  • Drop obvious titles like "Latest entries" and generic eyebrows like "Blog".

Enrich articles with components

  • If a reference page is given, use every image from it.
  • Always use placeholders images if images are not available.
  • Group multiple images side by side in a grid.
  • Tables are good. Draft one whenever it makes sense.
  • Developers like code examples. Put them in fenced code blocks.
  • Use expandables for leftover details, open questions, and FAQs.
  • Use columns for longer sidenotes and footnotes.
  • Use emphasis to highlight interesting points. It reads well in the middle of long content blocks.
  • Don't use grid for simple title/desc blocks. Simple list is the correct component.
  • Always put eyebrow before title. Not after (don't forget)

When a reference site or source HTML is provided

  1. Replicate copy exactly — Use the same text, headings, and structure from the source. Don't add section titles, eyebrows, or organizational elements that aren't there. Don't omit anything. Match the item ordering precisely (e.g., if images come before titles in the source, order them that way).

  2. Use exact image URLs — Reference images exactly as they appear in the source. Don't construct, guess, or transform URLs (e.g., don't invent _next/image?... params).

  3. Implement full navigation — Replicate header and footer links exactly as given. Include all navigation destinations in site.yaml, even if those pages aren't being built.

  4. Build only the provided pages — If source code is pasted, generate only the pages included in that source. Don't create additional pages referenced in navigation.

If a vague "site" is requested

Build a large information architecture:

  1. A full header with logotype, links, and buttons. Links should include at least blog and about. Projects and docs if they make sense. Four links is better than two. Add one or two buttons that match the topic.
  2. Rich content on the main pages: the home page and every page linked from the header.
  3. Several categories and links in the docs sidebar, drawer, and footer to show scale. These links don't have to work. Working main pages are enough.
  4. A grid layout for recent entries on the blog index, and a list layout for the archive (title, description, date cells, for example). Both can use a link wrapper pointing to a single blog entry.

Content still comes first. Pick a good topic and focus on it.

If content guidelines aren't provided

  • Write like you talk. Use simple language.
  • Avoid AI cliches and patterns. Never use an em-dash.
  • Use sentence case in titles.

Assets

Reference a bundled local image or video with a root-relative URL, e.g. /img/photo.webp. The corresponding source-bundle path omits that leading slash: img/photo.webp. Bare paths, ./, and ../ are rejected in authored media URLs — Hertta serves both /route and /route/, and browsers resolve relative paths differently between the two. The exact sized-placeholder forms described above are not URLs and require no attached asset. Ordinary safe relative links (not media) remain valid.

Allowed asset extensions and MIME types:

Extensions MIME type
.avif image/avif
.gif image/gif
.jpeg, .jpg image/jpeg
.png image/png
.svg image/svg+xml
.webp image/webp
.mp4 video/mp4
.webm video/webm
.woff2 font/woff2

Supplied MIME metadata must match the extension. Binary assets are uploaded first and attached to the source bundle at their source path by upload ID and SHA-256.

Validation checklist

Things that get a source tree rejected, worth checking before generating output:

  • site.yaml missing, or missing title, or missing/multiple theme.
  • No page resolves to /, or more than one does.
  • Two files resolve to the same route.
  • A page route collides with another page's generated file path or an asset's public path.
  • Anything written under @shared/**.
  • Absolute paths, ../ traversal, backslashes, or unsupported characters in a path.
  • Tabs or inconsistent indentation in Hertta YAML.
  • Duplicate keys, orphan content, or an inline value with children under it.
  • An unknown component name.
  • A YAML page in a route family controlled by sidebar/<route-root>; documentation-layout pages must be Markdown.
  • An asset's declared MIME type not matching its extension.
  • A media path that's bare, ./, or ../ instead of root-relative; intentional source-less placeholders must use one of the exact sized forms above.

Text is also rejected outright for a UTF-8 byte-order mark or invalid Unicode. CRLF/CR newlines are normalized to LF automatically.

Platform mechanics (MCP)

This section is about the API surface, not content — skip it unless you're driving the MCP tools directly.

Typical workflow:

  1. Call site.reserve immediately, before format discovery or source generation. It returns an initializing site and a clickable editor resource link.
  2. Immediately open the editor link externally using the host's link-opening capability; do not wait for source generation. If external opening is unavailable, prominently present the link. MCP Apps hosts may render the optional editor-link card; it uses the standard host-mediated link capability when available, adds ChatGPT's external-open bridge as a fallback, and always keeps a normal link. The editor safely shows a loading state until revision 1 exists.
  3. Call site.format if the format is not already known, then draft visibly while the editor remains open: show each file's complete content in chat, one file or page at a time as it is written.
  4. After every file has been shown, call site.initialize with the reserved site ID and the same complete source tree.
  5. Call site.get for the current revision and file hashes.
  6. Call site.update with put_text, attach_asset, or remove changes.
  7. Call render/status/publish or rollback lifecycle tools explicitly.

Non-conversational clients that already possess a complete valid source tree before the operation may still use site.create as a single operation. Conversational creation must use reservation first so the editor URL is opened independently of model generation time. Reserved detail responses contain revisionId: null and source: null; no partial or placeholder source is accepted.

REST and MCP wrap authored files in a JSON transport envelope but preserve the text as-authored:

{
  "format": "hertta-site/v1",
  "files": [
    {
      "path": "site.yaml",
      "kind": "hertta-yaml",
      "text": "title: Example site\ntheme: notion\n"
    }
  ]
}

Format errors come back as concise diagnostics (code, message, file, source position) and point back to site.format. There's no legacy source shape accepted.

The content hash is SHA-256 over the format identifier and every file sorted by normalized path, each frame length-prefixed. Edits made through the browser editor identify the exact file, hash, and UTF-16 offsets they target, and only apply if the hash still matches — this matters for the editor's optimistic concurrency, not for generating a page from scratch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment