A structured content layer for Phoenix LiveView apps. Write content as YAML and Markdown files, Brix validates, loads, and serves it.
No GUI. No database (yet). Files are the authoring interface.
# mix.exs
{:brix, "~> 0.1.1"}
# config/config.exs
config :brix, store: Brix.Store.Filesystem
# application.ex
children = [
{Brix.Store.Filesystem, content_dir: Path.expand("priv/content")}
]priv/content/
├── site.yml
├── authors/
│ └── maya.yml
├── tags/
│ └── coffee.yml
├── media/
│ ├── headshot.yml
│ └── files/
│ └── headshot.jpg
├── templates/
│ └── sections/
│ ├── hero.yml
│ └── richtext.yml
├── layouts/
│ └── default.yml
├── shared_sections/
│ ├── main-nav.yml
│ └── site-footer.yml
├── collections/
│ └── blog.yml
└── pages/
├── index/
│ ├── page.yml
│ └── versions/
│ └── 20240315T090000Z/
│ ├── version.yml
│ └── sections/
│ ├── 01-hero.yml
│ ├── 02-gallery.yml
│ ├── 02-gallery.slides/ # nested sections
│ │ ├── 01-slide.yml
│ │ └── 02-slide.yml
│ └── 03-intro.md
└── blog/
└── morning-ritual/
├── page.yml
└── versions/
├── 20240901T060000Z/
│ ├── version.yml
│ └── sections/
│ ├── 01-hero.yml
│ └── 02-body.md
└── 20241115T140000Z/
├── version.yml
└── sections/
├── 01-hero.yml
├── 02-body.md
└── 03-tips.yml
name: Ember & Bloom
tagline: Coffee worth slowing down for
meta_title: Ember & Bloom | Specialty Coffee
meta_description: A neighborhood coffee shop.
domain: emberandbloom.coffeeSlug is derived from the filename.
name: Maya Chen
bio: Founder and head roaster.
avatar: headshot # references media slug
url: https://example.comdisplay_name: Coffeealt: Photo of Maya
caption: At the roaster
content_type: image/jpeg
path: files/headshot.jpg # relative to media/Defines the field contract for a section type. The validator checks content against these.
fields:
heading:
type: string
required: true
subheading:
type: string
image:
type: mediaSupported field types: string, richtext, media, url, integer, boolean, list, map, sections.
Layouts define header and footer sections that wrap page content. Can reference shared sections or define sections inline.
header_sections:
- shared_section: main-nav
footer_sections:
- shared_section: site-footerOr inline:
header_sections:
- template: nav
fields:
links:
- label: Home
url: /
footer_sections:
- template: footer
fields:
copyright: "2026"Reusable content blocks. Define once, reference from layouts or page sections by name.
template: nav
fields:
links:
- label: Home
url: /
- label: About
url: /aboutNamed page groupings with filters and sorting.
name: Blog
filters:
prefix: /blog/
sort_by: slug
sort_direction: asc
meta_title: Blog | Ember & Bloom
meta_description: Thoughts on coffee and craft.Filter keys: prefix, tag, author. Sort fields: slug, title, published_at, updated_at.
Page slug is derived from directory path (pages/index/ → /, pages/blog/hello/ → /blog/hello). Can be overridden with slug:.
title: Home
layout: default
meta_title: Ember & Bloom | Home
meta_description: Specialty coffee in SE Portland.
authors: [maya]
tags: [coffee]
published_version: "20240315T090000Z"
slug_history:
- /old-home-urlpublished_version points to the version directory name that serves as the live content. slug_history enables redirect lookups for old URLs.
Each version directory is named with a compact ISO timestamp (YYYYMMDDTHHMMSSz). The version.yml contains per-version metadata:
published_at: "2024-03-15T09:00:00Z"
updated_at: "2024-03-15T09:00:00Z"published_at controls draft/published status:
- Absent or
null→ draft - Future datetime → scheduled (draft until that time)
- Past datetime → published
updated_at tracks when the version was last modified — useful for "Edited" dates in collection UIs.
Page-level data (shared across versions): slug, title, layout, authors, tags, meta_, og_, slug_history, extra, published_version.
Version-level data (per version): sections, published_at, updated_at.
The page struct's sections and published_at are populated from the published version for backward compatibility.
Sections live in pages/*/versions/*/sections/. Filename prefix sets order: 01-hero.yml before 02-body.md.
YAML sections — structured data:
template: hero
fields:
heading: Hello, I'm Maya
subheading: I roast coffee
image: headshotMarkdown sections — long-form content with frontmatter:
---
template: richtext
---
This is the about section with **bold** and *italic*.The markdown body becomes the body field, converted to HTML at load time.
Mixed sections — structured YAML fields plus a richtext markdown field:
A .yml file paired with a sibling .field.md file. The convention is {position}-{name}.{fieldname}.md:
sections/
├── 03-cta.yml # structured fields
└── 03-cta.body.md # richtext for the "body" field
03-cta.yml:
template: cta
fields:
heading: Come Visit Us
subheading: We'd love to meet you.03-cta.body.md:
We're open **seven days a week** in SE Portland.The loader merges them into a single Section struct with all fields combined. The markdown is converted to HTML. This avoids awkward inline richtext in YAML files.
Shared section references — reuse a shared section:
shared_section: main-navNested sections — sections that contain child sections:
Child sections live in subdirectories named {NN}-{template}.{field}/ alongside their parent file. The directory name ties to the parent by matching the {NN}-{template} prefix, and the .{field} suffix names the field.
sections/
├── 02-gallery.yml
├── 02-gallery.slides/ # children for the "slides" field
│ ├── 01-slide.yml
│ ├── 01-slide.caption.md # mixed markdown works in children too
│ └── 02-slide.yml
Declare nested fields in the section template with type: sections:
# templates/sections/gallery.yml
fields:
title:
type: string
required: true
slides:
type: sections
of: slide # constrains to "slide" template (single name or list)
required: trueRender children in components using Brix.Render.child_sections/1:
def gallery(assigns) do
~H"""
<div class="gallery">
<h2>{@fields["title"]}</h2>
<Brix.Render.child_sections module={@module} children={@children} field="slides" />
</div>
"""
endThe @children assign is a %{field_name => [Section]} map. @module is passed through so child rendering dispatches to the same component module. Nesting is recursive — children can have their own children.
# Site
Brix.get_site()
# => %Brix.Site{name: "Ember & Bloom", ...}
# Pages — returns published version's sections by default
Brix.get_page("/blog/morning-ritual")
# => {:ok, %Brix.Page{title: "The Morning Ritual", sections: [...], ...}}
# Request a specific version (e.g. preview a draft)
Brix.get_page("/blog/morning-ritual", version: ~U[2024-11-15 14:00:00Z])
# => {:ok, %Brix.Page{sections: [draft sections...], ...}}
# Access all versions
{:ok, page} = Brix.get_page("/blog/morning-ritual")
page.versions # => [%Brix.Version{}, ...]
page.published_version # => ~U[2024-09-01 06:00:00Z]
page.updated_at # => ~U[2024-11-15 14:00:00Z] (from latest version)
Brix.list_pages()
# => [%Brix.Page{}, ...]
# Filtered
Brix.list_pages(tag: "coffee")
Brix.list_pages(author: "maya")
Brix.list_pages(prefix: "/blog/")
Brix.list_pages(status: :published)
Brix.list_pages(status: :draft)
Brix.list_pages(tag: "coffee", prefix: "/blog/", status: :published)
# Layouts
Brix.get_layout("default")
# => {:ok, %Brix.Layout{header_sections: [...], footer_sections: [...]}}
# Authors, Tags, Media
Brix.get_author("maya")
Brix.list_authors()
Brix.get_tag("coffee")
Brix.list_tags()
Brix.get_media("headshot")
Brix.list_media()
# Section templates
Brix.get_section_template("hero")
Brix.list_section_templates()Brix.list_collections()
# => [%Brix.Collection{slug: "blog", name: "Blog", ...}, ...]
{:ok, blog} = Brix.get_collection("blog")
blog
|> Brix.list_collection_pages()
|> Enum.map(& &1.title)
# => ["The Morning Ritual"]Brix.list_shared_sections()
# => [%Brix.SharedSection{name: "main-nav", template: "nav", ...}, ...]
Brix.get_shared_section("main-nav")
# => {:ok, %Brix.SharedSection{...}}# Clock-aware: pages with published_at in the future are still drafts
Brix.list_pages(status: :published) # published_at <= now
Brix.list_pages(status: :draft) # published_at is nil or in the future
# Check a single page
{:ok, page} = Brix.get_page("/blog/upcoming")
Brix.Page.published?(page)
# => false (if published_at is in the future)# When a page has slug_history entries, old slugs resolve to the current one
Brix.find_redirect("/about-us")
# => {:ok, "/about"}
Brix.find_redirect("/nonexistent")
# => :errorsite = Brix.get_site()
{:ok, page} = Brix.get_page("/careers")
Brix.Meta.title(page, site)
# => page.meta_title || page.title || site.meta_title || site.name
Brix.Meta.description(page, site)
# => page.meta_description || site.meta_description
Brix.Meta.og_title(page, site)
# => page.og_title || Meta.title(page, site)
Brix.Meta.og_image(page, site)
# => page.og_image || site.og_imageOne function per section template name. All fields arrive in @fields as a string-keyed map.
defmodule MyAppWeb.Sections do
use Phoenix.Component
import Phoenix.HTML, only: [raw: 1]
def hero(assigns) do
~H"""
<section class="hero">
<h1>{@fields["heading"]}</h1>
<p :if={@fields["subheading"]}>{@fields["subheading"]}</p>
<img :if={@fields["image"]} src={Brix.Render.media_url(@fields["image"])} />
</section>
"""
end
def richtext(assigns) do
~H"""
<div class="prose">{raw(@fields["body"])}</div>
"""
end
def nav(assigns) do
~H"""
<nav>
<a :for={link <- @fields["links"]} href={link["url"]}>{link["label"]}</a>
</nav>
"""
end
def footer(assigns) do
~H"""
<footer>© {@fields["copyright"]}</footer>
"""
end
enddefmodule MyAppWeb.PageLive do
use MyAppWeb, :live_view
def handle_params(params, _uri, socket) do
slug = case params do
%{"slug" => parts} -> "/" <> Enum.join(parts, "/")
_ -> "/"
end
case Brix.get_page(slug) do
{:ok, page} ->
{:ok, layout} = Brix.get_layout(page.layout)
site = Brix.get_site()
{:noreply, assign(socket, page: page, layout: layout, site: site)}
:error ->
# Check for slug redirect
case Brix.find_redirect(slug) do
{:ok, new_slug} -> {:noreply, redirect(socket, to: new_slug)}
:error -> raise MyAppWeb.NotFoundError
end
end
end
def render(assigns) do
~H"""
<Brix.Render.layout layout={@layout} module={MyAppWeb.Sections}>
<Brix.Render.sections sections={@page.sections} module={MyAppWeb.Sections} />
</Brix.Render.layout>
"""
end
end<head>
<title>{Brix.Meta.title(@page, @site)}</title>
<meta name="description" content={Brix.Meta.description(@page, @site)} />
<meta property="og:title" content={Brix.Meta.og_title(@page, @site)} />
<meta property="og:description" content={Brix.Meta.og_description(@page, @site)} />
</head>Brix validates content on boot. Errors block loading. Warnings are logged.
Checked:
- Required fields present
- Field types match template schemas
- All references resolve (layouts, templates, authors, tags, media, shared sections)
- Media files exist on disk
- Unknown fields flagged with "did you mean?" suggestions
result = Brix.Validator.validate("priv/content")
# => %{errors: [], warnings: []}Author → Validate → Load → Serve
- Write YAML/MD files in
priv/content/ - Boot validates all content against templates and cross-references
- Valid content is parsed into structs, markdown converted to HTML, cached in ETS
- LiveView calls
Brix.get_page(slug), renders via section components
Validation gates loading. Bad content never enters the store.