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: markdownis 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 eachtabis insidetabs, 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: inlineis 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: nonemeans the component takes no body and is written self-closing, such as{% chart /%}. Omittingbodymeans the same thing;nonestates 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:
| Variable | What it holds |
|---|---|
$site | name, description, language, url, base_path |
$frontmatter | the page’s own frontmatter, including your custom fields |
$template | the template rendering the page |
$edition | the 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.
| Component | Attributes | What it is |
|---|---|---|
callout | type, title, collapsible | a titled box for a note, a warning, or another notice |
accordion | summary, open | a section collapsed behind a summary line |
button | href, variant | a link styled as a button |
card | title, href, image | a block with an optional image, title, and link |
table | none | the built-in table; the theme styles it |
tag | href | a small inline label |
image | src, alt, caption | an image with alt text and an optional caption |
breadcrumb | none | a trail of links written in Markdown |
tabs / tab | group / label, id | tabbed 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:
| Component | Parts |
|---|---|
callout | title |
accordion | summary; the root is a <details> element |
tabs | tablist, and a tab and a panel for each tab |
card | title and image, when those attributes are set |
image | image, and caption when it is set |
button, tag, breadcrumb | the 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.
| Component | What it renders | Attributes |
|---|---|---|
download | a file beside the page, as a card with its name, type, and size | src (required), title, show_size, class |
video | a video beside the page, with its captions | src (required), captions, poster, width, controls, autoplay, loop, muted |
diagram | a diagram, the way a fenced diagram block renders it, with a caption and alt text | type (required), src, theme, width, height, caption, alt, class |
search | the site’s search box, for the active search backend | placeholder, limit |
include-file | a file of the site, as a highlighted code block that stays in step with the file | src (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:
-
linescounts lines as your editor shows them:12,12-40,12-,-40, or a list such as1-3,10-12. -
regionshows the lines between a line holdingANCHOR: nameand a line holdingANCHOR_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.
| Problem | What you see |
|---|---|
| A component the theme does not declare | the tag is left in the page, and the error names the theme and whether the component is canonical |
| A theme missing a canonical component | reported 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 captions | the tag is left in the page, and the error names the file |
A video with no captions | reported; --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 file | the tag is left in the page, and the error names the reason; accent build fails unless you pass --lax |
| Template syntax in content | the directed message above, under its own check |
| Undeclared attribute | reported with the attribute name |
| Wrong type, or a value outside an enum | reported with what was expected |
| Missing required attribute | reported with the attribute name |
| Manifest that will not parse | reported with the file path; other components still load |
| Manifest with no template | reported 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:
| Flag | What it does |
|---|---|
--write | apply the rewrite; without it nothing is written |
--only NAME | migrate only this component; repeatable |
--include-frozen | also 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.