Components

Components are server-rendered, tag-based building blocks. epresso resolves a component as components/<Name>.ep first, then looks under layouts/ (so a layout can double as a component). In a src/ project both directories live under src/.

Components from an external repo or directory can be layered in with [layers] use; the site's own files are always searched first, so a site component with a matching basename overrides the layer's.

The .ep file format

.ep files unify Python frontmatter (--- … ---) + Jinja body + optional scoped <style> + optional client <script>:

  
components/Card.ep epresso
---from pydantic import BaseModelclass Props(BaseModel): title: str level: int = 3---<div class="card"><h{{ props.level }}>{{ props.title }}</h{{ props.level }}>{{ content }}</div>

File shape — one root per branch

An .ep file renders at most one root per branch — a branch may render nothing (an {% if %} with no {% else %}, a {% for %} over an empty sequence, an empty array). A root is anything that emits output: an element, a component, <slot/>, <Fragment>/<>, an interpolation ({{ … }}) or text. Whitespace and comments emit nothing, so they are ignored; {% if %} / {% for %} introduce branches, and each branch is checked the same way.

  
epresso
------<Fragment> <!-- ❌ two siblings at the top level --><div class="a">a</div><aside>b</aside></Fragment> <!-- ✅ one root: the group -->

Patterns:

needwrite
group siblings, render no wrapper<Fragment>…</Fragment> or <></>
a branch that renders nothing{% if x %}<a>…</a>{% endif %} — no {% else %} needed; an empty branch is fine
forward the caller's content <slot/> (a root in its own right)
conditionally forward {{ content }} counts as one root (it is treated as <Fragment>{{ content }}</Fragment>)

The sidecars are capped per kind too: at most one scoped <style>, one <style is:global> and one <script> per file — merge same-kind blocks.

Violations fail the build with file, line and a fix hint. .ep endpoints (whose body is never rendered) are exempt.

Typed props

A Props Pydantic model validates props before they reach the template — no unvalidated kwargs:

  
epresso
---from pydantic import BaseModelclass Props(BaseModel): title: str level: int = 3---<div class="card"><h{{ props.level }}>{{ props.title }}</h{{ props.level }}>{{ content }}</div>

Invalid props raise a TemplateError at render time. A component is scoped only when it contains a scoped <style> block; a .ep file with no scoped CSS (or only <style is:global>) renders unscoped, so a layout shell needs no flag (see Layouts.

Using a component

Two equivalent syntaxes.

Jinja tag:

  
jinja
{% component "Card", title="Hi", level=2 %}Body text{% endcomponent %}

JSX-style tags (rewritten to {% component %} before parsing):

  
jinja
<Card title="Hi">Body text</Card>
  • Only tags that resolve to a registered component are converted — <div>, <section>, etc. pass through as plain HTML.
  • Attributes: key="value" (string), key={expr} (expression), or a bare key (boolean true).
  • Paired components take children as {{ content }}; children may nest more components.
  • The legacy {% component %} tag still works and can be mixed freely.

Same basename in different subdirectories

A component is normally found by basename alone, wherever it lives under components/ (components/Card.ep, components/controls/Card.ep, components/ui/anything/Card.ep all answer to <Card/>). If two files share a basename outside the components//layouts/ roots themselves, which one <Card/> resolves to is undefined — pick a specific one with <Name:dir />:

  
jinja
<A:comp1 /> {# components/comp1/A.ep #}<A:comp2 /> {# components/comp2/A.ep #}<A:dir1:dir2 /> {# components/dir1/dir2/A.ep #}

The qualifier goes after the name (not before, XML-namespace style) because a tag must start with an uppercase letter to be recognized as a component at all — <comp1:A /> would just look like an unrecognized lowercase HTML tag. The same call also works through the low-level tag: {% component "A:comp1" %}.

Slots

Components get a default slot ({{ content }}, the children) plus named slots:

  
jinja
<Card title="Hi"> <Fragment slot="header">Header content</Fragment> Default body content</Card>

slot="…" also works directly on any element, which slots that element (and its subtree) without a wrapper — the marker attribute is stripped from the output:

  
jinja
<Panel> <img slot="side" src="/a.png" alt=""> Body content</Panel>

Use a slot-less <Fragment> (or <></>) to group siblings without emitting an element — the group still counts as the file's one root (see File shape).

  
epresso
------<div class="card"><h3>{{ props.title }}</h3><header>{{ slot('header') }}</header>{{ content }}</div>

Use {{ slot('name') or 'fallback' }} for fallback content.

Scoped CSS

A <style> block in a .ep file is extracted, scoped to the component's output (via a data-epresso-<hash> attribute), and linked from the head:

  
epresso
------<style> .card { border: 1px solid #ccc; } :global(.reset) { margin: 0; } /* opt out of scoping */</style><div class="card">{{ content }}</div>

Scoped CSS is written to dist/_scoped/epresso-<hash>.css. :global(...) rules are stripped to their inner selector, which stays global (unscoped). See [Styling and CSS](../guides/styling/styling.md.

Template syntax

The .ep body is a Jinja template: expressions, conditionals and loops interleave with HTML, and attribute values can be expressions.

Expressions

{{ … }} outputs a value; attributes accept {expr}:

  
epresso
<h1>{{ props.title }}</h1><p class="{{ props.description ? 'has-desc' : '' }}"></p><a href="/{{ props.slug }}/">read</a>

Every component has props (the validated Pydantic model), plus site, content (slot children), and any frontmatter helpers you define.

Conditionals

{% if %} / {% elif %} / {% else %} render a branch when the expression is truthy:

  
epresso
{% if props.prev %} <a href="{{ props.prev.url }}">{{ props.prev.title }}</a>{% else %} <span>This is the first page.</span>{% endif %}

Loops

{% for x in seq %} iterates a list (array indices via loop.index):

  
epresso
<ul>{% for h in props.headings %} <li><a href="#{{ h.slug }}">{{ h.text }}</a></li>{% endfor %}</ul>

Comments

{# … #} are comments and are not rendered.

Client scripts

A <script> block in a .ep file is extracted, bundled with esbuild (falling back to a raw module when esbuild isn't installed), and loaded on pages that use the component:

  
epresso
------<script> document.querySelector('.count').addEventListener('click', () => alert('hi'));</script><button class="count">{{ content }}</button>

Identical script blocks dedupe to one bundle under dist/_epresso/scripts/.

Markdown components

Allow-listed component tags can be used directly in Markdown (see Markdown. md/ wrapper components take precedence so a markdown-facing interface can differ from the underlying component (e.g. a <YouTube id> → blocks/YouTube wrapper).

Compose with components, not Jinja composition

Composition in .ep is done with component tags and <slot/> — not with Jinja's structural directives. {% extends %}, {% block %}, {% include %}, {% component %}, {% import %}/{% from … import %}, {% macro %} and {% call %} are rejected in .ep files at load time: the build fails with the file, line and directive named.

  • Replace {% extends %} + {% block %} with a layout component that emits the HTML shell and exposes <slot/> (e.g. layouts/Base.ep), then write <Base title={…}>…content…</Base>.
  • Replace {% include %} / {% component %} with <Component/>.
  • Replace {% macro %} / {% import %} with a component + props.

Still allowed in .ep: {{ expr }} interpolation, JSX props ({expr}), {% if %} / {% for %} control flow, comments, and <slot/>.