A content file does not need a component tree to express that one region is a hero, callout, card, or gallery. A small extension to Markdown can carry that intent while ordinary headings, paragraphs, links, and lists remain ordinary Markdown.
With markdown-it-container, the author-facing syntax can stay as small as:
:::hero
# Ship the next release
A short description stays normal Markdown.
:::That is already enough to create a lightweight UI DSL: hero has a meaning defined by the application, while the content inside it continues through the Markdown parser. The useful boundary is narrow. Once the format starts exposing rows, columns, padding values, CSS classes, event handlers, and deeply nested components, the content file stops behaving like content and starts becoming source code in another notation.
A container should express intent, not implementation
Compare two possible formats:
:::hero
# Hello
:::and:
:::column gap=16 padding=24 align=center
:::text size=48 weight=700
Hello
:::
:::The first says what the region is. The second describes how a layout engine should assemble it.
Both are DSLs, but they serve different authors. A developer may be comfortable with nested layout primitives. A writer or CMS editor is more likely to work reliably with a short vocabulary such as hero, note, quote, and gallery.
That distinction is architectural, not cosmetic. Semantic containers leave responsive layout, spacing, accessibility, and theme decisions in the renderer. Layout containers push those decisions into every document.
A semantic container can therefore survive a redesign:
:::hero
|
+-- 2026 renderer -> centered heading + image
|
+-- 2027 renderer -> split layout + gradient surfaceThe Markdown does not change because its meaning did not change.
markdown-it-container provides the block boundary
markdown-it parses Markdown into tokens and then passes those tokens to a renderer. markdown-it-container adds a block rule for fenced custom containers. A registered container produces opening and closing container tokens around the Markdown parsed inside it.
A minimal registration looks like this:
import MarkdownIt from 'markdown-it';
import container from 'markdown-it-container';
const md = new MarkdownIt();
md.use(container, 'hero', {
render(tokens, idx) {
if (tokens[idx].nesting === 1) {
return '<section class="hero">\n';
}
return '</section>\n';
}
});
const html = md.render(`
:::hero
# Hello
:::
`);The container plugin’s default validator identifies the named container from the text after the fence. Its render hook receives the generated tokens, so the application can replace the generic container output with semantic HTML.
The resulting HTML can be straightforward:
<section class="hero">
<h1>Hello</h1>
</section>This keeps the authoring syntax small while the renderer owns the actual DOM structure.
Register a vocabulary instead of arbitrary component names
If documents can request any component name, the content format becomes coupled to implementation details. A registry makes the supported vocabulary explicit.
const blocks = {
hero: {
tag: 'section',
className: 'hero'
},
note: {
tag: 'aside',
className: 'note'
},
panel: {
tag: 'section',
className: 'panel'
}
};
for (const [name, definition] of Object.entries(blocks)) {
md.use(container, name, {
render(tokens, idx) {
if (tokens[idx].nesting === 1) {
return `<${definition.tag} class="${definition.className}">\n`;
}
return `</${definition.tag}>\n`;
}
});
}The document can now select a known semantic role, but it cannot instantiate an arbitrary JavaScript component.
That limitation is useful. It gives the renderer a stable contract:
Markdown author
-> allowed block name
-> registered renderer
-> controlled HTML
-> CSS/design systemThe registry also creates one place to deprecate names, add aliases, or migrate a block without searching for component imports across content files.
Keep attributes deliberately boring
A block eventually needs small variations. A hero may need a compact form; a note may have info and warning variants. Attributes can handle those cases, but accepting arbitrary key=value input and copying it into HTML is a poor boundary.
Prefer a fixed grammar and an allowlist:
:::hero variant=compact
# Status page
:::Then validate the value before rendering it:
const heroVariants = new Set(['default', 'compact']);
function parseHeroInfo(info) {
const match = info.trim().match(
/^hero(?:\s+variant=(default|compact))?$/
);
if (!match) {
return { variant: 'default' };
}
return {
variant: heroVariants.has(match[1])
? match[1]
: 'default'
};
}
md.use(container, 'hero', {
validate(params) {
return /^hero(?:\s+variant=(default|compact))?$/.test(
params.trim()
);
},
render(tokens, idx) {
if (tokens[idx].nesting === 1) {
const { variant } = parseHeroInfo(tokens[idx].info);
return `<section class="hero hero--${variant}">\n`;
}
return '</section>\n';
}
});The author can choose a supported variant but cannot inject style, onclick, arbitrary classes, or an unexpected URL into the generated element.
For a content-oriented DSL, that restriction is a feature. The format should expose choices the design system intends to support, not the entire browser platform.
Raw HTML and custom containers are separate trust decisions
A controlled container renderer does not automatically make all Markdown input trusted. markdown-it can be configured to accept raw HTML, and applications may add other plugins that introduce URLs or HTML-generating behavior.
If authors are not fully trusted, keep the trust boundary explicit:
const md = new MarkdownIt({
html: false,
linkify: true
});Container values should still be validated and escaped whenever user-controlled text reaches an HTML attribute or raw HTML string. Disabling raw HTML prevents one direct injection path; it does not remove the need to review custom renderer code.
The safe mental model is:
Markdown source
-> parser rules
-> validated container metadata
-> tokens
-> controlled renderer
-> HTMLDo not treat the source file as safe merely because its syntax looks simpler than HTML.
Deep nesting changes the audience
A single semantic wrapper is easy to scan:
:::hero
# A clearer deployment status
Current incidents and maintenance windows.
:::A component tree written with fences is different:
:::column
:::row
:::card
:::text
Current incidents
:::
:::
:::card
:::text
Maintenance
:::
:::
:::
:::The second format asks the author to track opening and closing boundaries, hierarchy, and layout semantics. At that point, a visual block editor or a real component language often provides a better authoring surface.
This gives a practical threshold for the DSL:
- use Markdown for prose;
- use containers for a small number of semantic regions;
- keep visual layout in CSS and renderer components;
- move complex composition to a structured editor or component system.
The format remains approachable because authors only need to recognize a few named fences rather than mentally execute a layout tree.
The renderer can target components without exposing them
The Markdown syntax does not have to map directly to final HTML. It can map to tokens or an intermediate representation consumed by a framework.
For example, a parsing layer could normalize a container into:
{
"type": "hero",
"variant": "compact",
"content": [
{
"type": "heading",
"level": 1,
"text": "Status page"
}
]
}A React, Vue, Svelte, server-side template, or static renderer can then decide how hero becomes UI.
This separation matters when the same content appears in several contexts. A website may render the hero as a large section, an RSS pipeline may flatten it to ordinary text, and a search index may ignore the visual wrapper entirely.
The DSL describes content semantics. The consumer decides presentation.
Version the meaning, not every visual change
Content can live much longer than a CSS implementation. Once a custom container appears in hundreds of files, its name becomes part of the content schema.
Changing .hero from flexbox to grid is not a schema change. Renaming hero to masthead, removing an attribute, or changing what variant=compact means may be.
Treat container names like a small public API:
stable:
hero
note
gallery
supported hero variants:
default
compactWhen a name must disappear, a migration can rewrite old documents or the registry can temporarily support both names. This is much easier when the vocabulary contains ten semantic blocks instead of dozens of low-level layout primitives.
A small DSL is often the stronger one
The useful part of a Markdown UI DSL is not its ability to imitate Jetpack Compose. It is its ability to stop before it needs to.
For content-heavy pages, this:
:::hero
# Hello
:::can be enough. The author marks a region as a hero. Markdown handles the text structure. The renderer selects accessible HTML. CSS handles responsive layout and visual design. JavaScript is added only when the block actually needs behavior.
That division keeps the document readable without the renderer, keeps presentation decisions out of prose, and leaves the application free to redesign the UI later. A container syntax becomes durable when it carries only the semantic information that ordinary Markdown cannot express cleanly.