Skip to main content

Listing Templates

Write a custom listing template, and port one from Quarto 1’s EJS.

A listing can render through a template you write instead of one of the built-in layouts. Listings covers the configuration and the values a template can read; this page covers the parts that are easy to get wrong, the anatomy of the built-in layouts, and how to port a Quarto 1 EJS template.

Two rules on this page matter more than the rest, because breaking either one produces no error, no warning, and no visible difference in the template’s text. A ported listing looks finished, renders without complaint, and ships broken links or missing images. They are the first two sections.

Descriptions and the placeholder envelope

When an item has no description of its own — no description: under listing-item:, no description:, no abstract: — Quarto fills one in from the item page itself: the first paragraph of the rendered document, truncated at a word boundary. This happens after both documents are rendered, as a substitution pass over the host page, and it only happens inside a marker envelope your template emits:

::: {.listing-description}
```{=html}
$it.description-placeholder-begin$
```

$if(it.description)$
$it.description$
$endif$

```{=html}
$it.description-placeholder-end$
```
:::

Four things about that shape are load-bearing:

The markers go outside the $if$, not inside. An item with no paragraph Quarto could find before rendering (a page whose text comes from running code, say) has no $it.description$, but its rendered page may still have a paragraph. The envelope has to exist for exactly those items, so emit it unconditionally.

The markers are empty unless the item needs one. For an item whose description was written by its author, both markers are empty strings and the description is shown as written. For an item whose description is derived, $it.description$ holds the page’s first paragraph as written, which is what shows where the substitution pass doesn’t run (the browser-based editor); the pass replaces it with the rendered page’s paragraph.

The markers are HTML comments, so they belong in a raw-HTML block (```{=html}). They are stripped from the finished page whether or not a substitution happened.

Wrap the envelope in a div. The substituted preview is plain text with no block structure of its own, so a container gives it a styling hook and keeps it separated from the surrounding content. The built-ins use .listing-description.

To keep the description linked, put the envelope inside the link, using inline raw HTML and guarding the empty markers. This is what the built-in grid layout does:

[$if(it.description-placeholder-begin)$`$it.description-placeholder-begin$`{=html}$endif$$if(it.description)$$it.description$$endif$$if(it.description-placeholder-end)$`$it.description-placeholder-end$`{=html}$endif$]($it.path$)

The truncation limit is the listing’s max-description-length (default 175). Setting it to 0 disables truncation. If the rendered page has no usable first paragraph, the markers are simply removed, leaving whatever the $if$ produced; when that is a description found before rendering, Quarto emits Q-12-13.

image-placeholder-begin / -end work the same way for images: they are empty when the item’s image was written by its author, and otherwise wrap the derived image (or the empty placeholder <div>), which the substitution pass upgrades to the rendered page’s preview image.

Guard optional values with $if$

Most per-item values are absent rather than empty when an item does not have them, and reading an absent value directly is a warning, not a silent blank:

Warning [Q-12-10]: Listing `listing` doctemplate produced
1 diagnostic(s); first: Undefined variable: it.description

So $it.description$ on its own is only correct for values that are always present — title, path (whenever the item has a link target at all), and the four placeholder markers. Everything else wants a guard:

$if(it.subtitle)$[$it.subtitle$]{.listing-subtitle}$endif$

What the built-in layouts emit

A custom template is spliced into the same listing container as a built-in one, so matching the built-in class names is what lets your template inherit the listing CSS. The container is a div with id listing-<id> — #listing-listing for a page’s only listing when it sets no id: — and classes quarto-listing and quarto-listing-container-<type>; when the page has a ::: {#<id>} slot for the listing, that div becomes the container and is renamed. Inside it, each built-in layout is a wrapper template that applies a per-item partial:

Layout Wrapper element Per-item partial
default ::: {.list .quarto-listing-default} item-default
grid ::: {.list .grid .quarto-listing-grid .quarto-listing-cols-N} item-grid
table ::: {.quarto-listing-table-wrapper} item-table

item-default emits a .quarto-post with thumbnail, body and metadata regions. item-grid emits a Bootstrap card — .quarto-grid-item.card with .card-img-top, .card-body, .card-attribution. item-table is a single line, $table-row$, because the table layout’s cells are pre-rendered by Quarto into that one value.

You can apply the built-in partials from your own template, which is the cheapest way to customise a layout — wrap or interleave the built-in item rendering rather than reproducing it:

::: {.list .my-listing}
$for(items)$
$if(it.pinned)$
::: {.pinned-banner}
Pinned
:::
$endif$
$it:item-default()$
$endfor$
:::
WarningApply item partials inside $for(items)$, on a line of their own

Quarto drops the final newline of a partial, as Pandoc does. Each built-in item partial ends with the ::: that closes its div, so it is the line break after $it:item-default()$ inside the loop that keeps one item’s closing fence apart from the next item’s opening fence.

The shorthand $items:item-default()$ applies the partial to every item with nothing in between. The output then contains lines like :::::: {.quarto-post}, which the parser reads as a single opening fence: every item opens inside the one before it, and with enough items the listing exceeds the nesting limit and is left out of the page. The same applies to any partial of yours that ends in a closing :::.

The full sources are the best reference for idiomatic style, and they are short: crates/quarto-core/src/project/listing/templates/.

Porting a Quarto 1 EJS template

Quarto 1 listing templates were EJS — embedded JavaScript, under a .ejs or .ejs.md extension. Quarto 2 does not run EJS: a carried-over template warns at the extension (Q-12-9) and at its contents (Q-12-24), and the listing is left out of the page rather than pasted in raw.

Give the ported file a neutral extension such as .template so the extension warning stops firing.

The mapping

Quarto 1 (EJS) Quarto 2 (doctemplate)
<%= item.title %> $it.title$ in markdown; $it.title-html$ inside raw HTML
<% for (const item of items) { %> … <% } %> $for(items)$ … $endfor$
<% if (item.image) { %> … <% } else { %> … <% } %> $if(it.image)$ … $else$ … $endif$
<%= item.myfield %> $it.myfield$, or $it.extra.myfield$
<a href="<%- item.path %>"> [$it.title$]($it.path$) — not a raw anchor
<img src="<%= item.image %>"> ![$it.image-alt$]($it.image$) — not a raw <img>
<%= metadataAttrs(item) %> ::: {.item $it.metadata-attrs$} — see below
<div class="card"> … </div> ::: {.card} … :::
<%= items.length %>, string manipulation, process.env No expressions — see What doctemplates cannot do

The interpolation, control-flow and custom-field rows are mechanical, and a half-finished port announces itself. Three rows do not: the anchor and image rows fail silently, and metadataAttrs fails in a way that looks like a typo in your CSS. Check those three twice.

$it.metadata-attrs$ is the direct equivalent of Quarto 1’s metadataAttrs() helper: data-index, data-categories when the item has categories (base64, as Quarto 1 encodes them), and a data-listing-<field>-sort value for each date or number field the item has. It is attribute syntax, so put it where attributes go — inside a div’s braces, ::: {.item $it.metadata-attrs$}, which is what the built-in item-default and item-grid partials do, or on a tag inside a ```{=html} block. Anywhere else it is interpolated as markdown text, goes through the typographic filter, and comes out as data-index=“0” with curly quotes, which is not a valid attribute.

The built-in default and grid layouts read these attributes in the browser to paginate and to filter by category. A custom listing gets none of that client-side behaviour, so in a custom template the attributes are only there for your own CSS or scripts.

A worked example

Quarto’s own website has a gallery listing, and it is a fair representative of a real Quarto 1 template: everything wrapped in one {=html} fence, raw anchors, a raw thumbnail image, a helper call, a nested loop over a custom field, and an alt attribute built by nested ternaries.

Here is docs/gallery/gallery.ejs from quarto-dev/quarto-web, abridged to one card:

```{=html}
<% for (const item of items) { %>
<h2><%- item.category %></h2>
<p><%- item.description %></p>
<div class="list grid" style="column-gap: 10px;">
<% for (const tile of item.tiles) { %>
  <div class="card border-2 rounded-3 g-col-12 g-col-sm-6 g-col-md-4 mb-2" <%= metadataAttrs(tile) %>>
    <div class="card-header py-1 px-2 border-bottom border-1 bg-light">
      <small class="card-text inline-block">
        <a href="<%- tile.href %>" class="listing-title"><%= tile.title %></a>
        <% if (tile.code) { %>
        <a href="<%- tile.code %>" title="View source code" class="source-code">
          <i class="bi-code-slash"></i>
        </a>
        <% } %>
        <span class="text-muted listing-subtitle"><%= tile.subtitle %></span>
      </small>
    </div>
    <a href="<%- tile.href %>">
      <img src="<%- tile.thumbnail %>" alt="<% if (tile.alt) { %><%= tile.alt %><% } else { %><%= tile.title %> example<% } %>" class="card-img-top"/>
    </a>
  </div>
<% } %>
</div>
<% } %>
```

And the port:

$for(items)$
## $it.title$

$if(it.description)$
$it.description$
$endif$

::: {.list .grid .gallery-grid}
$for(it.tiles)$
::: {.card .g-col-12 .g-col-sm-6 .g-col-md-4}
::: {.card-header}
[$it.title$]($it.href$){.listing-title}$if(it.code)$ [`<i class="bi-code-slash"></i>`{=html}]($it.code$){.source-code title="View source code"}$endif$
$if(it.subtitle)$[$it.subtitle$]{.text-muted .listing-subtitle}$endif$
:::

[![$if(it.alt)$$it.alt$$else$$it.title$ example$endif$]($it.thumbnail$){.card-img-top}]($it.href$)
:::

$endfor$
:::

$endfor$

Point by point:

The {=html} fence is gone. It wrapped the whole template, which is what made every path inside it invisible. Dropping it is most of the port.

<div class="…"> became ::: {.…}. Fenced divs produce the same <div> with the same classes, and — unlike a raw <div> — their contents are markdown, so links and images inside them are resolved.

The two raw anchors became markdown links, so tile.href is rewritten from .qmd to .html. The inner icon anchor keeps its raw <i> as link text, which is allowed.

The raw <img> became a markdown image nested inside a markdown link — [![alt](src)](href). That single change both rewrites the thumbnail path and gets the file copied into the output directory.

The alt ternaries became $if$/$else$, which is a direct translation because the original only chose between two strings.

The nested loop survives. $for(it.tiles)$ iterates a list of maps held in a custom field, and inside it $it$ rebinds to the current tile, shadowing the outer item. Custom-field values are used verbatim, so paths in them must be written relative to the page that declares the listing.

metadataAttrs(tile) was dropped rather than translated. $it.metadata-attrs$ is bound per item, not per nested map, so the tiles have no equivalent. Quarto 2 does not paginate or filter custom listings in the browser, so dropping it changes nothing here — but it is the kind of helper call that needs a decision rather than a mechanical rewrite.

The outer loop restructured. The original grouped by item.category, emitting a heading per group; the port emits a heading per item and treats tiles as that item’s children, which is the shape the data already had. Quarto 2 has no grouping construct, so a Quarto 1 template that groups will always need this kind of judgement.

What doctemplates cannot do

A doctemplate interpolates, branches and loops. It cannot evaluate expressions, so anything computed in EJS has to move somewhere that runs before the template does.

A JavaScript prologue of constants becomes template-params:, read as $listing.template-params.<key>$:

listing:
  type: custom
  template: card.template
  template-params:
    columns: 2

This is also the answer for a per-item field that is really a listing-level setting. A Quarto 1 template could read items[0].columns to get a value that was identical on every item; a doctemplate cannot index a list, and shouldn’t need to.

String manipulation — slugifying a title, taking a path’s directory with lastIndexOf('/'), formatting a number — has to be pre-computed into an inline record key or the document’s listing-item.extra, and read back as $it.<key>$. There are text pipes ($it.title/uppercase$, and lowercase, chomp, nowrap, alpha, roman, first, last, rest, allbutlast, length, pairs, reverse, left, center, right) but they do not compose into general string surgery.

Counts and aggregates — items.length, sums, any “is this the last one” logic — have no direct form. $sep$ covers separators between items, which is the common case; a genuine total has to come from metadata.

When the template is the wrong tool, the listing can be built in Lua instead: every listing’s resolved items are published into the page’s metadata at meta.listings.<id>, and type: metadata declares a listing that resolves its items and leaves the markup to a filter. See Listings as data.

Verifying a port

Neither of the two silent failures produces a diagnostic, and neither changes the template’s text in a way review would catch. So after porting, check the rendered output rather than the template:

quarto render .

Then, in the generated HTML for the listing’s host page:

  1. Read the href values. Every link to a project document should end in .html. A surviving .qmd means that anchor is still raw HTML.
  2. Read the src values, then check the files exist. For each image src, confirm the file is actually present at that path under the output directory. A src that looks right but has no file behind it is a raw <img>.
  3. Check an item with no description:. It should show a preview drawn from its first paragraph. If it shows nothing, the placeholder envelope is inside an $if$ instead of around it.
  4. Look for Q-12-10. An “Undefined variable” warning means an optional value is being read without an $if$ guard.

Steps 1 and 2 are the ones worth automating if you maintain several custom listings; both failures are invisible to everything except the rendered output.