Skip to content
This repository was archived by the owner on Oct 20, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/adr/009-data-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# ADR-009: Data files (`_data/` + `data.*` vars)

- Status: accepted
- Date: 2026-09-22
- Phase: Jekyll parity (post-1.0, slice 3)

## Context

Jekyll `_data/*.yml|yaml|json|csv` feeds `site.data.*` (navigation,
authors, link lists). Plombir has no equivalent, blocking migrants.
The template engine is flat (`String | Array(String) |
Array(Hash(String,String)) | Bool | Nil`) — nested YAML must map
onto it without an engine break.

## Options

1. **Flat `_data/` loader (chosen):** `_data/<name>.{yml,yaml,json}`
→ `data.<name>.*` + `site.data.<name>.*` aliases. Mappings flatten
with dots, sequences bind whole, scalars stringify. No config keys.
2. **Raw `YAML::Any` values:** rejected — needs engine `Value`
widening, renderer/escaping changes, and new error surface for a
need flattening covers.
3. **CSV support in v1:** rejected — no fixture demands it; adds
delimiter/header rules for a hypothetical. Revisit with demand.

## Decision

Option 1. Rules:

- Dir `_data/` optional; absent → no vars, builds byte-identical.
- Extensions `.yml/.yaml/.json`; other files ignored silently.
Basename keeps exact case; use `snake_case` (template lookups are
dotted names).
- Mapping file: each leaf becomes `data.<file>.<dotted.path>`
(+ `site.data.` alias). Sequence/scalar file binds `data.<file>`.
- Leaf shapes: string → `String`; bool → `Bool`; numbers → `String`
(`to_s`); nil → `Nil`; array of scalars → `Array(String)`
(stringified); array of flat maps (string-scalar values) →
`Array(Hash(String,String))` (values stringified). Anything deeper
(nested arrays, maps with array values) fails `✖ Invalid data file`
with file, key path, expected shapes, and fix.
- Bad YAML/JSON fails the same contract (file + detail + example).
- `dev`: `_data/**` classifies as new `Watcher::Kind::Data` → full
rebuild; tiered content rebuilds reload data fresh. `check` covers
via temp build; sitemap/RSS unchanged.

## Consequences

- New: `src/content/data.cr`, watcher `Kind::Data`, pipeline merge
into page + taxonomy renders, `docs/content.md` section.
- No engine change, no config keys, no scaffold bloat.
- Deferred: CSV, `post.tags` in loops, data-driven pages (separate ADRs).

## Tests

- `crystal spec spec/content/data_spec.cr spec/watcher/ spec/build/data_spec.cr --error-on-warnings`
- Full suite green; golden data-driven nav; invalid-shape + invalid-YAML
snapshots; `check` clean on fixture.
25 changes: 25 additions & 0 deletions docs/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,31 @@ Term pages get `taxonomy.type/slug/name` + `taxonomy.items`
sitemap includes archives; `dev` takes the full-rebuild path when
taxonomy layouts exist.

## Data files

`_data/<name>.yml` (or `.yaml`/`.json`) becomes `data.<name>.*`
plus a `site.data.<name>.*` alias (see `adr/009-data-files.md`).
Mappings flatten with dots, sequences bind whole for loops, numbers
stringify. The directory is optional — absent means no vars.

```yaml
# _data/nav.yml
- name: Home
url: /
- name: Blog
url: /blog/
```

```html
{% for link in data.nav %}<a href="{{ link.url }}">{{ link.name }}</a>{% end %}
<p>{{ data.authors.lead.name }} / {{ site.data.authors.lead.name }}</p>
```

Bad YAML/JSON or deep shapes (nested arrays, maps with array
values) fail with `file` + key path + fix; two files sharing a
basename fail fast. `dev` full-rebuilds on `_data/` changes and
reloads data fresh on tiered rebuilds.

## Check

`plombir check [--strict]` runs five sections — Content, Routes,
Expand Down
61 changes: 61 additions & 0 deletions spec/build/data_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
require "../spec_helper"

describe "Data files build" do
it "exposes data vars and aliases in layouts" do
with_tempdir do |dir|
root = write_data_build_site(dir, {
"content/index.md" => "---\ntitle: Home\n---\n\n# Home\n",
"layouts/default.html" => "<nav>{% for link in data.nav %}<a href=\"{{ link.url }}\">{{ link.name }}</a>{% end %}</nav><main>{{ content }}{{ site.data.authors.lead.name }}</main>\n",
"_data/nav.yml" => "- name: Home\n url: /\n- name: Blog\n url: /blog/\n",
"_data/authors.yml" => "lead:\n name: Ada\n",
})

result = Plombir::Build::Pipeline.run(Plombir::Build::Context.new(root))

result.pages.should eq(1)
html = File.read(File.join(root, "dist", "index.html"))
html.should contain(%(<a href="/blog/">Blog</a>))
html.should contain("Ada")
end
end

it "fails invalid data files with file and fix" do
with_tempdir do |dir|
root = write_data_build_site(dir, {
"content/index.md" => "# Home\n",
"layouts/default.html" => "<main>{{ content }}</main>\n",
"_data/nav.yml" => "links:\n - - nested\n",
})

ex = expect_raises(Plombir::Content::Data::Error) do
Plombir::Build::Pipeline.run(Plombir::Build::Context.new(root))
end

ex.message.to_s.should contain("nav.yml")
ex.message.to_s.should contain("✖ Invalid data file")
end
end

it "builds byte-identical output without _data" do
with_tempdir do |dir|
root = write_data_build_site(dir, {
"content/index.md" => "---\ntitle: Home\n---\n\n# Home\n",
"layouts/default.html" => "<main>{{ content }}</main>\n",
})

result = Plombir::Build::Pipeline.run(Plombir::Build::Context.new(root))

result.pages.should eq(1)
File.read(File.join(root, "dist", "index.html")).should contain("<h1>Home</h1>")
end
end
end

private def write_data_build_site(root : String, files : Hash(String, String)) : String
files.each do |relative, body|
path = File.join(root, relative)
Dir.mkdir_p(File.dirname(path))
File.write(path, body)
end
root
end
82 changes: 82 additions & 0 deletions spec/content/data_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
require "../spec_helper"

describe Plombir::Content::Data do
it "returns empty vars without a _data directory" do
with_tempdir do |dir|
Plombir::Content::Data.load(dir).should be_empty
end
end

it "flattens mappings with a site.data alias" do
with_tempdir do |dir|
data = File.join(dir, "_data")
Dir.mkdir_p(data)
File.write(File.join(data, "authors.yml"), "lead:\n name: Ada\n url: /ada/\ncount: 3\nlive: true\n")

vars = Plombir::Content::Data.load(dir)

vars["data.authors.lead.name"].should eq("Ada")
vars["site.data.authors.lead.name"].should eq("Ada")
vars["data.authors.count"].should eq("3")
vars["data.authors.live"].should eq(true)
end
end

it "binds sequences whole for loops" do
with_tempdir do |dir|
data = File.join(dir, "_data")
Dir.mkdir_p(data)
File.write(File.join(data, "nav.json"), %([{"name": "Home", "url": "/"}, {"name": "Blog", "url": "/blog/"}]))
File.write(File.join(data, "tags.yml"), "- a\n- b\n")

vars = Plombir::Content::Data.load(dir)

links = vars["data.nav"].as(Array(Hash(String, String)))
links.map { |row| row["name"] }.should eq(["Home", "Blog"])
vars["site.data.nav"].as(Array(Hash(String, String))).size.should eq(2)
vars["data.tags"].as(Array(String)).should eq(["a", "b"])
end
end

it "rejects deep shapes with file and key" do
with_tempdir do |dir|
data = File.join(dir, "_data")
Dir.mkdir_p(data)
File.write(File.join(data, "nav.yml"), "links:\n - - nested\n")

ex = expect_raises(Plombir::Content::Data::Error) do
Plombir::Content::Data.load(dir)
end

ex.file.should contain("nav.yml")
ex.message.to_s.should contain("✖ Invalid data file")
ex.message.to_s.should contain("data.nav.links")
end
end

it "rejects unparsable files and basename collisions" do
with_tempdir do |dir|
data = File.join(dir, "_data")
Dir.mkdir_p(data)
File.write(File.join(data, "broken.yml"), "title: [oops\n")

bad = expect_raises(Plombir::Content::Data::Error) do
Plombir::Content::Data.load(dir)
end
bad.message.to_s.should contain("✖ Invalid data file")
bad.message.to_s.should contain("broken.yml")
end

with_tempdir do |dir|
data = File.join(dir, "_data")
Dir.mkdir_p(data)
File.write(File.join(data, "nav.yml"), "a: 1\n")
File.write(File.join(data, "nav.json"), %({"a": 1}))

clash = expect_raises(Plombir::Content::Data::Error) do
Plombir::Content::Data.load(dir)
end
clash.message.to_s.should contain("share the `nav` name")
end
end
end
1 change: 1 addition & 0 deletions spec/watcher/watcher_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ describe Plombir::Watcher do
Plombir::Watcher.classify("assets/app.css").should eq(Plombir::Watcher::Kind::Asset)
Plombir::Watcher.classify("plombir.yml").should eq(Plombir::Watcher::Kind::Config)
Plombir::Watcher.classify("public/notes.txt").should eq(Plombir::Watcher::Kind::Public)
Plombir::Watcher.classify("_data/nav.yml").should eq(Plombir::Watcher::Kind::Data)
end

it "ignores generated output, dotfiles, strays, and backups" do
Expand Down
6 changes: 5 additions & 1 deletion src/build/incremental.cr
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ module Plombir
if kinds.includes?(Watcher::Kind::Public) || kinds.includes?(Watcher::Kind::Asset)
return full_as(Tier::Full, "public assets changed", started)
end
if kinds.includes?(Watcher::Kind::Data)
return full_as(Tier::Full, "_data changed", started)
end
if paginated_site?
return full_as(Tier::Full, "pagination changed", started)
end
Expand Down Expand Up @@ -273,14 +276,15 @@ module Plombir
entries = Pipeline.discover(@context)
routes = Pipeline.resolve(entries, @context.patterns)
collections = Pipeline.collection_vars(entries, routes)
content_data = Content::Data.load(@context.root)
assets = Assets::Manifest.read(@context.output_dir) || {} of String => String
files = targets.map do |relative|
started = Time.instant
entry = entries.find! { |e| e.page.relative_path == relative }
route = routes[relative]
destination = File.join(@context.output_dir, route.output_path)
Dir.mkdir_p(File.dirname(destination))
File.write(destination, Pipeline.render_one(entry, route, @context, collections, nil, nil, assets, [] of String))
File.write(destination, Pipeline.render_one(entry, route, @context, collections, nil, nil, assets, [] of String, {} of String => Renderer::Page::Value, content_data))

record = graph.pages[relative]
old_layout = record.layout
Expand Down
21 changes: 14 additions & 7 deletions src/build/pipeline.cr
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,11 @@ module Plombir
collections = collection_vars(entries, routes)
extras = build_extras(entries, routes, collections)
taxo = build_taxonomy(entries, routes, context, extras)
content_data = Content::Data.load(context.root)
prepare_output(context)
assets = process_assets(context)
warnings = assets.warnings.dup
render_all(entries, routes, context, assets.files, warnings, collections, extras, taxo)
render_all(entries, routes, context, assets.files, warnings, collections, extras, taxo, content_data)
write_seo_files(entries, routes, context, extras, taxo)
copy_public(context)

Expand Down Expand Up @@ -295,6 +296,7 @@ module Plombir
collections : Hash(String, Renderer::Page::Value),
extras : Array(Extra),
taxo : Array(TaxoPage) = [] of TaxoPage,
content_data : Hash(String, Content::Data::Value) = {} of String => Content::Data::Value,
) : Nil
partials = Renderer::Page.partial_sources(context.layouts_dir)
components = Renderer::Page.component_sources(context.components_dir)
Expand All @@ -303,7 +305,7 @@ module Plombir
route = routes[entry.page.relative_path]
missing = [] of String
paginator = first_vars[entry.page.relative_path]? || {} of String => Renderer::Page::Value
html = render_one(entry, route, context, collections, partials, components, manifest, missing, paginator)
html = render_one(entry, route, context, collections, partials, components, manifest, missing, paginator, content_data)
missing.each do |reference|
warnings << "#{entry.page.relative_path} references missing asset #{reference.inspect} — add it under assets/ (fingerprinted) or public/ (as-is)."
end
Expand All @@ -314,7 +316,7 @@ module Plombir
end
extras.each do |extra|
missing = [] of String
html = render_one(extra.entry, extra.route, context, collections, partials, components, manifest, missing, extra.vars)
html = render_one(extra.entry, extra.route, context, collections, partials, components, manifest, missing, extra.vars, content_data)
missing.each do |reference|
warnings << "#{extra.entry.page.relative_path} references missing asset #{reference.inspect} — add it under assets/ (fingerprinted) or public/ (as-is)."
end
Expand All @@ -324,7 +326,7 @@ module Plombir
File.write(destination, html)
end
taxo.each do |page|
html = render_taxonomy(page, context, partials, components, manifest)
html = render_taxonomy(page, context, partials, components, manifest, content_data)
html = Utils::Html.minify(html) if context.minify
destination = File.join(context.output_dir, page.route.output_path)
Dir.mkdir_p(File.dirname(destination))
Expand All @@ -334,7 +336,8 @@ module Plombir

# Renders one taxonomy archive (term or index) inside its dedicated
# layout with `taxonomy.*` vars plus standard `title/url/site.*/seo_head`.
private def self.render_taxonomy(page : TaxoPage, context : Context, partials : Renderer::Page::Partials, components : Renderer::Page::Components, manifest : Hash(String, String)) : String
# *content_data* carries `data.*` vars like content pages get.
private def self.render_taxonomy(page : TaxoPage, context : Context, partials : Renderer::Page::Partials, components : Renderer::Page::Components, manifest : Hash(String, String), content_data : Hash(String, Content::Data::Value) = {} of String => Content::Data::Value) : String
vars = Renderer::Page::Context.new
vars["title"] = page.title
vars["description"] = ""
Expand All @@ -350,6 +353,7 @@ module Plombir
vars["taxonomy.slug"] = page.slug
vars["taxonomy.items"] = page.items
vars["taxonomy.terms"] = page.terms
content_data.each { |key, value| vars[key] = value }
file = "taxonomy:#{page.route.url}"
Renderer::Page.render_file("", page.layout, context.layouts_dir, vars, file, nil, partials, components, manifest)
end
Expand Down Expand Up @@ -416,8 +420,10 @@ module Plombir
# manifest for `| asset_url` and the `/assets/…` rewrite; refs
# backed by neither `assets/` nor `public/` are collected into
# *missing* for the caller to warn about. *paginator* carries
# `paginator.*` vars for paginated listings (empty otherwise).
def self.render_one(entry : Entry, route : Router::Route, context : Context, collections : Hash(String, Renderer::Page::Value) = {} of String => Renderer::Page::Value, partials : Renderer::Page::Partials? = nil, components : Renderer::Page::Components? = nil, assets : Hash(String, String) = {} of String => String, missing : Array(String) = [] of String, paginator : Hash(String, Renderer::Page::Value) = {} of String => Renderer::Page::Value) : String
# `paginator.*` vars for paginated listings (empty otherwise);
# *content_data* carries `data.*` + `site.data.*` vars (empty
# without `_data/`).
def self.render_one(entry : Entry, route : Router::Route, context : Context, collections : Hash(String, Renderer::Page::Value) = {} of String => Renderer::Page::Value, partials : Renderer::Page::Partials? = nil, components : Renderer::Page::Components? = nil, assets : Hash(String, String) = {} of String => String, missing : Array(String) = [] of String, paginator : Hash(String, Renderer::Page::Value) = {} of String => Renderer::Page::Value, content_data : Hash(String, Content::Data::Value) = {} of String => Content::Data::Value) : String
body = Markdown.render(entry.document.body)
vars = Renderer::Page::Context.new
slug = Router.slugify(File.basename(entry.page.relative_path, ".md"))
Expand All @@ -431,6 +437,7 @@ module Plombir
vars["site.url"] = context.site.url
vars["seo_head"] = seo_head(entry, route, slug, context.site, assets)
collections.each { |key, value| vars[key] = value }
content_data.each { |key, value| vars[key] = value }
paginator.each { |key, value| vars[key] = value }
layout_line = entry.document.data.has_key?("layout") ? entry.document.line_of("layout") : nil
rendered = Renderer::Page.render_file(body, entry.document.layout, context.layouts_dir, vars, entry.page.relative_path, layout_line, partials, components, assets)
Expand Down
2 changes: 1 addition & 1 deletion src/cli/dev.cr
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ module Plombir

io.puts ""
io.puts "Serving #{output}/ at #{config_root.url} (rebuilding on change)"
io.puts "Watching content/ layouts/ public/ assets/ plombir.yml for changes"
io.puts "Watching content/ layouts/ public/ assets/ _data/ plombir.yml for changes"
io.puts "Press Ctrl+C to stop"
watcher = Plombir::Watcher::Watcher.new(directory)
Signal::INT.trap do
Expand Down
Loading
Loading