BEM Blocks as Jet Templates
Each BEM block is a self-contained Jet template that owns its HTML, CSS, and JS.
Styles and scripts are collected during rendering and flushed once at the end of the page.
Concept
Traditional approach: one global CSS file, one global JS file. Every page loads everything.
BEM Blocks approach: each block declares its own styles and scripts inline.
The rendering context deduplicates by block name — no matter how many times a block is
included on a page, its CSS and JS are written to the page exactly once.
No build step. No bundler. No class name collisions. Only what is used gets included.
Block Structure
A block template contains three optional sections:
{{block button(label="Click", variant="")}}
{{inline_css}}
.button {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border: none;
cursor: pointer;
}
.button--primary {
background: #0070f3;
color: #fff;
}
{{end}}
{{inline_js}}
document.querySelectorAll('.button').forEach(btn => {
btn.addEventListener('click', e => e.currentTarget.classList.toggle('button--active'))
})
{{end}}
<button class="button{{if variant}} button--{{variant}}{{end}}">{{label}}</button>
{{end}}
Rules:
inline_cssandinline_jssections are optional — include only what the block needs.- CSS uses only BEM class selectors (see
bem.md). - JS is self-contained: uses
querySelectorAll, no globals, no dependencies on load order.
Page Template
<html>
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
</head>
<body>
{{import "blocks/header.html"}}
{{yield header()}}
{{import "blocks/button.html"}}
{{yield button(label="Subscribe", variant="primary")}}
{{yield button(label="Cancel")}}
{{import "blocks/card.html"}}
{{yield card(title="Hello")}}
{{render_inline_css}}
{{render_inline_js}}
</body>
</html>
button is included twice — its CSS and JS appear in the page once.
render_inline_css and render_inline_js are placed before </body>.
Inline <style> tags are valid anywhere in the document; browsers apply them regardless of
position. Inline <script> tags at end of body is the standard pattern anyway.
How It Works
Registration functions
inline_css and inline_js are Jet global functions backed by the render context:
// Simplified
type InlineCollector struct {
css map[string]string // block name → css text
js map[string]string // block name → js text
}
When a block calls {{inline_css}}...{{end}}, the function receives the block name
(from the enclosing BlockNode) and the text content, then stores it in the map.
Subsequent calls with the same key are ignored — first registration wins.
Render functions
render_inline_css outputs:
<style>
/* collected CSS from all used blocks */
</style>
render_inline_js outputs:
<script>
/* collected JS from all used blocks */
</script>
Static vs dynamic CSS
At template parse time, blockFinder in layoutloader can walk the AST of each
inline_css block and check whether all nodes are *jet.TextNode. If so, the CSS is
static and can be extracted at load time — no runtime overhead for those blocks.
If the block contains Jet expressions (e.g. a CSS custom property derived from a
template variable), it falls back to runtime collection.
One Block, One Responsibility
A block owns both its CSS and its JS. This means you cannot import a block and opt out
of its JS while keeping its CSS (or vice versa).
If you need that, split the block:
blocks/button.html — HTML + CSS only
blocks/button-interactive.html — extends button, adds JS
This is intentional: splitting is a signal that the block has two distinct responsibilities.
Performance
Per request cost
| Operation | Cost |
|---|---|
| CSS/JS registration | Map write — O(1) per block |
| Deduplication | Map key check — O(1) |
| render_inline_css/js | Single string join over N used blocks |
| Body buffering | Not needed — render_inline_* are at end of body |
No body buffering is required because the render functions are placed after all block
yields. The page streams normally; styles and scripts are appended at the end.
Comparison
WordPress renders a page by:
- executing dozens of PHP hooks and filters
- running 30–100+ SQL queries per request
- concatenating CSS/JS from 10–30 plugin files
- often serving 500–2000 KB of unused CSS
BEM Blocks approach:
- template is pre-compiled and cached in memory
- CSS/JS collection is pure in-memory map operations
- output contains only CSS and JS for blocks actually used on that page
- zero SQL queries, zero file reads during render
A page using 15 blocks collects 15 map writes and outputs one <style> and one
<script> tag. The overhead is immeasurable compared to a full page render.
Caching
If all blocks on a given layout have static CSS (no Jet expressions inside inline_css),
the entire collected stylesheet can be cached at layout compile time — the per-request
cost drops to a single string write.
yield_blocks
yield_blocks is the user-facing global function that implements the collection and
emission described above. It accepts a prefix string or a regexp pattern and emits CSS
(or JS) only for the blocks that were actually yielded on the current page.
<style>{{yield_blocks("_style_")}}</style>
<script>{{yield_blocks("/_js_.*/")}}</script>
The naming convention it relies on:
_style_blockname— CSS block for a component_js_blockname— JS block for a component
The loader auto-discovers component files (no {{import}} needed) and resolves
transitive dependencies: if card.html yields button, button.html is included
automatically.
User-facing documentation: yield_blocks user guide
Limitations and rules
Block parameters are nil. yield_blocks calls each collected block via rt.YieldBlock(name, nil) — no arguments are passed. All block parameters default to their zero/empty values. A CSS block with {{ if theme }}.box--{{theme}}{{ end }} will never emit the conditional part when called this way. Rule: CSS blocks must be self-contained and must not branch on their own parameters.
Global functions are available. Globals registered via AddGlobalFunc (e.g. asset, note) are accessible inside CSS blocks called by yield_blocks, exactly as in any other block. No special handling is needed.
Do not yield sibling CSS blocks manually. If a CSS block calls {{ yield _style_other() }} internally, and _style_other also matches the yield_blocks pattern, the output will contain that CSS twice — once from the explicit yield and once from yield_blocks collecting it independently. Let yield_blocks handle collection; CSS blocks must not yield each other.
Placement in <head> is safe. Because block names are resolved by static analysis (third-pass wire) before any rendering occurs, yield_blocks can be placed in <head> rather than at the end of <body>. This is the recommended placement for CSS to avoid flash of unstyled content (FOUC):
<head>
<style>{{ yield_blocks("_style_mesh_") }}</style>
</head>