Tutorial: landing page components

This tutorial builds the three components every landing page needs – a hero banner, a call-to-action band, and a teaser grid – as declared components. You write each one twice: a YAML manifest that declares its attributes, and a Jinja template in your theme that renders its markup. An author then composes the whole landing page in one markdown file, with tag syntax, and Accent validates every use against your declarations.

The tag syntax is the Markdoc language, which Accent implements natively in Rust. You do not need to know that to follow along – but it means the syntax you learn here is a documented language, not a house dialect.

By the end you will have:

  • a hero component that wraps a body and takes typed attributes,
  • a cta component that is self-closing and takes everything as attributes,
  • a teasers component that composes its teaser children into a card grid,
  • one markdown file that uses all three, and validates in CI.

How this compares to the other approaches

Accent has three ways to build a landing page, and they compose rather than compete:

ApproachContent lives inTheme workBest for
Components (this tutorial)one markdown file, tag syntaxa manifest and template per componentreusable blocks any page can drop in, on any page template
Modular pagesone _-prefixed subdirectory per sectiona modular template that loops page.modulessections with rich, independent frontmatter
Structured sectionsone file with ---section: name--- markersa template that reads page.sections.<name>a fixed layout with a few named regions

Components are the only one of the three that needs no dedicated page template: the landing page below renders through your ordinary default template, because the components carry their own markup. One thing to check in that template: most themes render page.title as an <h1>, and the hero below renders its own. Give the landing page a template without the title heading, or drop the <h1> from the hero and let the page title carry it – two <h1> elements on one page is a heading-order defect that no validation catches. That also means an author can drop a cta into the middle of a docs page later – the components you build here are not tied to the landing page.

Before you begin

You need a theme you can edit. Components live in your theme’s shortcodes/ directory, one manifest and one template per component:

themes/mytheme/shortcodes/
├── hero.yaml             the declaration
├── hero.html.jinja       the markup
├── cta.yaml
├── cta.html.jinja
├── teasers.yaml
├── teasers.html.jinja
├── teaser.yaml
└── teaser.html.jinja

The dev server hot-reloads both files, so keep accent serve running and refresh as you go. If you have not read Declared components yet, skim it first – this tutorial applies what that page defines.

The examples reuse the button component the default theme ships. If your theme does not have one, the reference shows its manifest and template.

The page you will write

This is the finished landing page, so you know where the tutorial is going. One file, no custom template, three components:

---
title: Home
template: default
---

{% hero title="Ship your site in minutes" kicker="Accent CMS" image="/landing/hero.webp" %}
One binary, markdown in, HTML out. No build chain to babysit.

{% button variant="primary" href="/docs/getting-started" %}Get started{% /button %}
{% /hero %}

{% teasers heading="Why teams pick Accent" %}
{% teaser title="One binary" href="/docs/getting-started" %}
Install a single executable. Serve, build, and validate with the same tool.
{% /teaser %}
{% teaser title="Markdown first" href="/docs/markdown-guide" %}
Your content is plain files in git, rendered through a fast native pipeline.
{% /teaser %}
{% teaser title="Themes you own" href="/docs/theming" %}
Jinja templates and plain CSS. No lock-in, no runtime JavaScript required.
{% /teaser %}
{% /teasers %}

{% cta title="Ready to try it?" text="Free to build and to serve locally. Pay when you go to production." label="Download" href="/download" /%}

Everything an author can get wrong here – a misspelled attribute, a missing required one, a value outside an enum – is reported by accent validate before it reaches a reader.

Step 1: the hero

A hero is a block component: it wraps a body (the supporting copy) and takes its headline and imagery as attributes.

Declare it

# themes/mytheme/shortcodes/hero.yaml
name: hero
description: A full-width banner for the top of a landing page.
params:
  - name: title
    type: string
    required: true
    description: The headline.
  - name: kicker
    type: string
    required: false
    description: A short line rendered above the headline.
  - name: image
    type: media
    required: false
    description: A shared media path for the banner image, such as /media/landing/hero.webp.
  - name: align
    type: enum
    values: [left, center]
    required: false
    description: How the text is aligned. Defaults to center.
body: markdown

Three declarations doing real work:

  • required: true on title means a hero without a headline fails validation instead of rendering an empty <h1>.
  • type: enum on align closes the value set. accent validate reports align="middle" as an error naming the allowed values. A serve or build run logs the same finding as a warning and still renders the page, so run the validator in CI if you want the value set enforced rather than reported.
  • type: media records that the value is a media path rather than free text. In the template it arrives as a string, which you pass to media().

Write the image value as the path under your shared media directory: media("/landing/hero.webp") emits /media/landing/hero.webp. The shipped card and image components read their media attributes the same way. media() also accepts a value that already starts with /media/, and accent validate points such a value out.

Render it

{#- themes/mytheme/shortcodes/hero.html.jinja

    The landing page banner. `attrs` holds the declared attributes, escaped;
    `body` is the tag's inner markdown, with any nested components already
    expanded, and is marked safe so it is not escaped again.
-#}
{%- set align = attrs.align | default("center") -%}
<section class="hero hero-{{ align }}">
{%- if attrs.image %}
<img class="hero-image" src="{{ media(attrs.image) }}" alt="">
{%- endif %}
<div class="hero-inner">
{%- if attrs.kicker %}
<p class="hero-kicker">{{ attrs.kicker }}</p>
{%- endif %}
<h1 class="hero-title">{{ attrs.title }}</h1>
<div class="hero-copy">{{ body }}</div>
</div>
</section>

{{ body }} sits straight inside the <div>, and the copy still renders as markdown, because the manifest said body: markdown. What that buys you, and the one rule it leaves you, is under Let the body render as markdown below.

Component templates render through the same engine as your page templates, so every filter and function is available: media() resolves the image through the media pipeline, and default supplies the fallback alignment. Autoescaping is on for component templates – a title containing <script> reaches the page as text, while body is content the expander produced and is marked safe, so it is not escaped a second time.

A component also reads the same page context a page template does, so it can list and link beyond its own attributes:

VariableWhat it is
sitesite name, description, base URL, language
pagethe page the component sits on – page.url, page.title, page.language, and page.modules, each with its rendered content (its own body is not available here)
pagesthe menu-visible pages of the page’s language
all_pagesevery listable page, for a listing such as “five newest posts per tag”
taxonomythe tags of the page’s language
theme, dev, accenttheme assets, dev flags, and the running Accent version

A component’s own attrs, children and body win if a name clashes. With these a recent-posts component can filter all_pages by tag, and a footer band can read site.name – values you would otherwise repeat as an attribute on every use. A page whose component lists others is re-rendered when any page is added, removed, or changes what listings show of it (a tag, date, or status), in both accent serve and an incremental accent build.

On a sub-path deployment, Accent adds the prefix to every root-relative href, src and poster in a component’s output, as it does for a link in markdown. So in a component the URLs in page, pages and all_pages are root-relative, and so are the URLs url(), media() and the other URL helpers return: write {{ post.url }} as it is. site.base_path still holds the prefix, and absolute_url() still includes it. A root-relative URL in any other attribute, such as action or srcset, gets no prefix, so build it from site.base_path.

The image is decorative here, so its alt is empty. If your hero image carries meaning, declare an alt parameter the way the teaser card does in step 3.

Use it

{% hero title="Ship your site in minutes" kicker="Accent CMS" image="/landing/hero.webp" %}
One binary, markdown in, HTML out. No build chain to babysit.

{% button variant="primary" href="/docs/getting-started" %}Get started{% /button %}
{% /hero %}

Components nest. The button inside the body is expanded first, so the hero’s body already contains the rendered link by the time your hero template runs. Keep the blank line before it, so the button sits in its own paragraph rather than at the end of the previous one. The shipped button declares inline: true, which is what lets you write it on one line, or in a sentence, without a placement error from accent validate.

Let the body render as markdown

A component’s body is spliced into your page as markdown source, at the spot where your template interpolates {{ body }}. Whether it then renders depends on a CommonMark rule rather than on Accent:

A line that starts with an HTML tag opens a raw HTML block, and everything up to the next blank line is passed through literally.

That covers more than the block-level names: a complete tag alone on its line opens a block too, <img> and <a> included. A body written flush inside a <div> therefore reaches the page with its asterisks intact. The manifest is what prevents that: body: markdown tells Accent the body is block content, and Accent splices it between blank lines before your template runs. The raw HTML block closes, the body reaches the markdown parser like any other content, and your template can put {{ body }} wherever it likes:

<div class="hero-copy">{{ body }}</div>

Two things still bite, and each one is silent:

  • Do not indent the tag that closes after {{ body }}. Four spaces of indentation at the start of a line following a blank line is an indented code block, so an indented </div> prints as &lt;/div&gt;. The templates in this tutorial keep their closing tags in column 0.
  • A label is not block content. Blank lines inside an <a> or a <span> turn the label into a paragraph inside the link. Declare body: inline for such a component and the body is spliced flush, on the same line as the tag, with the newline a block placement leaves after it trimmed – which is what the shipped button does. Flush is what keeps it inline: the tag and the label share a line, so no raw HTML block opens.
  • <pre> and its kind cannot be opened up this way. A <pre>, <script>, <style> or <textarea> block runs to its own closing tag rather than to a blank line, so a component whose template wraps the body in one keeps a literal body whatever the manifest says. That is the right behaviour for preformatted text, and worth knowing before you declare body: markdown on such a component.

accent validate --templates reports both: a body: inline body placed on a line that starts with a block-level tag, and an indented line after a body: markdown body.

The body arrives as paragraphs once it renders as markdown, so style the paragraph rather than the container. A hero body that ends with a nested button is two paragraphs, not one, which is why the stylesheet below spaces them and only flattens the last.

Step 2: the call to action

A call to action has no free-form body – it is a title, a line of text, and a button, all of which the theme should control the shape of. So declare everything as attributes and omit body from the manifest, which makes the component self-closing.

Declare it

# themes/mytheme/shortcodes/cta.yaml
name: cta
description: A call-to-action band with one button.
params:
  - name: title
    type: string
    required: true
    description: The band's heading.
  - name: text
    type: string
    required: false
    description: One supporting sentence under the heading.
  - name: label
    type: string
    required: true
    description: The text on the button.
  - name: href
    type: reference
    required: true
    description: Where the button links to.

type: reference marks href as a link target rather than free text, the same way the shipped button declares its href. Pass it through url() in the template, as above: that keeps the slash literal instead of HTML-escaped. If the site is served from a sub-path, Accent adds your deployment prefix to the link.

Render it

{#- themes/mytheme/shortcodes/cta.html.jinja

    A full-width band with a single action. Everything comes from `attrs`;
    a self-closing component has no body.
-#}
<section class="cta">
  <h2 class="cta-title">{{ attrs.title }}</h2>
  {%- if attrs.text %}
  <p class="cta-text">{{ attrs.text }}</p>
  {%- endif %}
  <a class="btn btn-primary" href="{{ url(attrs.href) }}">{{ attrs.label }}</a>
</section>

Use it

{% cta title="Ready to try it?" text="Free to build and to serve locally. Pay when you go to production." label="Download" href="/download" /%}

The /%} closes the tag in place. Leaving out label or href is a missing-required-attribute error naming the attribute, because the manifest said so.

Step 3: the teaser grid

Teasers are the interesting one: a grid of cards where the container owns the layout and each card is a child tag. The container cannot recover a card’s title from a rendered body string, so its template reads children – the component’s immediate child tags, each with its own name, attrs, and body – exactly the way the shipped tabs component reads its tab children.

Declare both

The container wraps a body (the run of teaser children):

# themes/mytheme/shortcodes/teasers.yaml
name: teasers
description: A responsive grid of teaser cards. Its children are `teaser` components.
params:
  - name: heading
    type: string
    required: false
    description: An optional heading rendered above the grid.
body: markdown

Each card declares what a card needs:

# themes/mytheme/shortcodes/teaser.yaml
name: teaser
description: One card in a teaser grid. Belongs inside `teasers`.
params:
  - name: title
    type: string
    required: true
    description: The card's heading, which links to `href`.
  - name: href
    type: reference
    required: true
    description: Where the card links to.
  - name: image
    type: media
    required: false
    description: A shared media path for the card image.
  - name: alt
    type: string
    required: false
    description: Alternative text for the image. Defaults to empty, for decorative images.
body: markdown

Render both

The container composes its children:

{#- themes/mytheme/shortcodes/teasers.html.jinja

    Reads `children` instead of `body`: each card's attributes are data here,
    which a rendered body string could not give back.
-#}
<section class="teasers">
{%- if attrs.heading %}
<h2 class="teasers-heading">{{ attrs.heading }}</h2>
{%- endif %}
<div class="teaser-grid">
{%- for c in children if c.name == "teaser" %}
<article class="teaser">
{%- if c.attrs.image %}
<img class="teaser-image" src="{{ media(c.attrs.image) }}" alt="{{ c.attrs.alt | default("") }}">
{%- endif %}
<h3 class="teaser-title"><a href="{{ url(c.attrs.href) }}">{{ c.attrs.title }}</a></h3>
<div class="teaser-text">{{ c.body }}</div>
</article>
{%- endfor %}
</div>
</section>

A child’s body follows the same rule as a parent’s: the teaser manifest says body: markdown, so {{ c.body }} arrives between blank lines and the card text renders as markdown inside <div class="teaser-text">.

A child’s attrs are the same resolved, escaped values the parent’s own attrs are, so reading a child is no more privileged than reading an attribute.

Because this template renders children and never interpolates body, anything in the container that is not a teaser – an intro sentence between the cards, a stray button – is dropped from the output without a warning. A container that should carry prose as well as cards has to interpolate {{ body }} too.

The child still gets a template of its own, for the case where an author writes a teaser outside a teasers. Emitting the body unchanged is the honest fallback, and it is exactly what the shipped tab component does:

{#- themes/mytheme/shortcodes/teaser.html.jinja

    A `teaser` is normally read by its `teasers` parent. On its own, it is
    just its body.
-#}
{{ body }}

Use it

{% teasers heading="Why teams pick Accent" %}
{% teaser title="One binary" href="/docs/getting-started" %}
Install a single executable. Serve, build, and validate with the same tool.
{% /teaser %}
{% teaser title="Markdown first" href="/docs/markdown-guide" %}
Your content is plain files in git, rendered through a fast native pipeline.
{% /teaser %}
{% teaser title="Themes you own" href="/docs/theming" %}
Jinja templates and plain CSS. No lock-in, no runtime JavaScript required.
{% /teaser %}
{% /teasers %}

Adding a fourth card is one more line of content and zero theme changes – that is the split working: the author owns the cards, the theme owns the grid.

Style the components

The templates emit classed, semantic markup, so styling is ordinary CSS in your theme’s stylesheet. A minimal, framework-free starting point:

/* Hero */
.hero { padding: 4rem 1.5rem; }
.hero-center { text-align: center; }
.hero-image { max-width: 100%; margin-bottom: 2rem; }
.hero-kicker {
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 0.875rem;
  color: #6b7280;
}
.hero-title { font-size: 2.5rem; margin: 0.25rem 0 1rem; }
.hero-copy { font-size: 1.125rem; max-width: 40rem; margin: 0 auto; }
.hero-copy p { margin: 0 0 1rem; }
.hero-copy p:last-child { margin-bottom: 0; }
.hero-left { text-align: left; }
.hero-left .hero-copy { margin-inline: 0; }

/* Call to action */
.cta {
  text-align: center;
  padding: 3rem 1.5rem;
  margin: 3rem 0;
  background: #f4f4f5;
  border-radius: 0.5rem;
}
.cta-title { margin: 0 0 0.5rem; }
.cta-text { margin: 0 0 1.5rem; color: #52525b; }

/* Teaser grid */
.teasers { margin: 3rem 0; }
.teasers-heading { text-align: center; margin-bottom: 2rem; }
.teaser-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  gap: 1.5rem;
}
.teaser {
  border: 1px solid #e4e4e7;
  border-radius: 0.5rem;
  padding: 1.5rem;
}
.teaser-image { width: 100%; border-radius: 0.25rem; margin-bottom: 1rem; }
.teaser-title { margin: 0 0 0.5rem; }
.teaser-title a { text-decoration: none; }
.teaser-text p { margin: 0; }

The auto-fit grid means three cards make three columns on a wide screen and one column on a phone, with no per-count classes.

Validate the page

Run the validator the way CI does:

accent validate --config config.yaml

Every mistake an author can make against your manifests is reported with the page and the reason: an unknown component name, an undeclared attribute, a missing required one, an enum value outside the declared set. The same findings appear in dev-server logs and in the admin panel, and a tag Accent does not recognize is left literally in the page – visibly wrong rather than silently absent. The full list of what gets reported is in the reference.

Where to go next

  • Pull site facts into the copy. A component body can reference variables: Welcome to {% $site.name %}. stays correct when the site is renamed.
  • Share a block across landing pages. If the same CTA appears on several pages, move it into a partial and include it with {% partial file="shared/cta.md" /%} – editing the partial re-renders every page that uses it.
  • Know the portability trade. hero, cta, and teasers are your theme’s own components. Content that uses them depends on a theme that declares them, so a theme swap needs the new theme to bring the same names – see where a component can come from for how declarations from the built-in set, your theme, and plugins interact.
  • Sectioned frontmatter instead? When a landing page’s sections need rich per-section frontmatter – their own dates, authors, or custom fields – reach for modular pages and keep these components for the blocks inside each section.