Declared components

Accent used to expand [shortcode] syntax, which embedded a reusable component but declared nothing about it. Nothing knew which attributes [alert] took, so a misspelled attribute rendered as nothing and a typo in a variant name reached the page.

A declared component fixes that. You write a small YAML manifest beside the template, author the component with tag syntax, and Accent validates every use against the declaration.

This is the only component syntax. The square-bracket form has been removed, and accent migrate shortcodes rewrites a site that still uses it.

This page is the reference. For a hands-on walk-through that builds a hero, a call-to-action band, and a teaser grid and composes a landing page from them, see the landing page components tutorial.

Declare the component

A manifest lives beside its template, in your theme’s shortcodes/ directory:

themes/default/shortcodes/
├── callout.html.jinja    the markup
└── callout.yaml          the declaration
# themes/default/shortcodes/callout.yaml
name: callout
description: A titled box that sets a passage apart.
params:
  - name: type
    type: enum
    values: [info, success, warning, danger]
    required: false
    description: The kind of notice.
body: markdown

name must match the filename. params are the attributes, in the order you declare them. body says whether the component wraps content and how that content meets your template:

  • body: markdown is block content: paragraphs, lists, headings, nested components. Accent splices it between blank lines, so the block element your template puts it in closes first and the body renders as markdown. A block component written alone on its line inside its parent, as each tab is inside tabs, is a block placement even when its body shares the line. On a line with other text or another tag, in a list item, or at the top level of a page, it is reported as misplaced.
  • body: inline is a label or a phrase that stays on its line, such as a button’s text. Accent splices it flush; inline markdown in it still renders.
  • body: none means the component takes no body and is written self-closing, such as {% chart /%}. Omitting body means the same thing; none states it. A body passed to such a component is reported, with the self-closing spelling to write instead.

placement says where the component may be written: block for a block of its own between blank lines, inline for inside a line of prose, or both. Omit it and the placement follows inline:: an inline component may sit in either place, a block one is reported when written inside a line of prose. Declare placement: both for a component that wraps one line of text and stands between paragraphs – an install command in a card – because a tag whose body shares its line parses as inline even when it stands alone. A call in a placement the manifest excludes is reported, naming the declaration. The shipped button manifest declares placement: both.

Placement says where the component may be written; inline: says how its markup is spliced. A combination that would validate one shape and render another is rejected when the manifest loads: placement: inline needs inline: true, and placement: block needs it absent. both is legitimate with either splice.

Attribute types come from the same vocabulary document models use: string, number, boolean, enum, list, object, reference, and media.

A media value is a path under the shared media directory, such as /landing/hero.webp for media/landing/hero.webp, a remote URL, or a path relative to the page. The component template passes an absolute path to media(), which emits the served URL, and anything else to url(). accent validate warns about an absolute path the shared media route would not serve, and one written with the /media/ URL prefix, which media() still accepts. A media item inside a list or object attribute is not checked.

A list declares what each item is under items:: a type, and the values, items or fields that type needs. An object declares its fields under fields:, each written like a parameter, with a name and optionally required and description. They nest, so a list of links is:

# themes/default/shortcodes/menu.yaml
name: menu
params:
  - name: links
    type: list
    required: true
    items:
      type: object
      fields:
        - name: label
          type: string
          required: true
        - name: href
          type: reference
          required: true

Content writes the value as an array of objects, and the template reads it like any other attribute:

{% menu links=[{label: "Home", href: "/"}, {label: "Blog", href: "/blog"}] /%}
<ul>{% for link in attrs.links %}<li><a href="{{ url(link.href) }}">{{ link.label }}</a></li>{% endfor %}</ul>

accent validate checks every item and field, and names where a value does not match, such as links[1].label. A list without items: or an object without fields: is a manifest error, because nothing would say what the value holds.

A manifest error also fails accent build, the way a broken link does: a manifest that does not register demotes its component to undeclared, and every page using it would ship with a component-shaped hole. accent build --lax reports it as a warning instead and builds the page without the component, which suits a preview of a site mid-change.

This theme’s related component takes its links that way, and the list below is one:

Inline components

By default a component renders as a block of its own: Accent puts blank lines around its markup so the surrounding markdown reads it as a block. A component meant to sit inside a line – a badge in the middle of a sentence, or in a heading – declares inline: true:

name: edition
description: An inline badge naming the license tier a feature needs.
inline: true
params:
  - name: edition
    type: enum
    values: [free, core_plus, standard, pro]
## Integrate {% edition edition="pro" /%}

Without inline: true that heading would be split into three blocks.

button declares it too, for a different reason: a button is written in a sentence as often as on a line of its own, and {% button href="/x" %}Go{% /button %} on one line is an inline placement. An inline component is valid in both positions; a block component written inside a line is reported as misplaced by accent validate.

Components that read their children

Most components wrap a body. One kind composes: a tabbed switcher has to emit a button per panel, carrying that panel’s label, before any panel. By the time the parent renders, its body is one string of markup and a label cannot be recovered from it – so a component template can read children, the component’s immediate child tags, each with its own attributes and body.

{# themes/default/shortcodes/tabs.html.jinja #}
<div class="tabs" data-tab-group="{{ attrs.group | default("default") }}">
  <div class="tabs-nav" role="tablist">
  {%- for c in children if c.name == "tab" %}
    <button role="tab" data-tab="{{ c.attrs.label | slugify }}">{{ c.attrs.label }}</button>
  {%- endfor %}
  </div>
  {%- for c in children if c.name == "tab" %}
  <div class="tabs-panel" role="tabpanel">{{ c.body }}</div>
  {%- endfor %}
</div>
{% tabs group="os" %}
{% tab label="macOS" %}Install with Homebrew.{% /tab %}
{% tab label="Linux" %}Install with the script.{% /tab %}
{% /tabs %}

Each 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. A component that reads children normally ignores body, because it is rendering the children itself.

Author with it

{% callout type="warning" %}
Back up your content directory before upgrading.
{% /callout %}

A self-closing component takes no body; its manifest declares body: none, or omits the field. The shipped edition badge is one:

{% edition edition="pro" /%}

The shipped button is the worked example of a label body. Its manifest declares both keys:

name: button
description: A call-to-action link styled as a button.
params:
  - name: variant
    type: enum
    values: [default, primary, secondary]
    required: false
  - name: href
    type: reference
    required: true
body: inline
inline: true
placement: both

and its template keeps the label on the line, inside the link:

{%- set variant = attrs.variant | default("default") -%}
<a href="{{ url(attrs.href) }}" class="btn btn-{{ variant }}">{{ body }}</a>

Passing href through url() is what keeps the link correct on a sub-path deployment, and keeps the slash literal rather than HTML-escaped.

It is written on one line, in a sentence or on a line of its own:

Ready? {% button variant="primary" href="/download" %}Download{% /button %}

Values you interpolate are escaped, so an attribute containing <script> reaches the page as text.

A component body renders as markdown. The body is spliced into the page as markdown source at the spot where the template interpolates {{ body }}, and the manifest’s body value decides how. A body: markdown body arrives between blank lines, so a template can put it straight inside a <div> and it renders: paragraphs, lists, headings, fenced code, and nested components all work, through the same pipeline as the rest of the page. A body: inline body arrives flush, which is what a label inside an <a> or <span> needs. Two things to keep in the template: write {{ body }} and the tag that closes after it in column 0, because an indented line after a blank line is an indented code block; and declare body: inline for a label, because blank lines inside an inline element turn the label into a paragraph. accent validate --templates reports both mistakes.

Variables

A component attribute can reference a variable with $ (title=$frontmatter.title), and a variable written as a tag of its own writes its value into the text, in a component’s body or in the page’s markdown:

{% callout type="info" %}
Welcome to {% $site.name %}.
{% /callout %}

The value is written as text, never as markup: Kapp & <b>Co</b> reads as exactly that, and smart punctuation treats it as it treats the page’s own text. Inside a fence that processes tags ({% process=true %}) or a diagram’s source, the value is written as it is. Tags inside raw HTML are not read, so a variable there stays as written. A map has no text: it stays as written, and the build log says so.

accent validate names an unknown $site key or namespace. A $frontmatter key is optional, so {% if $frontmatter.draft %} asks whether a page has one, and a page without it is no finding.

Four namespaces are available:

VariableWhat it holds
$sitename, description, language, url, base_path
$frontmatterthe page’s own frontmatter, including your custom fields
$templatethe template rendering the page
$editionthe licensed edition, when the site supplies one

Nothing else is available, deliberately. The clock, environment variables, data files, remote URLs, and anything that varies per request are all unavailable, because Accent’s page cache cannot tell when they change. A page built from one of them would serve stale output until an unrelated edit happened to clear the cache. The three sources above are exactly the three the cache knows how to invalidate.

Conditionals

{% if $frontmatter.draft %}
This page is a draft.
{% else /%}
This page is published.
{% /if %}

Conditions are evaluated by Accent’s own expression engine, not by the template language. Content stays declarative: it can select between branches, but it cannot run arbitrary template code.

Partials

A partial splices another markdown file into the page:

{% partial file="shared/upgrade-notice.md" /%}

The name is resolved against your content directory first, then your theme’s partials/ directory. A partial may include another; a cycle is reported rather than followed. A partial is never a dotfile, a file under a dot directory, a file a symbolic link takes outside those directories, or the site’s config file. On a page whose markdown is remote content, a partial is not spliced, and the build says why.

Partials are tracked. Editing a partial re-renders every page that includes it – in the dev server and in an incremental accent build alike – because Accent records which files each page was built from. Deleting a partial re-renders its former includers too.

Canonical components

Components are theme-scoped, so a page that uses a component only one theme declares breaks when the site switches to another theme. The canonical components are the part of the vocabulary you can rely on: every conforming theme implements each of them with the same attribute names, so content that uses only these renders under any conforming theme.

ComponentAttributesWhat it is
callouttype, title, collapsiblea titled box for a note, a warning, or another notice
accordionsummary, opena section collapsed behind a summary line
buttonhref, varianta link styled as a button
cardtitle, href, imagea block with an optional image, title, and link
tablenonethe built-in table; the theme styles it
taghrefa small inline label
imagesrc, alt, captionan image with alt text and an optional caption
breadcrumbnonea trail of links written in Markdown
tabs / tabgroup / label, idtabbed panels

Names are fixed and values are a floor. A conforming theme declares every attribute above, and where an attribute has fixed values, the theme accepts at least those: callout takes info, success, warning, and danger, and button takes default, primary, and secondary. A theme can add attributes, values, and components of its own; content that uses them depends on that theme.

Markup landmarks. Each theme writes its own markup and classes, but every conforming theme marks the same landmarks, so a script or a stylesheet that finds a callout’s title or a tab’s panel works under any conforming theme. The component’s root element carries data-canonical with the component’s name, and each part carries data-part:

ComponentParts
callouttitle
accordionsummary; the root is a <details> element
tabstablist, and a tab and a panel for each tab
cardtitle and image, when those attributes are set
imageimage, and caption when it is set
button, tag, breadcrumbthe root only; a button is an <a> or a <button>, and a breadcrumb is a <nav>
<div class="callout callout-warning" role="complementary" aria-label="Heads up" data-canonical="callout"><p class="callout-title" data-part="title">Heads up</p>
...
</div>

A theme you write for the canonical components carries the same attributes.

The set was chosen by measurement. A component is canonical when at least three of four CSS frameworks – Bulma, Pico CSS, Foundation, and USWDS – style it and content writes it, rather than page chrome such as a navigation bar. tabs is the exception: two of the four have it, and it is canonical because the documentation Accent ships uses tabs throughout.

accent validate checks the active theme against the set, with or without a page that uses a missing component, and reports each gap as an error. When a page uses a component the theme does not declare, the finding names the theme and says whether the component is canonical:

error  /docs/example
       line 8: theme `minimal` does not implement `card`, one of the canonical
       components every theme is expected to provide; add it to the theme, or
       use a theme that implements the canonical components
       [accent::component-not-in-theme]

A theme that provides no components, such as a marketing site’s theme, declares that in theme.yaml, and accent validate does not check it:

components: none

For a site part way through a theme change, accent validate --lax reports a theme’s gaps, and pages using a canonical component the theme lacks, as warnings that do not fail the run. A name that no theme, builtin, or plugin declares stays an error under --lax, because it may be a misspelling.

Builtin components

Five components need something a theme template is never given, so Accent renders them itself. Every theme has them, and neither a theme nor a plugin can redefine them.

ComponentWhat it rendersAttributes
downloada file beside the page, as a card with its name, type, and sizesrc (required), title, show_size, class
videoa video beside the page, with its captionssrc (required), captions, poster, width, controls, autoplay, loop, muted
diagrama diagram, the way a fenced diagram block renders it, with a caption and alt texttype (required), src, theme, width, height, caption, alt, class
searchthe site’s search box, for the active search backendplaceholder, limit
include-filea file of the site, as a highlighted code block that stays in step with the filesrc (required), lang, lines, region, dedent, title
{% download src="report.pdf" /%}

{% video src="talk.mp4" captions="talk.vtt" poster="talk.jpg" /%}

{% diagram type="mermaid" caption="Publishing flow" alt="A draft goes to review, then to publishing" %}
flowchart LR
  A[Draft] --> B{Review} --> C[Publish]
{% /diagram %}

{% search placeholder="Search the docs" limit=5 /%}

{% include-file src="nginx.conf" /%}

download and video serve files the page owns: put the file in the page’s directory and name it. Both refuse a URL, because Accent can neither read a remote file’s size and type nor serve it. For a PDF, download renders the same card as the pdf_card() template function.

The file must also be a type Accent serves. Images, video, audio, PDFs, text and data files such as .csv and .json, and .vtt captions are served as they are. Any other type, such as .zip, needs its extension in media.attachments.extensions, which serves it as a download:

media:
  attachments:
    extensions: [zip]

diagram takes its source from the tag’s body, or from a file beside the page named by src, not both. It renders through the same pipeline, cache, and diagrams.on_error policy as a fenced block. search renders what search_form() renders, and nothing while search is turned off.

Captions are part of video. Name a WebVTT (.vtt) caption file with captions, or write captions="none" when the video has no speech or no captions exist. accent validate reports a video with neither, and accent validate --lax relaxes that finding. A SubRip (.srt) file is refused with a message to convert it, because browsers load WebVTT captions only.

accent validate also reports a src that names a file the page does not have, listing the files it does have, and a builtin given a URL. Both stay errors under --lax.

Include a file

include-file shows a file’s contents as a code block. The page reads the file each time it renders, so the code on the page never drifts from the file. Editing the file updates the page in accent serve, and the next accent build renders the page again, with or without --clean.

{% include-file src="plan.yml" /%}

{% include-file src="../server/src/main.rs" region="handler" title="main.rs" /%}

{% include-file src="/docs/examples/benchmark.yml" lines="1-12" /%}

src takes one of two forms:

  • A relative path resolves from the directory the page’s file is in, like a relative link on GitHub.
  • A site path, starting with /, resolves through the mount whose path it starts with, into that mount’s source. With no matching mount, it resolves from the content directory. The rest of the path names files on disk, not page URLs.

Any file in the content directory or a mount’s source can be included. A single-file mount opens the directory its file is in, and every directory below it, so a mounted README.md can show the files it describes. Mount a repository’s root README.md, and every file in the repository can be shown on a page. A path that resolves outside all of them is refused, and so is a symbolic link that points outside them. Dotfiles and files under a dot directory such as .git/ are never read, and neither is a file under the content directory that the site hides from publishing, nor the site’s config file: the file itself, under its name, a link or another name for it. A page whose markdown is remote content, from a remote mount or accent serve <url>, reads no file of the site at all, with include-file or a partial: a third party writes its markdown. accent validate and the build report such a tag as a warning, which fails no build, so a third party’s edit cannot fail yours. A copy is an ordinary file, so keep backups of the config file out of the content directory. A file must be UTF-8 text and at most 256 KiB.

The language comes from the file’s extension, or from the whole name for a file without one, such as Dockerfile. Set lang to choose another.

To show part of a file, give lines or region, not both:

  • lines counts lines as your editor shows them: 12, 12-40, 12-, -40, or a list such as 1-3,10-12.

  • region shows the lines between a line holding ANCHOR: name and a line holding ANCHOR_END: name. Put the markers in comments, so they work in any language:

    fn main() {
        // ANCHOR: handler
        let reply = handle(request);
        // ANCHOR_END: handler
    }
    

    Lines holding a marker are never shown, whichever region they belong to, so one file can carry several regions.

The shown lines lose the indentation they share, so a region from inside a function starts at the left margin. Set dedent=false to keep it. Blank lines before and after the shown lines are dropped. title adds a caption above the block, in a figure with the class include-file.

A tag that cannot be rendered stays in the page as written. accent validate reports it as an error, naming the reason, and accent build fails on it, the way it fails on a broken link. accent build --lax reports it as a warning instead. To write include-file as an example rather than run it, put it in a code block, as on this page.

Mark code inside a fence

No syntax highlighter can say “this substring is the part the reader should look at” – a highlighter colours a quoted shell string, not the filter you are teaching. You make that editorial call with the mark component.

Your fences are literal by default: braces in them are code, and nothing in this section applies until you opt a fence in with {% process=true %} on its info string. Inside an opted-in fence, wrap the part that matters:

```console {% process=true %}
$ kubectl get pods | yqr -r {% mark role="filter" %}'.items[]'{% /mark %}
```

Your fence renders as an unhighlighted code block – you took over emphasis – with the marked part in a classed span:

<pre data-language="console"><code>$ kubectl get pods | yqr -r <span class="mark mark-filter" data-part="mark">'.items[]'</span>
</code></pre>

A mark around whole lines is the same component: a tag alone on its line absorbs the line, so the span wraps cleanly. Marking the hit line in a YAML snippet:

```yaml {% process=true %}
spec:
{% mark role="hit" %}  replicas: 3
{% /mark %}  template: x
```

Both live, on this page:

$ kubectl get pods | yqr -r '.items[]'
spec:
  replicas: 3
  template: x

role says what the marked code is, and reaches the mark-<role> class for the theme to style. The default theme styles filter (the part of the command being taught), hit (the line the reader should find), and key and value (the halves of a pair); any other value renders with the base mark style. Everything else about fences is unchanged: a fence without {% process=true %} renders exactly as before, highlighted and literal, so a page documenting Markdoc itself needs no escaping. The fence’s text – without the mark syntax and without the span markup – is what reaches search and llms-full.txt. One placement to avoid: inside a blockquote the opt-in is not read through the > prefix and the fence stays literal, so lift a marked fence out of the quote. A fence in a list item works.

Where a component can come from

Every declared component records where it was declared, and three sources are possible: Accent’s own built-in tags, your theme, and an installed plugin. When two sources declare the same name, the higher-precedence one wins:

built-in  >  theme  >  plugin

A plugin cannot redefine a tag your theme declares, and neither can redefine a built-in. This matters because a schema does not control one tag’s markup – it controls what counts as valid content across your whole site, so installing a plugin must not silently change what your existing pages mean. When a declaration is shadowed, Accent logs which source won and which lost.

Two declarations at the same level are an error naming both files, never a silent overwrite.

{% for %} is not content

Components share {% %} with the theme’s template language, and that is the one thing worth knowing before you start. Template control flow belongs in the theme. A page cannot loop, include, or extend, and writing

{% for item in $items %}

in a page is reported as its own kind of problem rather than as a misspelled component:

error  /docs/example
       line 12: `{% for %}` is template syntax, not content. Template control
       flow belongs in the theme's `.html.jinja` files, which content cannot
       reach. If you meant a component, declare one in the theme's
       `shortcodes/` directory and use its name here.

The reserved words are for, endfor, include, extends, block, set, macro, call, filter, raw, with, and import. A component whose name merely starts with one – setting, for-each – is unaffected, and a reserved word inside a code fence or backticks is prose, not a tag, so pages that document template syntax are fine.

What gets reported

accent validate reports every component problem, so a broken tag fails CI instead of reaching a reader. The same findings appear in the admin panel and in dev-server logs.

ProblemWhat you see
A component the theme does not declarethe tag is left in the page, and the error names the theme and whether the component is canonical
A theme missing a canonical componentreported against the theme, whether or not a page uses the component
A builtin given a URL, a file the page lacks, a file type Accent does not serve, or .srt captionsthe tag is left in the page, and the error names the file
A video with no captionsreported; --lax relaxes it
An include-file whose file is missing, outside the site, hidden, too large, or not UTF-8, or whose lines or region does not match the filethe tag is left in the page, and the error names the reason; accent build fails unless you pass --lax
Template syntax in contentthe directed message above, under its own check
Undeclared attributereported with the attribute name
Wrong type, or a value outside an enumreported with what was expected
Missing required attributereported with the attribute name
Manifest that will not parsereported with the file path; other components still load
Manifest with no templatereported at load, not on the first page that uses it

Migrate your existing shortcodes

accent migrate shortcodes rewrites [shortcode] content into tag syntax. It reports what would change and writes nothing until you pass --write:

accent migrate shortcodes --config config.yaml
accent migrate shortcodes --config config.yaml --write

It rewrites a shortcode only when it can verify the result, and leaves everything else exactly as you wrote it, with the file, the line, and the reason. Shortcodes inside fenced code blocks are never touched, and neither is frontmatter.

Tell it what your positional arguments meant. Shortcodes take bare words; tags take named attributes. Add a migrate block to the manifest so the command does not have to guess:

name: callout
params:
  - name: type
    type: enum
    values: [info, success, warning, danger]
body: markdown
migrate:
  positional: [type]   # [callout warning] -> {% callout type="warning" %}

Without it, [callout warning] is reported rather than rewritten.

Tell it what a renamed component used to be called. When a theme renames a component, list the old name under migrate.from, with any attribute it renamed. The default theme renamed alert to callout and details to accordion, so its callout manifest says:

migrate:
  positional: [type]
  from:
    - name: alert
      rename:
        variant: type   # [alert variant="warning"] -> {% callout type="warning" %}

A component declared under the shortcode’s own name still wins, so a theme that keeps an alert component migrates [alert] to it.

Useful flags:

FlagWhat it does
--writeapply the rewrite; without it nothing is written
--only NAMEmigrate only this component; repeatable
--include-frozenalso rewrite pages in frozen documentation versions

Pages in a frozen documentation version are reported but not rewritten, because a frozen version is errata-only and its content hashes are recorded. Passing --include-frozen takes them, and records the new hashes as it goes, so accent validate stays clean. The report names every page it rebaselined – those are released documents that changed after release, and you should be able to see which.

Check the result with a build diff. Build before and after, and compare:

accent build --output /tmp/before --clean
accent migrate shortcodes --write
accent build --output /tmp/after --clean
diff -rq /tmp/before /tmp/after

Rendered pages should be identical. The word count in each page’s structured data, and llms-full.txt, read your markdown source rather than the rendered page, so those do change – everything else changing is worth investigating.