# VML — Virtual Markup Language, Reference

## Syntax

```
# Comment — ignored
@Type slug-name
PropertyName=value
AnotherProperty=value

@NextType next-slug
...
```

- `@Type name` starts a new block. `name` is that block's unique slug
  within its scope.
- `Key=Value` lines set properties on the current block.
- Blank lines separate blocks (cosmetic only — not load-bearing).
- Multi-line values: indent continuation lines with 2+ spaces or a tab.

## THE ONE RULE THAT CAUSES THE MOST BUGS

**Every property key is stored lowercase-first**, regardless of how it's
written in VML. `Text=`, `BgColor=`, `FontSize=` are written PascalCase by
convention (readability) — the parser's `allProps()` applies `lcfirst()`
to every key on ingestion, so they're actually stored as `text`, `bgColor`,
`fontSize`.

**Every render script — PHP or Lua — must read fields by their lowercase
key.** A Lua script reading `f.Text` instead of `f.text` will get `nil`
silently (no error, just blank output). A PHP script reading
`$fields['Label']` when the field is actually `text` will get nothing and
fall through to defaults. This exact mistake caused multiple real,
hard-to-diagnose bugs this session (empty buttons, blank cards, blank stat
values) — always double-check the actual stored key casing against
`cms_block_fields`, don't assume it matches the VML property name's case.

Structural/meta keys (`Section`, `Page`, `Type`, `Order`, `Position`,
`Parent`, etc.) are the exception — they're consumed by the parser itself,
never stored as content fields, so casing doesn't matter for them the same
way.

## Structural tags

| Tag | Purpose | Key properties |
|---|---|---|
| `@Site` | One per site | `Domain=`, `Webroot=`, `Title=`, `Desc=`, `CssSlug=` |
| `@Page` | One per output page | `Output=` (path), `Title=`, `Status=` |
| `@Section` | A page section | `Page=`, `Type=`, `BgColor=` |
| `@Nav` | A navigation group | *(not yet auto-extracted by the importer — hand-author for now)* |
| `@NavItem` | One nav link | `Nav=`, `Label=`, `Href=`, `Order=` |

### Section `Type=` values and their wrapper

Each maps to a template in `cms_section_layouts`. Pick the closest match —
don't invent a new type without adding a matching layout row first.

| Type | Shape | Typical content |
|---|---|---|
| `hero` | Full-width intro, two-column visual split | tag, h1, sub-text, buttons, stat-cards |
| `about` | Two-column: prose left, extras right | tag, heading, plain text paragraphs |
| `model` | Like `about`, dark background variant | same, on a dark section |
| `features` / `services` | Grid of cards | tag, heading, feature-card/card/stat-card |
| `contact` | Two-column: details left, form right | tag, heading, contact-card, emergency |
| `text` | Plain content, no special layout | any |
| `footer` | Site footer | handled separately via `@Nav`/copyright, not authored per-page |

## Content block tags

Every block supports `Position=` (`head` / `left` / `right` / `blocks` /
`extras` — which slot in the section wrapper it lands in) and, optionally,
`Order=` to override sequential file-position order (see below — rarely
needed).

| Tag | Fields (write PascalCase; stored lowercase-first) |
|---|---|
| `@Tag` | `Text=` |
| `@Heading` | `Text=`, `FontFamily=`, `FontSize=`, `FontWeight=`, `LineHeight=` |
| `@Text` | `Text=` |
| `@Button` | `Text=` (the label — **not** `Label=`), `Href=` |
| `@Stat-card` | `Value=`, `Label=` |
| `@Feature-card` | `Number=`, `Title=`, `Text=` |
| `@Card` | `Title=`, `Text=` |
| `@Contact-card` | `Label=`, `Value=`, `Href=` (one row per card — a "phone/email/hours" block group is *several* `@Contact-card` blocks, not one block with several fields) |
| `@SiteJs` | `Content=` (base64-encoded JS — whole-page script, not a content block) |

Block types without a dedicated render script yet (`@Emergency`,
`@Ttags`, `@Fw-grid`, `@Badges`, `@Safety-list`, `@Ref-steps`) fall back to
plain-text rendering — functional, unstyled. Writing a real
`cms_render_scripts` row for one of these is a small, self-contained task
if you need it styled.

## Style properties (any block type)

These are collected automatically into one CSS string before a block's
render script runs — a Lua script reads it as `f.inlineStyle`, a PHP
script as `$fields['inlineStyle']`. You never write `InlineStyle=`
directly; you write the individual props and the renderer assembles them.

| VML property | CSS output |
|---|---|
| `Border=` | `border` |
| `BorderRadius=` | `border-radius` |
| `Shadow=` | `box-shadow` |
| `BgColor=` | `background` |
| `TextColor=` | `color` |
| `FontSize=`, `FontWeight=`, `FontFamily=` | `font-size`, `font-weight`, `font-family` |
| `LineHeight=`, `LetterSpacing=`, `TextTransform=`, `TextAlign=` | matching CSS property |
| `Padding=`, `Margin=`, `MarginTop/Bottom/Left/Right=` | matching CSS property |
| `Opacity=`, `ZIndex=` | `opacity`, `z-index` |

## The render pipeline, end to end

```
VML file → cms_vml.php (parser) → per-site SQLite (cms_blocks, cms_block_fields, ...)
         → cms_renderer.php → static HTML
```

- `cms_vml.php --file=X.vml --site=<slug>` parses and stores. It does
  **not** render — publishing is a deliberate, separate step.
- `cms_renderer.php --site=<slug>` renders the stored content to static
  HTML, using `cms_render_scripts` (one script per block type, PHP or
  Lua) and `cms_section_layouts` (one wrapper per section type).
- Re-running the importer/parser against the same source is meant to be
  **idempotent** — matching slugs update existing rows rather than
  creating duplicates. Slugs are position-within-group based (stable
  under content changes elsewhere on the page), not derived from a
  volatile global order counter.

