From 11be6579ee3648e55523343058d3e1f7e0221e03 Mon Sep 17 00:00:00 2001 From: Subhan Gadirli Date: Wed, 23 Sep 2026 10:51:49 +0400 Subject: [PATCH 1/2] feat(content): add _data loader with site.data aliases ADR-009 core: yml/yaml/json, dotted flattening, loopable rows, domain errors. Verified: data_spec 5/0/0. --- docs/adr/009-data-files.md | 58 ++++++++++ spec/content/data_spec.cr | 82 ++++++++++++++ src/content/data.cr | 227 +++++++++++++++++++++++++++++++++++++ src/plombir.cr | 1 + 4 files changed, 368 insertions(+) create mode 100644 docs/adr/009-data-files.md create mode 100644 spec/content/data_spec.cr create mode 100644 src/content/data.cr diff --git a/docs/adr/009-data-files.md b/docs/adr/009-data-files.md new file mode 100644 index 0000000..f7c71a8 --- /dev/null +++ b/docs/adr/009-data-files.md @@ -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/.{yml,yaml,json}` + → `data..*` + `site.data..*` 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..` + (+ `site.data.` alias). Sequence/scalar file binds `data.`. +- 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. diff --git a/spec/content/data_spec.cr b/spec/content/data_spec.cr new file mode 100644 index 0000000..3a0723d --- /dev/null +++ b/spec/content/data_spec.cr @@ -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 diff --git a/src/content/data.cr b/src/content/data.cr new file mode 100644 index 0000000..bc4c7f5 --- /dev/null +++ b/src/content/data.cr @@ -0,0 +1,227 @@ +# Plombir::Content::Data loads `_data/` files into template vars. +# +# `_data/.{yml,yaml,json}` becomes `data..*` plus a +# `site.data..*` alias (see ADR-009). Mappings flatten with +# dots (`author.name`), sequences bind whole, scalars stringify — +# everything the flat template engine already renders. The directory +# is optional; absent means no vars and byte-identical builds. +# +# ``` +# Data.load("/sites/blog") # => {"data.nav.links" => [...], ...} +# ``` +module Plombir + module Content + module Data + # Template-ready values (same members as the engine's `Value`, + # spelled locally so `content/` never imports the renderer). + alias Value = String | Array(String) | Array(Hash(String, String)) | Bool | Nil + + # Raised when a data file cannot be parsed or holds shapes the + # engine cannot render (nested arrays, maps with array values). + class Error < Exception + getter file : String + + def initialize(@file : String, message : String) + super(message) + end + end + + # Loads every `_data/*.{yml,yaml,json}` file under *root*. + # Files sort by path; basename collisions (`nav.yml` + + # `nav.json`) raise. Subdirectories and other extensions are + # ignored. + def self.load(root : String) : Hash(String, Value) + vars = {} of String => Value + dir = File.join(root, "_data") + return vars unless Dir.exists?(dir) + + seen = Set(String).new + files = Dir.glob(File.join(dir, "*.{yml,yaml,json}")).sort + files.each do |path| + next unless File.file?(path) + base = File.basename(path).sub(/\.(yml|yaml|json)\z/, "") + if seen.includes?(base) + raise Error.new(path, collision_message(path, base)) + end + seen << base + begin + if path.ends_with?(".json") + flatten_json(JSON.parse(File.read(path)), "data.#{base}", path, vars) + else + flatten_yaml(YAML.parse(File.read(path)), "data.#{base}", path, vars) + end + rescue ex : YAML::ParseException | JSON::ParseException + raise Error.new(path, parse_message(path, ex.message)) + end + end + vars + end + + # Binds *value* and its `site.` alias into *vars*. + private def self.bind(key : String, value : Value, vars : Hash(String, Value)) : Nil + vars[key] = value + vars["site." + key] = value + end + + # Flattens one YAML node at *key*. + private def self.flatten_yaml(node : YAML::Any, key : String, file : String, vars : Hash(String, Value)) : Nil + raw = node.raw + case raw + when Nil + bind(key, nil, vars) + when String + bind(key, raw, vars) + when Bool + bind(key, raw, vars) + when Int, Float + bind(key, raw.to_s, vars) + when Array + bind(key, yaml_array(node.as_a, key, file), vars) + when Hash + mapping = node.as_h + if mapping.empty? + return + end + mapping.each do |raw_key, child| + label = raw_key.as_s? || raw_key.to_s + flatten_yaml(child, "#{key}.#{label}", file, vars) + end + else + raise Error.new(file, shape_message(file, key)) + end + end + + # Converts a YAML sequence at *key* (scalars → strings, flat + # maps → stringified rows). + private def self.yaml_array(list : Array(YAML::Any), key : String, file : String) : Value + return [] of String if list.empty? + if list.all? { |entry| scalar_yaml?(entry) } + return list.map { |entry| scalar_yaml_string(entry) } + end + if list.all? { |entry| flat_map_yaml?(entry) } + return list.map do |entry| + row = {} of String => String + entry.as_h.each { |k, v| row[k.as_s? || k.to_s] = scalar_yaml_string(v) } + row + end + end + raise Error.new(file, shape_message(file, key)) + end + + private def self.scalar_yaml?(node : YAML::Any) : Bool + case node.raw + when String, Bool, Int, Float, Nil then true + else false + end + end + + private def self.scalar_yaml_string(node : YAML::Any) : String + raw = node.raw + case raw + when Nil then "" + when Bool then raw ? "true" : "false" + when String then raw + else raw.to_s + end + end + + private def self.flat_map_yaml?(node : YAML::Any) : Bool + mapping = node.as_h? + return false if mapping.nil? + mapping.values.all? { |child| scalar_yaml?(child) } + end + + # Flattens one JSON node at *key*. + private def self.flatten_json(node : JSON::Any, key : String, file : String, vars : Hash(String, Value)) : Nil + raw = node.raw + case raw + when Nil + bind(key, nil, vars) + when String + bind(key, raw, vars) + when Bool + bind(key, raw, vars) + when Int, Float + bind(key, raw.to_s, vars) + when Array + bind(key, json_array(node.as_a, key, file), vars) + when Hash + mapping = node.as_h + return if mapping.empty? + mapping.each do |child_key, child| + flatten_json(child, "#{key}.#{child_key}", file, vars) + end + else + raise Error.new(file, shape_message(file, key)) + end + end + + private def self.json_array(list : Array(JSON::Any), key : String, file : String) : Value + return [] of String if list.empty? + if list.all? { |entry| scalar_json?(entry) } + return list.map { |entry| scalar_json_string(entry) } + end + if list.all? { |entry| flat_map_json?(entry) } + return list.map do |entry| + row = {} of String => String + entry.as_h.each { |k, v| row[k] = scalar_json_string(v) } + row + end + end + raise Error.new(file, shape_message(file, key)) + end + + private def self.scalar_json?(node : JSON::Any) : Bool + case node.raw + when String, Bool, Int, Float, Nil then true + else false + end + end + + private def self.scalar_json_string(node : JSON::Any) : String + raw = node.raw + case raw + when Nil then "" + when Bool then raw ? "true" : "false" + when String then raw + else raw.to_s + end + end + + private def self.flat_map_json?(node : JSON::Any) : Bool + mapping = node.as_h? + return false if mapping.nil? + mapping.values.all? { |child| scalar_json?(child) } + end + + private def self.shape_message(file : String, key : String) : String + String.build do |io| + io << "✖ Invalid data file\n\n" + io << file << "\n\n" + io << "Key: " << key << "\n\n" + io << "Expected a string, bool, number, list of values, or list of {key: value} maps with scalar values.\n\n" + io << "Example:\nnav:\n - name: Home\n url: /\n" + end + end + + private def self.parse_message(file : String, detail : String?) : String + String.build do |io| + io << "✖ Invalid data file\n\n" + io << file << "\n\n" + io << detail << "\n\n" if detail + io << "Expected valid YAML or JSON.\n\n" + io << "Example:\nname: Home\nurl: /\n" + end + end + + private def self.collision_message(file : String, base : String) : String + String.build do |io| + io << "✖ Invalid data file\n\n" + io << file << "\n\n" + io << "Two data files share the `#{base}` name.\n\n" + io << "Keep one `_data/#{base}.yml` (or `.json`) and remove the other." + end + end + end + end +end diff --git a/src/plombir.cr b/src/plombir.cr index b72c133..5c17545 100644 --- a/src/plombir.cr +++ b/src/plombir.cr @@ -11,6 +11,7 @@ require "./content/document" require "./content/collection" require "./content/pagination" require "./content/taxonomy" +require "./content/data" require "./content/schema" require "./router/router" require "./frontmatter/parser" From 747078927d8ad5339291fbd703477b62b95fd4df Mon Sep 17 00:00:00 2001 From: Subhan Gadirli Date: Wed, 23 Sep 2026 10:54:07 +0400 Subject: [PATCH 2/2] feat(build): wire _data vars into renders with full-rebuild on change ADR-009 integration: page + taxonomy renders, tiered reload, Kind::Data. Verified: data build 3/0/0, full 445 green, shards build ok. --- docs/content.md | 25 +++++++++++++++ spec/build/data_spec.cr | 61 ++++++++++++++++++++++++++++++++++++ spec/watcher/watcher_spec.cr | 1 + src/build/incremental.cr | 6 +++- src/build/pipeline.cr | 21 ++++++++----- src/cli/dev.cr | 2 +- src/watcher/watcher.cr | 2 ++ 7 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 spec/build/data_spec.cr diff --git a/docs/content.md b/docs/content.md index 4876c1a..b566cdb 100644 --- a/docs/content.md +++ b/docs/content.md @@ -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/.yml` (or `.yaml`/`.json`) becomes `data..*` +plus a `site.data..*` 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 %}{{ link.name }}{% end %} +

{{ data.authors.lead.name }} / {{ site.data.authors.lead.name }}

+``` + +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, diff --git a/spec/build/data_spec.cr b/spec/build/data_spec.cr new file mode 100644 index 0000000..402ffb0 --- /dev/null +++ b/spec/build/data_spec.cr @@ -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" => "
{{ content }}{{ site.data.authors.lead.name }}
\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(%(Blog)) + 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" => "
{{ content }}
\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" => "
{{ content }}
\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("

Home

") + 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 diff --git a/spec/watcher/watcher_spec.cr b/spec/watcher/watcher_spec.cr index 4fe1879..55eda1f 100644 --- a/spec/watcher/watcher_spec.cr +++ b/spec/watcher/watcher_spec.cr @@ -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 diff --git a/src/build/incremental.cr b/src/build/incremental.cr index 6e9f257..099ff13 100644 --- a/src/build/incremental.cr +++ b/src/build/incremental.cr @@ -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 @@ -273,6 +276,7 @@ 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 @@ -280,7 +284,7 @@ module Plombir 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 diff --git a/src/build/pipeline.cr b/src/build/pipeline.cr index e73ce41..9e5da56 100644 --- a/src/build/pipeline.cr +++ b/src/build/pipeline.cr @@ -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) @@ -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) @@ -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 @@ -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 @@ -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)) @@ -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"] = "" @@ -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 @@ -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")) @@ -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) diff --git a/src/cli/dev.cr b/src/cli/dev.cr index 89c085d..e3e2060 100644 --- a/src/cli/dev.cr +++ b/src/cli/dev.cr @@ -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 diff --git a/src/watcher/watcher.cr b/src/watcher/watcher.cr index 7fa02c3..5edee7e 100644 --- a/src/watcher/watcher.cr +++ b/src/watcher/watcher.cr @@ -15,6 +15,7 @@ module Plombir Asset # assets/** — passthrough / affected pages Config # plombir.yml — full rebuild Public # public/** — copy through + Data # _data/** — full rebuild (every page can read it) end # How the file changed between two snapshots. @@ -54,6 +55,7 @@ module Plombir when "layouts" then Kind::Layout when "assets" then Kind::Asset when "public" then Kind::Public + when "_data" then Kind::Data else Kind::Config if path == "plombir.yml" end