Content Structure

This guide explains how markdown content is parsed and delivered to your components.

The Basics

When you write markdown in a section file, the parser extracts semantic elements and organizes them into a structured object your component receives:

---
type: Features
---

# Our Features

We built this for you.

### Fast

Lightning quick response times.

### Secure

Enterprise-grade security.

### Simple

No configuration required.

Your component receives:

{
  title: "Our Features",
  paragraphs: ["We built this for you."],
  items: [
    { title: "Fast", paragraphs: ["Lightning quick response times."] },
    { title: "Secure", paragraphs: ["Enterprise-grade security."] },
    { title: "Simple", paragraphs: ["No configuration required."] }
  ]
}

Ignored Files and Folders

The content collector skips certain files and folders by convention:

PatternExamplePurpose
README.mdpages/README.mdRepository documentation (for GitHub), not site content
_* files_draft-hero.mdDraft or private content not ready for publishing
_* folders_drafts/, _archive/Entire folders of draft or archived content

This lets you keep repository documentation and work-in-progress content alongside published pages without them appearing on your site.

Content Fields

All content fields are available at the top level:

FieldSourceDescription
titleFirst headingMain headline (string or string[] for multi-line)
pretitle#> label line, or smaller headings above the titleEyebrow/kicker text (string or string[])
subtitleLine(s) one step below the titleSecondary headline (string or string[])
paragraphsBody textArray of paragraph strings
links[text](url)Array of link objects (see below)
images![alt](url)Array of image objects
icons![](icon:url)Array of icon objects
videos![](url){role=video}Array of video objects
documents![](file.pdf){role=pdf}Array of document objects — see Document Attributes
insets![](@Component) or a ```@Component fenceComponent references — inline, or wrapping a body
mediaImages, videos and insets on their own lineThe section's visual media, in the order written — each with its kind (see below)
lists- itemBullet or numbered lists
quotes> textBlockquote content
snippetsFenced codeCode snippets — [{ language, code }]
dataTagged blocksyaml:/json: give the parsed value; md: gives { items, sequence } (see below)
tablesMarkdown tablesPresent only when the content has one — [{ rows }]
math$ … $ on its own linesPresent only when the content has one — [{ id, latex, mathml }]
headingsNested content onlyHeadings inside quote/list bodies — a section's headline never spills here
itemsSubsequent headingsChild content groups
sequenceAll elementsOrdered array for document-order rendering

Attribute Syntax

Both links and media (images, videos, icons) support attributes using curly braces after the element:

[text](url){attributes}
![alt](url){attributes}

Attribute Types

SyntaxResultExample
key=valueNamed attributewidth=800
key:valueSame — : is an alias for =width:800
key="value"Quoted value (allows spaces)alt="My image"
#idNameCross-reference / anchor id#fig-cells
booleanKeyBoolean trueautoplay
![Hero](./hero.jpg){role=banner width=1200 #main-hero loading=lazy}

Attributes can appear in any order.

There is no CSS-class syntax

A leading dot is an ordinary name character, not a class marker: {.featured} is the boolean attribute ".featured", and {.one.two} is the boolean attribute ".one.two".

Author-supplied CSS classes were the one place markdown was taken literally. Everywhere else the foundation interprets what you write — # is not <h1>, **bold** is not font-weight: 700, {accent} is not a color. class="featured" was the exception, and it let content dictate presentation.

Nothing is lost, because the better spelling already existed. A bare name is an open, multi-valued label set, and it renders as a semantic attribute rather than a class:

[Ships today]{accent}          <!-- <span accent="true">   -->
[Ships today]{accent urgent}   <!-- two labels, both applied -->

Sites declare what those names mean under theme.yml's inline: key. Write {featured} where you would once have written {.featured}.

#idName is unaffected — it is identity, not presentation, and it is how a figure becomes a cross-reference target (see Cross-references).

Separators

Pairs are separated by whitespace, a comma, or both. These are the same:

{role=banner width=1200}
{role=banner, width=1200}
{role:banner, width:1200}

: and , are accepted because they are what most people reach for out of habit, and getting them wrong used to fail quietly rather than loudly. They add no capability — = and spaces remain the canonical form, and that is what the editor writes back when it saves a file.

Two rules keep the syntax unambiguous:

  • The separator must touch the key. {note:warning} is one pair; {note : warning} is two boolean flags. Spaces around : or = are never part of an assignment.
  • A value containing a comma must be quoted — {style="a, b"}. An unquoted comma always ends the value. A value containing a colon needs no quoting; only the first colon separates, so {href:https://example.com} and {style=color:red} work as written.

Asset Paths

Assets (images, videos, PDFs) can be referenced using several path formats:

Relative Paths

Paths relative to the markdown file:

![Photo](./photo.jpg) <!-- Same folder as the markdown file -->
![Logo](../shared/logo.svg) <!-- Parent folder -->
![Team](./images/team.jpg) <!-- Subfolder -->

Absolute Paths (Site Root)

Paths starting with / are resolved from the site's public/ or assets/ folder:

![Hero](/images/hero.jpg) <!-- public/images/hero.jpg or assets/images/hero.jpg -->
![Logo](/brand/logo.svg) <!-- public/brand/logo.svg -->

The build system checks public/ first, then assets/.

External URLs

External URLs are passed through unchanged:

![External](https://example.com/image.jpg)

Build Optimizations

During build, local assets are automatically processed:

Image Optimization

  • PNG, JPG, JPEG, GIF → Converted to WebP for smaller file sizes
  • SVG, WebP, AVIF → Passed through unchanged
  • All images get content-hashed filenames for cache busting
![Photo](./photo.jpg)

<!-- Output: /assets/photo-a1b2c3d4.webp -->

Automatic Poster Generation

Videos without an explicit poster attribute get an auto-generated poster image (requires ffmpeg on your system):

![Demo](./demo.mp4){role=video}

<!-- Auto-generates: /assets/demo-poster-a1b2c3d4.webp -->

To use your own poster, specify it explicitly:

![Demo](./demo.mp4){role=video poster=./custom-poster.jpg}

Automatic PDF Previews

PDFs without an explicit preview attribute get an auto-generated preview thumbnail (requires pdf-lib):

![Report](./report.pdf)

<!-- Auto-generates: /assets/report-thumb-a1b2c3d4.webp -->

To use your own preview, specify it explicitly:

![Report](./report.pdf){preview=./report-cover.jpg}

Media Assets: Images, Videos, Documents, and Icons

Media uses the standard image syntax ![alt](url) but the role attribute determines which content array it goes into:

RoleOutput ArrayUse Case
image (default)imagesContent images
bannerimagesHero/banner images
galleryimagesGallery images
backgroundimagesBackground images
iconiconsIcons and small graphics
videovideosVideo content
pdfdocumentsDocuments — a file, with an optional preview, author, description

The roles above are conventions, not a fixed set. role rides through verbatim, so {role=wibble} reaches your component as role: "wibble"; it simply lands in images like any role that isn't icon, video or pdf. Only those three select a different array.

The media slot — content.media

images, videos and insets keep the author's order within one kind and lose it across kinds. content.media keeps it: every image, video and embedded component the author placed on its own line, in the order written, each tagged with its kind — 'image', 'video' or 'inset':

![Night launch](./launch.mp4){role=video}

![The crew](./crew.jpg)

![Trajectory](@TrajectoryChart)
media: [
  { kind: 'video', src: './launch.mp4', … },
  { kind: 'image', url: './crew.jpg', alt: 'The crew', … },
  { kind: 'inset', refId: 'inset_0' },
]

It is what a component with a media slot reads, and each entry in items has its own. Kit's <Visual> renders an item by its kind — <Visual media={content.media} block={block} /> renders the first. An image inside a sentence belongs to the text: it is in images and sequence, not media. An icon and a document are not visual media, so neither is in it.

Setting the Role

There are two ways to set the role:

1. Prefix syntax (legacy):

![Logo](icon:./logo.svg)
![Demo](video:./demo.mp4)
![Report](pdf:./report.pdf)

2. Attribute syntax (recommended):

![Logo](./logo.svg){role=icon}
![Demo](./demo.mp4){role=video}
![Hero](./hero.jpg){role=banner}

The attribute syntax is more flexible—it allows combining role with other attributes:

![Demo](./demo.mp4){role=video autoplay muted loop poster=./poster.jpg}

Image Attributes

![Alt text](./image.jpg){width=800 height=600 loading=lazy fit=cover}
AttributeDescription
widthImage width
heightImage height
loadinglazy or eager
fitCSS object-fit: cover, contain, etc.
positionCSS object-position
placementWhere it sits in content rendered in order: left, right, wide or full — see Placement
authorWho made it — a credit. Kit's <Render> shows it in the caption line

Video Attributes

![Demo](./video.mp4){role=video autoplay muted loop controls poster=./thumb.jpg}
AttributeDescription
autoplayAuto-play on load
mutedStart muted
loopLoop playback
controlsShow video controls
posterPoster/thumbnail image
placementWhere it sits, as an image's — see Placement

Document Attributes

A document is a file a visitor opens or downloads — a PDF, say. The image syntax places it; role=pdf makes it a document.

![Annual report](./report.pdf){role=pdf preview=./cover.jpg author="Finance team" description="Results for the year"}
AttributeDescription
previewAn image of the document — a cover — shown in its place
authorWho wrote it
descriptionWhat it is. It describes the file, where alt describes an image

It arrives in content.documents, not content.images:

documents: [{
  url: './report.pdf', name: 'report.pdf', mime: 'application/pdf',
  alt: 'Annual report', caption: '', role: 'pdf',
  preview: './cover.jpg', author: 'Finance team', description: 'Results for the year',
}]

A document names its file as a file record does — url, name, mime, size — so one component can render a downloads list from either. name and mime come from the address. size is known only where the file is, so it is there only when a producer knows it: a build that copies a local file into dist/ adds it, and gives the file's own name too, since the copy's address carries a content hash.

A component that renders its content in order gets documents from content.sequence too; kit's <Render> and <Article> draw each as a link to the file, with its preview (or a file badge) and its name.

Icons

Icons can be referenced in three ways:

1. URL-based icons (any SVG file):

![Logo](./logo.svg){role=icon}
![Logo](icon:./logo.svg)

2. Named icons from icon libraries:

![check](lucide:check)
![arrow](lucide:arrow-right){size=20}
![heart](lucide:heart){size=24 color=red}

Supported icon library prefixes (browse icons visually at react-icons.github.io/react-icons):

Prefix (friendly name)Short codeLibrary
lucide:luLucide
heroicons:hiHeroicons v1
heroicons2:hi2Heroicons v2
feather:fiFeather
phosphor:piPhosphor
tabler:tbTabler Icons
bootstrap:bsBootstrap Icons
material-design:mdMaterial Design
ant-design:aiAnt Design Icons
remix:riRemix Icon
simple-icons:siSimple Icons
ionicons:io5Ionicons 5
boxicons:biBoxicons
vscode:vscVS Code Codicons
weather:wiWeather Icons
game:giGame Icons
fa: / fa6:fa / fa6Font Awesome 5 & 6

Icon attributes:

AttributeDescription
sizeIcon size in pixels (default: 24)
colorIcon color (CSS color value)

Important: Named icons require foundation support. The foundation must include the icon library to render named icons. See Foundation Configuration for setup instructions.

If your foundation doesn't include an icon library, use URL-based icons instead:

![check](/icons/check.svg){role=icon}

Clickable Images and Videos

Images and videos can be links—clicking them navigates to the specified URL:

![Product Screenshot](./screenshot.jpg){href=/products/details}
![Demo Video](./demo.mp4){role=video href=/demo target=_blank}
images: [
  {
    url: './screenshot.jpg',
    alt: 'Product Screenshot',
    href: '/products/details',
  },
]
videos: [{ src: './demo.mp4', href: '/demo', target: '_blank' }]

Components can wrap the media in a link element when href is present:

function Image({ src, alt, href, target }) {
  const img = <img src={src} alt={alt} />
  return href ? (
    <a href={href} target={target}>
      {img}
    </a>
  ) : (
    img
  )
}

Insets (Inline Component References)

Use image syntax with an @ prefix to place a foundation component inline within content:

![description](@ComponentName){param=value}

The parts carry distinct information:

  • [description] — text passed to the component as block.content.title
  • (@Name) — foundation component to render (must have inset: true in meta.js)
  • {params} — configuration attributes passed as component params
![Architecture diagram](@NetworkDiagram){variant=compact}
![Cache metrics](@PerformanceChart){period=30d}
![](@GradientBlob){position=top-right}

Insets appear in content.insets[] as { refId } entries (parallel to content.images[]). They also appear in content.sequence[] as { type: "inset", refId } entries for positional rendering.

At runtime, inset Block instances are available via block.insets (separate from block.childBlocks). Kit's Render component handles inset_placeholder nodes in the content flow automatically, rendering the corresponding component at the author's chosen position.

Components that need a visual slot (image, video, or inset) can use <Visual> from @uniweb/kit — pass the candidates you want considered (inset, video, image) and it renders the first non-empty one.

Block Insets (Component Containers)

An inset written with image syntax is a leaf — it takes params but no body. When the component should wrap authored content, use the fenced form: a code fence whose info string is @ComponentName plus optional params.

```@Alert{type=warning}
Back up your database **before** running this.

- The migration is not reversible
- Allow ten minutes of downtime

The two forms are the same mechanism at different scales:

| | Leaf inset | Block inset |
|---|---|---|
| Written | `![alt](@Name){params}` | ` ```@Name{params} ` … ` ``` ` |
| Position | Anywhere, including mid-sentence | Block level only |
| Body | None — `alt` becomes the title | Markdown, parsed as blocks |

**The body is ordinary content, not text.** It is parsed exactly like the rest of
the page, so everything works inside a container: headings, lists, tables, links,
icons, inline styling, leaf insets, and other containers. Params use the same
attribute syntax as everywhere else.

**Nesting needs a wider fence.** To put a code block — or another container —
inside one, open the outer fence with more backticks than the inner one:

````markdown
````@Details
How do I undo this?

```bash
uniweb rollback --to previous

The component comes from your foundation. @Alert names a component the foundation provides, exactly as ![](@NetworkDiagram) does — both resolve through the same lookup. A name the foundation does not define renders as a plain bordered container that still shows its body; content is never dropped for the sake of an unknown component.

The component receives { content, params, block } like any section type, and its content is the parsed body — title, paragraphs, items, sequence. Render it with kit's <Prose content={content} block={block} />, or reach into the parsed fields directly:

export default function Alert({ content, params }) {
  return (
    <aside className={`alert alert-${params.type || 'info'}`}>
      <Prose content={content} />
    </aside>
  )
}

Declare it with inset: true in meta.js, the same as a leaf inset.

Inline Text Styling

Style inline text using bracketed spans with named attributes—Pandoc-style syntax that works in both markdown files and the visual editor.

Basic Syntax

[text]{name}
[text]{name key=value}

The framework provides two default styles: accent (colored + bold) and muted (subtle). Sites can define additional styles in theme.yml's inline: section.

Common Styles

Build [faster]{accent} with structure.

Here's a [side note]{muted} that's less prominent.

Your component receives these as <span> elements in paragraph text:

paragraphs: [
  'Build <span accent="true">faster</span> with structure.',
]

The CSS is generated from theme.yml using attribute selectors (span[accent] { ... }), so the boolean flag syntax maps directly to styling.

Inline Style Escape Hatches

For one-off styling without defining a named style:

[red text]{color=red}
[highlighted]{bg=yellow}

These render as inline styles: <span style="color: red">red text</span>.

Combining with Other Formatting

Spans work with bold, italic, and other inline formatting:

This is [**bold and accented**]{accent} text.
Check the [_italicized note_]{muted} below.

Defining Custom Styles

Add named styles in theme.yml:

inline:
  accent:
    color: var(--link)
    font-weight: '600'
  muted:
    color: var(--subtle)
  highlight:
    background: var(--accent-100)
    color: var(--accent-900)

These generate span[name] { ... } CSS rules. Content authors write [text]{name} — no CSS knowledge needed. Styles that reference semantic tokens (like var(--link)) adapt to context automatically.

Math (LaTeX)

LaTeX math in markdown is compiled to MathML Core at build time and rendered natively by the browser. No runtime math library, no KaTeX CSS, no CDN — the MathML ships inside the page HTML.

Three forms (Pandoc / GitHub / VS Code / Jupyter / Obsidian convention):

FormModeExample
$x$InlineThe identity $e^{i\pi} + 1 = 0$ is Euler's.
$x$Display on its own line; inline display mid-paragraph$\int_0^\infty e^{-x^2}\,dx = \tfrac{\sqrt{\pi}}{2}$
```math fenceDisplay (multi-line friendly)see below
\$Literal $The price is \$20.

Multi-line display math is easier to read with a fence:

```math
\begin{aligned}
  f(x) &= \sum_{n=0}^\infty a_n x^n \\
       &= a_0 + a_1 x + a_2 x^2 + \cdots
\end{aligned}
```

What it looks like

Everything below this line is live — it is authored in this page's markdown exactly as shown above, so what you see is what the pipeline produces.

Inline, in running prose. A sentence can carry math without breaking its line: the Gaussian integral ∫−∞∞e−x2dx=π\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi} sits in the paragraph, and so does a rate like O(nlog⁡n)O(n \log n) or a threshold of p<0.05p < 0.05. Symbols work the same way — α\alpha, β\beta, σ2\sigma^2 — as do fractions such as 34\tfrac{3}{4}.

Display, on its own line. A formula the reader is meant to stop at goes on its own:

∑k=1nk=n(n+1)2\sum_{k=1}^{n} k = \frac{n(n+1)}{2}

Fenced, for anything multi-line. Alignment survives, which is what makes derivations readable:

(a+b)2=(a+b)(a+b)=a2+ab+ba+b2=a2+2ab+b2\begin{aligned} (a + b)^2 &= (a + b)(a + b) \\ &= a^2 + ab + ba + b^2 \\ &= a^2 + 2ab + b^2 \end{aligned}

Numbered equations

Which equations carry a number is decided in the LaTeX, the same way it is in a paper: equation and align number their rows, while aligned and the starred forms equation* / align* do not. Nothing to configure — write the environment you mean.

```math
\begin{equation}
\nabla \cdot \mathbf{E} = \frac{\rho}{\varepsilon_0}
\end{equation}
```

That renders live below, tag and all:

∇⋅𝐄=ρε0\begin{equation} \nabla \cdot \mathbf{E} = \frac{\rho}{\varepsilon_0} \end{equation}

These tags are not cross-reference numbers. They are LaTeX's own counter, running over every auto-numbered environment on the page. A [#id] reference counts something different — the elements you labelled — so on a page that uses both, an equation's printed tag and the "Equation 1" in a sentence are answering two different questions and need not match. Pick one convention per document and the ambiguity disappears.

Matrices and cases work too:

A=(1234)|x|={xx≥0−xx<0A = \begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix} \qquad |x| = \begin{cases} x & x \ge 0 \\ -x & x < 0 \end{cases}

Disambiguating $...$. Dollar-delimited inline math requires (1) no whitespace next to either delimiter and (2) no digit immediately after the closing $. Currency sentences therefore stay prose without any escaping, and this one is live: it costs $5 and $10 total, with a budget of $200.

For a literal dollar the rules cannot save — a lone $ that happens to pair with another one later in the same paragraph — escape it as \$. That is worth knowing precisely because it is easy to trip: an earlier draft of the paragraph above ran on past its last figure and picked up a $ further down the line, turning the sentence that claims currency stays prose into rendered math.

Tables

A table is GitHub's: a header row, a separator row, then the rows. The line after it — Pandoc's caption line, a paragraph that starts with : — gives the table a caption, and its trailing braces a cross-reference label and a placement:

| Plan  | Price |
| ----- | ----- |
| Basic | $10   |

: Plans and prices {placement=wide #tbl-plans}

Either part may stand alone — : Plans and prices, or : {#tbl-plans}.

  • A blank line comes first. A line directly under the rows is another row.
  • The caption is plain text. Formatting in it is left out, with a warning, and its words are kept.
  • The braces take #id and placement=…, and nothing else. Anything else in braces is part of the caption, so : Revenue by {year} keeps its placeholder.

A component reads a table in content.sequence as { type: 'table', rows, attrs }, with caption, id and placement in attrs when the caption line set them. Kit's <Render> writes the caption as the table's <caption>.

Cross-references

Label a figure, equation, table or heading with {#id}, then refer to it with [#id]. The number is worked out at render time, so inserting a figure ahead of another renumbers every reference to it.

$
E = mc^2
$
{#eq-energy}

As shown in [#eq-energy], mass and energy are equivalent.

{#id} goes on the element itself — after a display-math block, in the attribute braces of an image or heading, or on a table's caption line. Four kinds are recognized without any configuration:

You labelIt becomesA reference reads
A headinga section§1.2
An imagea figureFigure 1
Display mathan equationEquation 1
A tablea tableTable 1

Figures, equations and tables are counted straight through the page; only sections nest. One marker can point at several targets — [#eq-energy;#fig-cell] — and the label is written once for the group.

A reference links to what it names. The label is a link to the labelled element, and the id becomes an anchor on the element itself — so /reference/content-structure#eq-binomial lands on the equation, and an EPUB or PDF built from the same content gets real internal links. A marker naming several targets stays plain text, since one link cannot lead to both.

Ids are content, not presentation. #eq-energy says what this is, never how it looks — which is why it is the one thing curly braces carry that isn't a parameter (see There is no CSS-class syntax).

Live

The equation below is labelled {#eq-binomial} in this page's markdown:

(a+b)2=a2+2ab+b2(a + b)^2 = a^2 + 2ab + b^2

Expanding a square of a sum gives Equation 1, which is the identity the derivation above works through step by step.

That sentence is written as gives [#eq-binomial], in the source. What you are reading is the resolved label — the number came from the registry, not from the markdown.

A foundation decides whether references resolve. They are not automatic: a foundation opts in by declaring xref and registering a Ref component, and one that never labels anything pays nothing for the machinery. Where nothing has opted in — or where a marker points at an id that was never declared — the reference renders as its own literal text, brackets and all, so a broken one is visible rather than silently blank.

Math flows through the same pipeline as the rest of content — it appears in prerendered HTML, survives EPUB and Paged.js compilation, and roundtrips cleanly through the editor. Malformed LaTeX renders as an inline <span class="temml-error"> containing the source, so authors see that something is wrong without breaking the page. Foundations can style .temml-error in their theme CSS if they want visible error feedback.

Links are collected in the links array. Attributes control behavior and styling.

[Learn more](/about)
[External](https://example.com){target=_blank}
[Download](./report.pdf){download}
[Download as](./report.pdf){download="annual-report.pdf"}
AttributeDescription
target_blank, _self, etc.
relnoopener, noreferrer, etc.
downloadMake it a download link

The parser intelligently handles links based on context:

Links in text stay as paragraphs with inline HTML:

Visit our [about page](/about) to learn more.
paragraphs: ['Visit our <a href="/about">about page</a> to learn more.']

Link-only paragraphs become link objects—useful for CTAs and navigation:

[Get Started](/signup)
links: [{ href: '/signup', label: 'Get Started' }]

Multiple links on consecutive lines split into separate link objects:

[Home](/)
[About](/about)
[Contact](/contact)
links: [
  { href: '/', label: 'Home' },
  { href: '/about', label: 'About' },
  { href: '/contact', label: 'Contact' },
]

This makes it easy to create button groups or nav links without special syntax.

When a link-only paragraph contains an adjacent icon, the parser associates them:

![](/icons/home.svg){role=icon} [Home](/)
links: [
  {
    href: '/',
    label: 'Home',
    iconBefore: { url: '/icons/home.svg' },
    iconAfter: null,
  },
]

Icons can appear before or after the link text:

[External Link](https://example.com) ![](/icons/external.svg){role=icon}
links: [
  {
    href: 'https://example.com',
    label: 'External Link',
    iconBefore: null,
    iconAfter: { url: '/icons/external.svg' },
  },
]

Note: Icon association only works for single-link paragraphs where the relationship is unambiguous. In paragraphs with multiple links, icons are collected separately in the icons array.

Clickable Icons

Icons can be links themselves—useful for social media buttons and icon-only navigation:

![Twitter](/icons/twitter.svg){role=icon href="https://twitter.com/example" target=_blank}
![GitHub](/icons/github.svg){role=icon href="https://github.com/example" target=_blank}
icons: [
  {
    url: '/icons/twitter.svg',
    href: 'https://twitter.com/example',
    target: '_blank',
  },
  {
    url: '/icons/github.svg',
    href: 'https://github.com/example',
    target: '_blank',
  },
]

Components can check icon.href to render clickable icons differently from decorative ones.

Links can include optional attributes that components may use as rendering hints:

[Get Started](/signup){button variant=primary size=lg}
[Pricing](button:/pricing)
[Learn More](/docs){target=_blank}
[Download PDF](/file.pdf)

Available attributes:

AttributeValuesDescription
buttonA flag, or the button: prefix on the URLThe link is a button: its role is button
roleAny string (e.g., nav)Hint for component styling
variantprimary (the default), secondary, outline, ghostA button's visual style hint
sizesm, md, lgSize hint
iconAn icon name (lu-arrow-right) or pathIcon hint
target_blank, _self, _parent, _topWhere the link opens — any other value opens it in the same tab

Important: These attributes are hints—components decide how to render links. A Hero component might render all links as buttons regardless of attributes, while a Footer component might render them as plain links.

Components receive all links in the content.links array:

const { links } = content

links.forEach((link) => {
  console.log(link.href)
  console.log(link.label)
  console.log(link.role)     // Optional hint from author
  console.log(link.variant)  // Optional styling hint
  console.log(link.target)   // Link target
})

File links (.pdf, .doc, etc.) automatically include download: true for browser handling.

Document-Order Rendering with Sequence

While most components use the semantic fields (title, paragraphs, items), some components need to render content in exact document order—like an Article or Blog Post component.

The sequence array provides all elements in their original order:

const { sequence } = content

sequence.forEach((element) => {
  switch (element.type) {
    case 'heading':
      return <Heading level={element.level}>{element.text}</Heading>
    case 'paragraph':
      return <Paragraph html={element.text} />
    case 'image':
      return <Image src={element.attrs.src} alt={element.attrs.alt} />
    case 'list':
      return <List items={element.children} style={element.style} />
    case 'blockquote':
      return <Blockquote>{element.children}</Blockquote>
    case 'codeBlock':
      return (
        <CodeBlock language={element.attrs.language}>{element.text}</CodeBlock>
      )
    case 'inset':
      // Rendered automatically by kit's Render component
      return <InsetRenderer refId={element.refId} block={block} />
    // ... other types
  }
})

When to use which:

ApproachUse CaseExample Components
Semantic fieldsStructured layouts with specific slotsHero, Features, Pricing, Team
sequenceDocument-order flowArticle, Blog Post, Documentation

You can also combine both—use semantic fields for the header area and sequence for the body.

Placement — where a block sits

An author can say where an image, a video or a table sits in content rendered in order:

![The harbour at dawn](harbour.jpg){placement=wide}
placementThe block sits
(none)in the text column
left · rightbeside the text, on that side, and the text wraps around it
widewider than the text column
fullacross the whole width of the section

A table takes it on its caption line: : Plans and prices {placement=wide}. Any other value is left out, with a warning.

It is the author's intent, not a size. A component that renders the content in order may honour it, and any other component ignores it. The element carries it as attrs.placement. Kit's <Render> — and <Prose> and <Article>, built on it — writes it as data-placement on the block's box, and @uniweb/kit/prose-tokens.css lays the four out inside a .prose container: left and right at half the column, the whole column on a narrow screen; wide past the column on both sides; full across the nearest size container (container-type: inline-size), else the viewport. A foundation restyles [data-placement], or sets --prose-side-width, --prose-wide-bleed and --prose-full-width.

A section type declares the placements it honours — sequence: { placement } in its meta.js. A visual editor offers an author only those, and the build — like the dev server and uniweb validate — says when a file asks for one its section type does not offer.

Semantic Heading Interpretation

Important: Heading levels in markdown are relative, not absolute. A # (H1) in your markdown doesn't necessarily become an <h1> in the final HTML.

The parser interprets headings based on their relationship to each other:

## Welcome ← This becomes `title` (it's the first/main heading)

### Getting Started ← This becomes `subtitle` (it's after the title)

Some content here.

### Features ← This starts an `item` (heading after content)

The same semantic structure can be expressed with different heading levels:

# Welcome ← title

## Getting Started ← subtitle

or:

### Welcome ← title

#### Getting Started ← subtitle

Both produce the same content.title and content.subtitle. The component decides what HTML elements to use. A hero component might render title as <h1>, while a card component might render it as <h3>.

Adjacent Levels Only

A heading groups with the previous heading only when it is exactly one level deeper. Skipping levels breaks the group:

# Features        ← title
## Our key areas  ← subtitle (one level deeper — groups with title)
# Features        ← title (alone — H3 is two levels deeper, not grouped)

### Speed         ← starts a new group → item
### Security      ← starts a new group → item

The level gap signals a structural tier change: ### Speed is an item under # Features, not a subtitle of it. If you want a subtitle, use the adjacent level (##).

Subtitle Lines

The headline can step down more than once. Each further one-step descent — and each same-level repeat — adds another subtitle line, so subtitle is a string for one line and an array for several:

# DevConf 2027    ← title
## June 12 – 14   ← subtitle line 1
### Lisbon        ← subtitle line 2
content.subtitle // ["June 12 – 14", "Lisbon"]

A CV header (name / role / affiliation), an event cover, or a book page all fit in one headline this way. Designs that use a single subtitle read the first line.

Pretitle Detection

The explicit spelling is the #> label line — it names the block that starts next, with no level to choose:

#> Welcome to ← pretitle (label line)

# Acme Corp ← title

## Build faster ← subtitle

Any number of leading #s spells the same label (##> and #> are identical); repeat label lines for a multi-line pretitle. A label directly above an item's heading becomes that item's pretitle, and a label opening an untitled block labels the block itself.

Older content: a smaller ordinary heading directly above a more important one is also read as a pretitle — stacked smaller headings all join it — so files written before label lines keep working. Prefer #> in anything new; it does not depend on what follows it.

### Welcome to ← pretitle (H3 before H1)

# Acme Corp ← title

This positional form works at any level: H3 → H1, H2 → H1, H4 → H2, H6 → H5.

Multi-Line Headings

Consecutive headings at the same level merge into a title array — perfect for dramatic, multi-line hero headlines:

# Build the future
# with confidence
content.title // ["Build the future", "with confidence"]

Kit's text components (<H1>, <H2>, etc.) render arrays as a single HTML element with visual line breaks.

With accent styling — the classic hero pattern:

# Build the future
# [with confidence]{accent}
content.title // ["Build the future", "<span accent=\"true\">with confidence</span>"]

With pretitle and subtitle:

#> Our Mission
# Build the future
# with confidence
## The platform for modern teams
content.pretitle  // "Our Mission"
content.title     // ["Build the future", "with confidence"]
content.subtitle  // "The platform for modern teams"

Important: Same-level merging only applies within the heading group at the top of a section. Once body content begins and items start, same-level headings create separate items instead of merging. Use --- to force a split when needed:

# Line one
---
# Line two

Here, --- separates the two headings into different groups rather than merging them.

Items: Child Content Groups

The items array contains child content groups. A new item starts whenever a heading appears after other content (paragraphs, images, etc.). Each item has the same field structure as the main content.

Use items when your component displays repeating content—feature cards, pricing tiers, team members, FAQ questions.

Convention: Use a higher-level heading for the main title and lower-level headings for items. This makes the structure clear, but the parser is flexible—any heading after content starts a new item.

# Pricing

Choose your plan.

### Starter

$9/month

Perfect for individuals.

[Get Started](/signup?plan=starter){button}

### Pro

$29/month

For growing teams.

[Get Started](/signup?plan=pro){button variant=secondary}
// In your Pricing component
const { title, paragraphs, items } = content

items.forEach((tier) => {
  console.log(tier.title) // "Starter", "Pro"
  console.log(tier.paragraphs) // ["$9/month", "Perfect for..."], ...
  console.log(tier.links) // [{ href: "/signup?plan=starter", role: "button", ... }]
})

Lists

The lists field contains markdown bullet or numbered lists. Each list is an array of list items, and each list item has the same structure as content (paragraphs, links, nested lists, etc.):

- First item with **bold** text
- Second item with a [link](/path)
  - Nested item
- Third item
// lists is an array of lists (usually just one)
// Each list is an array of list items
// Each list item has: paragraphs, links, lists (nested), etc.

const { lists } = content

lists[0].forEach((item) => {
  console.log(item.paragraphs) // ["First item with <strong>bold</strong> text"]
  console.log(item.links) // [{ href: "/path", label: "link" }] for second item
  console.log(item.lists) // Nested lists array (for items with sub-lists)
})

Structure:

lists: [                           // Array of lists in the content
  [                                // First list (array of list items)
    {                              // First list item
      paragraphs: string[],
      links: Link[],
      lists: [...],                // Nested sub-lists
      ...                          // Same fields as content
    },
    ...
  ]
]

Important: List items are not plain strings. They're objects with the same structure as content, allowing rich formatting, links, and nested lists within each item.

Fenced Code in Content

Fenced code in markdown serves three distinct purposes, decided by its info string:

Info stringWhat it is
```yaml:tag / ```json:tagData — parsed into a value at content.data[tag]
```md:tagA concept block — prose under a name, at content.data[tag]
```js, ```sh, …A code sample, in content.snippets

Tagged Data Blocks

A tagged data block is structured data that gets parsed into a JS object. The format (yaml or json) tells the parser how to deserialize — it's a serialization format, not a display language. The tag is the key in content.data:

```yaml:form
fields:
  - name: email
    type: email
    required: true
  - name: message
    type: textarea
submitLabel: Send Message
```
const formConfig = content.data?.form
// { fields: [...], submitLabel: "Send Message" }

Supported formats: yaml (or yml) and json. The tag name is yours to choose — yaml:pricing, json:config, yaml:speakers — whatever describes the data.

Code Snippets

Fenced code without a tag is a code snippet — display content with a language for syntax highlighting. Snippets are collected in content.snippets:

```jsx
function Hello() {
  return <h1>Hello world</h1>
}
```
content.snippets[0]
// { language: 'jsx', code: 'function Hello() {\n  return <h1>Hello world</h1>\n}' }

The language attribute is a display hint for syntax highlighting renderers, not a parsing format. Filter by language when needed: content.snippets.filter(s => s.language === 'css').

The Distinction

Tagged data blocksCode snippets
Syntax```yaml:tagname```javascript
PurposeStructured data for the componentDisplay content
Language meansSerialization format (how to parse)Display hint (how to highlight)
Destinationcontent.data.tagName (JS object)content.snippets (array of { language, code })
Formatsyaml, jsonAny language identifier

Both also appear in content.sequence for document-order rendering.

For repeating structured content that lives in its own files — blog posts, team members, events — see Records: records in markdown (.md for rich content), YAML or JSON, reached by a query, with i18n extraction included.

Concept Blocks

A ```md:<tag> fence marks a run of prose as a named kind of content. The author writes ordinary markdown; the tag says what it is.

```md:faq
# What plans do you have?
Three — Starter, Team and Enterprise.

# Can I change later?
Any time, and we prorate the difference.
```

Each # heading starts an item, so this reaches a component as content.data.faq:

{
  items:    [{ title, paragraphs, links, ... }, ...],  // one per heading
  sequence: [...]                                      // the same content, in document order
}

The shape comes from the fence, not the tag. Any tag works and none is special — no schema is consulted, and nothing in the framework learns what faq means. A body with no headings is the same block holding one titleless item, which is what a callout is:

```md:warning
Back up your database **before** running this. It is not reversible.
```

Why not a component reference. ```@Alert names *which component renders this* — a rendering decision living in content. md:warning names what the content is and leaves rendering to the foundation, so the same content survives a change of foundation, and an editor can recognize the concept and offer a surface suited to it.

Read items for anything row-shaped, and sequence when you do not recognize the tag and want to render it faithfully in order. Both are derived on demand; what gets stored is the prose.

A warning and a note are different concepts, so they are md:warning and md:note — the fence takes no parameters.

GitHub alert syntax

The five GitHub alert kinds are a second spelling of the same thing. Authors already know it, so it is accepted as written:

> [!NOTE]
> Useful information a reader should notice even when skimming.

NOTE, TIP, IMPORTANT, WARNING and CAUTION become content.data.note, .tip, .important, .warning and .caution — the same node a ```md:<tag> fence produces, with the same { items, sequence }. A marker outside that set is left alone and stays an ordinary blockquote.

Whichever spelling you write is the one you get back. The two are interchangeable going in and preserved going out, so an editor round trip never rewrites your file into the other form.

Live, so you can see what a foundation does with them:

This block is authored as a GitHub alert in this page's markdown.

Rendering is the foundation's decision. A concept block arrives as a bare <div data-concept="warning"> with no styling at all, so on its own it reads as ordinary prose. Importing @uniweb/kit/callout-tokens.css gives the five standard kinds the look above, coloured from the site's own theme.yml.

Dividers as Separators

You can also use horizontal rules (---) to separate items instead of headings:

# Team

---

![](/sarah.jpg)

**Sarah Chen**

Lead Engineer

---

![](/alex.jpg)

**Alex Rivera**

Designer

This creates two items without requiring headings for each.

Runtime Guarantees

The runtime guarantees all fields exist—you don't need defensive null checks:

// These are always defined (empty string/array if not in content)
const { title, paragraphs, links, images, items, data } = content

// Safe to use directly
paragraphs.forEach((p) => console.log(p))
items.map((item) => <Card {...item} />)

Nesting: Items, Insets, Subsections, and Child Pages

There are four ways to create nested content, each for a different purpose:

ApproachWhat it isWhen to use
ItemsHeadings in one markdown fileRepeating content within a single section (cards, features)
Insets@Component references in markdownIllustrations, charts, widgets placed inline in content
Block insets```@Component fences wrapping markdownA component that wraps authored content: callouts, disclosures
SubsectionsSeparate section files in the same page folderComplex sections needing their own component type
Child pagesSubfolders in pages/Separate pages with their own routes

Items (same section)

Use items for repeating content that shares the same component—feature cards, pricing tiers, FAQ questions. Just use headings after content in your markdown file.

Section nesting (child sections)

Use section nesting when a parent section type needs children — a Grid arranging cards, a TabGroup holding panels. Mark child files with the @ prefix and declare relationships with nest::

pages/home/
├── page.yml
├── 1-hero.md              # Top-level section
├── 2-features.md          # Parent section (type: Grid)
├── 3-cta.md               # Top-level section
├── @card-speed.md         # Child of features (@ = not top-level)
├── @card-security.md      # Child of features
└── @card-scale.md         # Child of features
# page.yml
nest:
  features: [card-speed, card-security, card-scale]

Rules:

  • @-prefixed files are excluded from the top-level section list
  • nest: declares parent-child relationships (parent stable name → array of child names)
  • Child files must use the @ prefix — the filename and YAML must agree
  • @@ prefix signals deeper nesting (e.g., @@sub-item.md for grandchildren)
  • Children are ordered by their position in the nest: array
  • The parent component receives children via block.childBlocks

Each child file can specify its own type: in frontmatter. Alternatively, sections: also supports inline nesting with the same @ prefix convention (see page configuration).

Child pages (separate routes)

Subfolders create entirely separate pages with their own routes:

pages/
├── docs/            → /docs
│   ├── page.yml
│   ├── intro.md
│   ├── getting-started/   → /docs/getting-started
│   │   └── ...
│   └── api/               → /docs/api
│       └── ...
└── about/           → /about

Key point: Each folder is its own page with its own route. Parent and child folders don't conflict—/docs and /docs/getting-started are separate pages that both exist.

The index: setting in site.yml only controls which page becomes the root / route—it doesn't affect other pages or create any "container" behavior.

Note for developers coming from other site frameworks: Uniweb treats every page folder as a distinct route. Nested pages do not replace or "take over" their parent folder.

  • /docs builds to dist/docs/index.html (when prerendering is enabled)
  • /docs/getting-started builds to dist/docs/getting-started/index.html

Both pages exist independently—no conflict, no overwriting.

How Attributes Reach Components

Attributes written in markdown flow through the entire pipeline without filtering. Understanding how they arrive helps you decide what to use and what to ignore.

Two Channels

Attributes reach components through two distinct channels depending on the content type:

1. Object properties — on images[], links[], icons[]

When a content author writes ![Logo](./logo.svg){role=icon color=red}, the parser extracts attributes into an object. Your component receives:

content.icons[0]
// { library: undefined, name: undefined, src: './logo.svg', role: 'icon', color: 'red' }

Kit components like <Icon>, <Link>, and <Image> destructure the properties they understand and spread the rest onto the DOM element via ...props. So color=red becomes an HTML attribute on the <svg>.

2. Inline HTML — in title, paragraphs[], items[].title, etc.

When attributes appear on inline text or within paragraph content, they're serialized as HTML before reaching the component. For example:

This has [important]{callout color=red} information.

Your component receives:

content.paragraphs[0]
// 'This has <span callout="true" style="color: red">important</span> information.'

Kit's <Text>, <H1>, <H2>, and <P> components render these strings with dangerouslySetInnerHTML, so the span and its attributes appear in the DOM as written. That is safe because the parser builds the string from structure: every character of the text is escaped, so a < an author types shows as < and never opens a tag.

What Survives the Pipeline

An attribute reaches the component when the object it is written on carries it, and when it cannot run anything:

StageWhat happens
Markdown{key=value #id} parsed into an object
Content readerStored in the ProseMirror node's attrs — an image or a link keeps the attributes it knows (width, height, loading, target, role, class, id, …), a span keeps every name
Semantic parserSpread onto content objects (images, links, icons), or serialized as HTML (text) with the text escaped
prepare-propsPassed through — only guarantees structure
ComponentReceives what is left, and decides what to use

Content never carries script. In the HTML strings, a span keeps class, id, a colour (color, bg) and named styles — a letter, then letters, digits, _ or -, like callout or bigRed, never a name starting with on — and drops anything else, an event handler (onclick) included. A link keeps a relative URL or an http:, https:, mailto:, tel: or page: one; any other, such as javascript:, leaves its text unlinked. Markup that rides in content by design — a math formula's MathML, an icon's SVG — is rebuilt from an allowlist of elements and attributes. And HTML an author writes in markdown is not markup at all: it shows as written, except <br/> (see Writing Content).

The Foundation as Gatekeeper

This creates a deliberate design choice for foundations: how much control do you give content authors?

Permissive — spread everything onto the DOM:

function FeatureCard({ icon, title, paragraphs }) {
  return (
    <div>
      <Icon {...icon} />  {/* All icon attributes reach the <svg> */}
      <H1 text={title} /> {/* Inline HTML rendered as-is */}
    </div>
  )
}

This is what Kit components do by default. Content authors can set the attributes each object carries — an icon's color and size, an image's class — and they work. It's a useful escape hatch — a content author can write ![](lu-star){color=var(--primary-600)} to tint an icon without the foundation needing a color param.

Restrictive — pick only what you support:

function FeatureCard({ icon, title, paragraphs }) {
  // Only pass the attributes this foundation supports
  const { library, name, size } = icon
  return (
    <div>
      <Icon library={library} name={name} size={size} />
      <H1 text={title} />
    </div>
  )
}

Here, color=red on an icon is silently ignored. The foundation preserves its branding because it controls exactly which properties reach the DOM.

Both are valid. A design-system foundation serving multiple sites might be strict to enforce brand consistency. A personal site foundation might be permissive because the content author and developer are the same person.

Transforming Inline HTML

Since text fields (paragraphs, title, etc.) arrive as HTML strings, a foundation that wants to intercept inline attributes needs to parse the HTML. DOMParser works for this:

function StyledParagraph({ text }) {
  // Transform inline attributes before rendering
  const transformed = useMemo(() => {
    const doc = new DOMParser().parseFromString(text, 'text/html')

    // Example: remap color values to design tokens
    doc.querySelectorAll('span[style]').forEach(span => {
      const color = span.style.color
      if (color && !color.startsWith('var(')) {
        // Replace raw colors with the nearest design token
        span.style.color = `var(--primary-600)`
      }
    })

    // Example: strip classes the foundation doesn't support
    doc.querySelectorAll('[class]').forEach(el => {
      const allowed = ['highlight', 'muted', 'callout']
      el.className = [...el.classList].filter(c => allowed.includes(c)).join(' ')
    })

    return doc.body.innerHTML
  }, [text])

  return <p dangerouslySetInnerHTML={{ __html: transformed }} />
}

This is an advanced technique — most foundations won't need it. But it's available when a foundation needs to enforce constraints on inline styling while still allowing authors to use the attribute syntax.

See Also