Compono is a platform-agnostic, component-based domain-specific language (DSL) that extends Markdown syntax with reusable components.
Originally developed for Umono CMS, Compono can be used in any Go project that needs a flexible templating solution.
go get github.com/umono-cms/componopackage main
import (
"bytes"
"fmt"
"github.com/umono-cms/compono"
)
func main() {
c := compono.New()
source := []byte(`{{ SAY_HELLO name="World" }}
~ SAY_HELLO name="Guest"
# Hello, {{ name }}!
`)
var buf bytes.Buffer
if err := c.Convert(source, &buf); err != nil {
panic(err)
}
fmt.Println(buf.String())
// Output: <h1>Hello, World!</h1>
}Compono supports common Markdown elements:
# Heading 1
## Heading 2
### Heading 3
This is a paragraph with **bold** and *italic* text.
`inline code`
[Link text](https://example.com)
Code blocks are also supported:
```go
fmt.Println("Hello")
```
Components are the core feature of Compono. They allow you to create reusable content blocks.
Local components are defined in the same scope where they're used:
{{ GREETING }}
~ GREETING
Welcome to our website!
The ~ COMPONENT_NAME syntax marks the beginning of a local component definition. Everything after it becomes the component's content. A component definition ends when another component definition starts or at EOF.
Components can accept parameters with default values:
{{ USER_CARD name="Anonymous" role="Guest" }}
~ USER_CARD name="" role=""
## {{ name }}
*{{ role }}*
Components containing multiple paragraphs or block elements are block components:
{{ ARTICLE }}
~ ARTICLE
# Title
First paragraph.
Second paragraph.
Components with single-line content can be used inline:
Welcome, {{ USERNAME }}!
~ USERNAME
John
Global components can be registered once and used across multiple conversions:
c := compono.New()
// Register a global component
c.RegisterGlobalComponent("FOOTER", []byte(`© 2026 My Company`))
// Use it in any conversion
c.Convert([]byte(`
# Page Title
Content here...
{{ FOOTER }}
`), &buf)Global components can also have parameters:
c.RegisterGlobalComponent("BLOG_PAGE", []byte(`title="" content=""
## {{ title }}
{{ content }}`))Creates an anchor element with optional target blank:
{{ LINK text="Visit us" url="https://example.com" new-tab=true }}
Output:
<compono-link><a href="https://example.com" target="_blank" rel="noopener noreferrer">Visit us</a></compono-link>Creates a semantic web image output from a media record and optional responsive variants.
Basic usage:
{{ IMAGE media = {
url: "https://cdn.example.com/my-photo.jpg",
width: 1600,
height: 900,
mime-type: "image/jpeg",
variants: [
{
url: "https://cdn.example.com/my-photo-640.avif",
width: 640,
height: 360,
mime-type: "image/avif"
},
{
url: "https://cdn.example.com/my-photo-1280.avif",
width: 1280,
height: 720,
mime-type: "image/avif"
},
{
url: "https://cdn.example.com/my-photo-1600.avif",
width: 1600,
height: 900,
mime-type: "image/avif"
},
{
url: "https://cdn.example.com/my-photo-640.webp",
width: 640,
height: 360,
mime-type: "image/webp"
},
{
url: "https://cdn.example.com/my-photo-1280.webp",
width: 1280,
height: 720,
mime-type: "image/webp"
},
{
url: "https://cdn.example.com/my-photo-1600.webp",
width: 1600,
height: 900,
mime-type: "image/webp"
}
]
} alt = "My photo" }}
Output:
<compono-image><picture><source type="image/avif" srcset="https://cdn.example.com/my-photo-640.avif 640w, https://cdn.example.com/my-photo-1280.avif 1280w, https://cdn.example.com/my-photo-1600.avif 1600w"><source type="image/webp" srcset="https://cdn.example.com/my-photo-640.webp 640w, https://cdn.example.com/my-photo-1280.webp 1280w, https://cdn.example.com/my-photo-1600.webp 1600w"><img src="https://cdn.example.com/my-photo.jpg" alt="My photo" width="1600" height="900"></picture></compono-image>Without variants, IMAGE renders a plain img element inside the compono-image custom element:
{{ IMAGE media = {
url: "https://cdn.example.com/avatar.png",
width: 512,
height: 512,
mime-type: "image/png"
} alt = "Profile avatar" }}
Output:
<compono-image><img src="https://cdn.example.com/avatar.png" alt="Profile avatar" width="512" height="512"></compono-image>IMAGE can be used inline or as a block component depending on where it is called:
Gallery cover: {{ IMAGE media = {
url: "https://cdn.example.com/gallery-cover.jpg",
width: 800,
height: 450,
mime-type: "image/jpeg"
} alt = "Gallery cover" }} is ready.
mediais required and must be a record with:urlwidthheightmime-type- optional
variants
altis a string. Passalt=""for decorative images.
Each item in variants must be a record with:
urlwidthheightmime-type
Supported mime types:
image/jpegimage/pngimage/webpimage/gifimage/avif
mediais always the fallback image source.- HTML output is always wrapped with the
compono-imagecustom element. - variants are grouped by first-seen
mime-type, preserving the original group order. - within each mime type group,
srcsetentries are sorted by ascending width. - an empty
variantsarray is valid and renders only the fallbackimginsidecompono-image. - all widths and heights must be greater than
0. - all variants must preserve the same aspect ratio as the main
media. - duplicate
mime-type+widthpairs are invalid.
When validation fails, Compono renders an error placeholder instead of silently producing invalid markup. Common IMAGE-specific errors include:
Invalid built-in argumentsUnsupported mime-typeInvalid dimensionDuplicate variantInconsistent aspect ratio
Deprecated:
WEB_GRIDis deprecated and will be removed in v1. Compono will now only carry semantic content. Built-in components that tell the presentation layer what to do will no longer be added to Compono.
Creates a web grid wrapper from component items and grid template definitions:
{{ WEB_GRID
items = [
{ component: HEADER, grid-area: "header" },
{ component: CONTENT, grid-area: "content" },
{ component: FOOTER, grid-area: "footer" }
]
grid-template-columns = ["1fr"]
grid-template-rows = ["min-content", "1fr", "min-content"]
grid-template-areas = [
["header"],
["content"],
["footer"]
]
}}
~ HEADER
# Header
~ CONTENT
Main content.
~ FOOTER
Footer
Output:
<compono-web-grid data-grid-template-columns="1fr" data-grid-template-rows="min-content 1fr min-content" data-grid-template-areas='[["header"],["content"],["footer"]]'><compono-web-grid-item data-grid-area="header"><h1>Header</h1></compono-web-grid-item><compono-web-grid-item data-grid-area="content"><p>Main content.</p></compono-web-grid-item><compono-web-grid-item data-grid-area="footer"><p>Footer</p></compono-web-grid-item></compono-web-grid>WEB_GRID also supports responsive breakpoint variants for the grid template parameters:
sm-grid-template-columns, md-grid-template-columns, lg-grid-template-columns, xl-grid-template-columns, xxl-grid-template-columns, and the corresponding *-grid-template-rows / *-grid-template-areas parameters.
Creates a platform navigation tree from an items array. In the HTML renderer, it outputs a <compono-navigation> wrapper containing a semantic nav list. Compono does not define the custom element; that belongs to the application or runtime using the generated output.
Basic usage:
{{ NAVIGATION
items = [
{ label: "Home", target: "/" },
{ label: "About", target: "/about" },
{ label: "Contact", target: "/contact" }
]
}}
Output:
<compono-navigation><nav><ul><li><a href="/">Home</a></li><li><a href="/about">About</a></li><li><a href="/contact">Contact</a></li></ul></nav></compono-navigation>Items can contain nested children arrays:
{{ NAVIGATION
items = [
{ label: "Home", target: "/" },
{
label: "Docs",
target: "/docs",
children: [
{ label: "Guide", target: "/docs/guide" },
{
label: "API",
target: "/docs/api",
children: [
{ label: "Components", target: "/docs/api/components" }
]
}
]
}
]
}}
Output:
<compono-navigation><nav><ul><li><a href="/">Home</a></li><li><a href="/docs">Docs</a><ul><li><a href="/docs/guide">Guide</a></li><li><a href="/docs/api">API</a><ul><li><a href="/docs/api/components">Components</a></li></ul></li></ul></li></ul></nav></compono-navigation>itemsis an array of navigation item records.- each item must include:
label: string rendered as the visible link text.target: string rendered as the linkhref.- optional
children: array of navigation item records.
NAVIGATION is a block built-in component. It can receive items from a parameter or context(key), and label/target values are HTML escaped by the HTML renderer.
Components can accept parameters. Each parameter must have a default value defined in the component definition.
If a parameter value is not provided during the call, the default value is used.
{{ SAY_HELLO name="Jane" }}
~ SAY_HELLO name="John"
# Hello, {{ name }}!
Supported parameter types:
- String →
name = "John" - Integer →
age = 25 - Bool →
active = true - Component →
comp = COMP - Array →
items = ["Jane", 22, true, COMP] - Record →
config = { lang: "tr", for-admin: true }
A parameter can be passed directly to another component call.
{{ USER age=31 }}
~ USER age=18
{{ ANOTHER_COMP another-integer-param=age }}
~ ANOTHER_COMP another-integer-param=0
Integer: *{{ another-integer-param }}*
Here:
USERreceivesage- it forwards that value to
ANOTHER_COMP
Components themselves can also be passed as parameters.
{{ USER name="Yunus Emre" age=31 age-wrapper=AGE_WRAPPER_2 }}
~ USER name="John" age=25 age-wrapper=AGE_WRAPPER_1
# Welcome **{{ name }}**!
{{ age-wrapper age=age }}
~ AGE_WRAPPER_1 age=0
Your age: *{{ age }}*
~ AGE_WRAPPER_2 age=0
*{{ age }}*
Here:
age-wrapperreceives a component- that component is executed inside
USER
When a global component defines parameters, those parameters are visible to local components inside it.
c.RegisterGlobalComponent("PROFILE_PAGE", []byte(`
name="Guest"
{{ PROFILE_CARD }}
~ PROFILE_CARD
## {{ name }}
Welcome to the profile page.
`))
Usage:
{{ PROFILE_PAGE name="Yunus" }}
Output:
<h2>Yunus</h2>
<p>Welcome to the profile page.</p>
The local component PROFILE_CARD can directly access the global parameter name.
{{ WRAPPER names = ["John", "Jane"] }}
~ WRAPPER names = []
{{ SAY_HELLO name = names[0] }}
{{ SAY_HELLO name = names[1] }}
~ SAY_HELLO name = ""
# Hello **{{ name }}**!
Arrays do not have to be homogeneous.
~ COMP mix = ["Jane", 22, true, SAY_HELLO]
We can reach an element via index.
{{ mix[2] }}
// true
Arrays can be nested.
{{ TABLE data = [
[1,2],
[3,4],
]}}
~ TABLE data = []
{{ data[0][0] }} - {{ data[0][1] }}
{{ data[1][0] }} - {{ data[1][1] }}
Pass data as key - value
{{ COMP record = { title: "Hello", content: "Here Content" } }}
~ COMP record = {}
# {{ record.title }}
{{ record.content }}
Records can be nested
{{ COMP nested = {record: {key-1: "string", key-2: 123}, empty-record: {} } }}
~ COMP nested = {}
{{ nested.record.key-1 }} - {{ nested.record.key-2 }}
context(key) is a built-in reference mechanism for injecting immutable values at convert time.
Use it with compono.WithContext:
type CurrentUser struct {
FirstName string `compono:"first-name"`
LastName string `compono:"last-name"`
}
err := c.Convert(source, &buf, compono.WithContext(map[string]any{
"app/version": "1.2.0",
"feature/live": true,
"stats/numbers": []int{10, 20, 30},
"current-user": CurrentUser{
FirstName: "Yunus",
LastName: "Emre",
},
}))Direct usage:
Version: {{ context(app/version) }}
It can also be used as:
- a component argument
- a default parameter value
- an array item
- a record value
- built-in component arguments
Example:
{{ LINK text=context(link/text) url=context(link/url) new-tab=context(link/new-tab) }}
~ GREETING name=context(current-user/first-name)
Hello **{{ name }}**!
If the resolved value is a record or array, you can keep using normal access syntax:
# {{ context(current-user).first-name }}
## {{ context(stats/numbers)[1] }}
Keys are static and unquoted. They are made of segments joined by /.
- segments may contain lowercase Latin letters, numbers, and
- /cannot appear at the beginning or end- repeated separators are invalid
- whitespace is allowed around the key inside
context(...)
If context() is empty or the key format is invalid, it is treated as plain text instead of a context reference.
WithContext supports:
stringboolint,int8,int16,int32,int64[]Tand[N]Tmap[string]T- structs
Notes:
nilis not supported- map keys must be
string - struct fields must be exported
componostruct tags must be validkebab-case- if a struct field has no
componotag, its name is converted tokebab-case - unsupported types such as floats, pointers, functions, and channels return a fatal conversion error
- fatal context injection errors are returned as
ErrUnsupportedTypeorErrUnsupportedKeyNotation
If a referenced key is not injected, Compono renders an error placeholder with:
- Title:
Unknown key - Message:
The key **[key]** is not injected.
Error placement depends on how context(...) is used:
- direct usage always renders an inline error
- using it in a block component call renders a block error at the call site
- using it in an inline component call renders an inline error at the call site
- default values are resolved lazily, so no error is produced unless that parameter is actually used
Renderer hooks let an application inspect or replace the HTML output produced by the renderer for supported rendered units. They are registered per conversion with compono.WithRendererHook.
Hooks run after the renderer creates the default output for a markdown element or built-in component. The string returned by the hook becomes the output for that unit. When multiple hooks are registered, they run in registration order and each hook receives the output returned by the previous hook.
import (
"bytes"
"strings"
"github.com/umono-cms/compono"
"github.com/umono-cms/compono/renderer/hook"
)
c := compono.New()
markdownHook := func(ctx hook.RendererHookContext) string {
if ctx.Kind == hook.KindMarkdown && ctx.Name == "h1" {
return strings.Replace(ctx.Output, "<h1>", `<h1 class="page-title">`, 1)
}
return ctx.Output
}
builtinHook := func(ctx hook.RendererHookContext) string {
if ctx.Kind == hook.KindBuiltin && ctx.Name == "LINK" {
if url, ok := ctx.Params.String("url"); ok && strings.HasPrefix(url, "https://example.com") {
return strings.Replace(ctx.Output, "<compono-link>", `<compono-link data-internal="true">`, 1)
}
}
return ctx.Output
}
var buf bytes.Buffer
err := c.Convert(
[]byte("# Hello\n\n{{ LINK text=\"Visit\" url=\"https://example.com\" }}"),
&buf,
compono.WithRendererHook(markdownHook),
compono.WithRendererHook(builtinHook),
)Every hook receives a hook.RendererHookContext:
Kind: where the output came from.hook.KindMarkdownis used for markdown elements, andhook.KindBuiltinis used for built-in components.Name: the rendered element or component name. Markdown names includeh1,h2,h3,h4,h5,h6,p,em,strong,link,inline-code, andcode-block. Built-in names use the component name, such asLINK,IMAGE,WEB_GRID, orNAVIGATION.Params: raw resolved data for that rendered unit. Markdown elements expose values such ascontent,text,url, andlang. Built-in components expose successfully resolved arguments and defaults, including values coming fromcontext(key).Output: the current HTML output. Returningctx.Outputleaves it unchanged; returning another string replaces it.
Params is a hook.Params map of hook.ParamValue. Values can be read as strings, arrays, or records:
imageHook := func(ctx hook.RendererHookContext) string {
if ctx.Kind != hook.KindBuiltin || ctx.Name != "IMAGE" {
return ctx.Output
}
media, ok := ctx.Params.Record("media")
if !ok {
return ctx.Output
}
mimeType, _ := media.String("mime-type")
if mimeType == "image/avif" {
return strings.Replace(ctx.Output, "<compono-image>", `<compono-image data-modern="true">`, 1)
}
return ctx.Output
}Hook params are intentionally raw. For example, markdown link text and url, markdown content, and built-in arguments are provided before hook-level escaping or sanitizing. If a hook returns newly constructed HTML, that HTML is the responsibility of the application using the hook.
Compono provides error feedback by rendering placeholders where errors occur. Fatal errors during conversion stop the process and no output is produced.
// Create a new Compono instance
c := compono.New()
// Convert source to HTML
err := c.Convert(source []byte, writer io.Writer, opts ...compono.ConvertOption)
// Register a global component
err := c.RegisterGlobalComponent(name string, source []byte)
// Unregister a global component
err := c.UnregisterGlobalComponent(name string)
// Inject a global component for a single conversion
err := c.Convert(source, writer, compono.WithGlobalComponent(name, globalSource))
// Inject convert-time context values
err := c.Convert(source, writer, compono.WithContext(map[string]any{
"app/version": "1.2.0",
}))
// Register a renderer hook for a single conversion
err := c.Convert(source, writer, compono.WithRendererHook(func(ctx hook.RendererHookContext) string {
return ctx.Output
}))Component names must be in SCREAMING_SNAKE_CASE:
- ✓
HEADER - ✓
USER_PROFILE - ✓
NAV_MENU_ITEM - ✗
header - ✗
userProfile
Parameter names must be in kebab-case:
- ✓
name - ✓
user-name - ✓
is-active - ✗
userName - ✗
user_name
When multiple components share the same name, Compono follows a clear override hierarchy:
Local Component > Global Component > Built-in Component
Local always wins:
{{ LINK }}
~ LINK
I override the built-in LINK component!
This outputs <p>I override the built-in LINK component!</p> instead of an anchor tag.
Global overrides built-in:
c.RegisterGlobalComponent("LINK", []byte(`Custom link behavior`))Now all {{ LINK }} calls will use your global definition instead of the built-in one.
This allows you to customize or extend built-in components without modifying the library.
MIT License - see LICENSE for details.