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
69 changes: 69 additions & 0 deletions docs/adr/008-taxonomies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# ADR-008: Taxonomies (tags/categories auto pages)

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

## Context

Plombir has per-page `tags:` but no archive pages; Jekyll parity
needs `/tags/:slug/` + `/categories/:slug/` without plugins. Jekyll
itself needs `jekyll-archives` for this — Plombir should do it
zero-config. Must stay HTML-first, one binary, `file:line` errors,
no template-engine break (rows stay `Hash(String, String)`).

## Options

1. **Auto-generate from frontmatter (chosen):** `tags:` (exists) +
new `categories:`/`category:` alias feed `/tags/`, `/tags/:slug/`,
`/categories/`, `/categories/:slug/` pages. No source files, no
`plombir.yml` keys in v1.
2. **Source-driven `content/tags/*.md`:** rejected — forces authors
to hand-maintain an archive per tag; drifts from posts.
3. **Config-gated `taxonomies:` block:** rejected for v1 — adds
config surface before the zero-config default is proven; revisit
with disable/rename demand.

## Decision

Option 1. Rules:

- `categories:` mirrors `tags:` (scalar-or-list, strip, drop
empties); singular `category:` merges as alias. Bad shapes fail
`✖ Invalid frontmatter` (field/received/expected/example).
- Terms group by slug (`Utils.slugify`); display name is first-seen
raw value. Empty taxonomies emit nothing.
- URLs: `/tags/`, `/tags/:slug/`, `/categories/`,
`/categories/:slug/`. Clashes with content routes raise
`Router::Conflict`.
- Opt-in by layout presence (v1, keeps existing builds byte-identical):
term pages generate only when `layouts/tag.html` / `layouts/category.html`
exists; index pages only when `layouts/tags.html` / `layouts/categories.html`
exists. No terms → no pages even with layouts. Missing `default.html`
is irrelevant — taxonomy pages never fall back; they render with their
dedicated layout only.
- Template vars (flat, strings + `taxonomy.items` rows of
`title/url/excerpt/date`):
`taxonomy.type` (`tags`/`categories`), `taxonomy.name` (term display,
`""` on indexes), `taxonomy.slug`, `taxonomy.items`, plus standard
`title/url/site.*/seo_head`. Index pages also get `taxonomy.terms`
looped via `term.name/term.url/term.count`? v1 keeps `taxonomy.items`
only for term pages and renders index links from the same rows?
Decision: index gets `taxonomy.terms` as `Array(Hash)` of
`name/slug/url/count` (strings) — loopable with existing engine.
- Sitemap includes taxonomy URLs; RSS unchanged; `check` covers via
temp build; `dev` takes the full-rebuild path when taxonomy layouts exist.

## Consequences

- New: `src/content/taxonomy.cr`, frontmatter `categories`,
pipeline term/index generation, `docs/content.md` rows.
- No engine `Value` change, no config keys, no row-shape change.
- Deferred: custom permalinks/disable flag, `post.tags` in loops,
combined pagination+taxonomy (separate ADRs).

## Tests

- `crystal spec spec/content/taxonomy_spec.cr spec/frontmatter/ spec/build/taxonomy_spec.cr --error-on-warnings`
- Full `crystal spec --error-on-warnings` green; golden term + index
HTML; conflict + bad-category snapshots; `check` clean on fixture.
40 changes: 39 additions & 1 deletion docs/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,16 @@ deliberate and documented in code.
| `description` | string | `""` |
| `date` | `YYYY-MM-DD` or RFC3339 (quoted or not) | file mtime (ADR-002) |
| `tags` | string or list of strings | `[]` |
| `categories` / `category` | string or list of strings (merged) | `[]` |
| `layout` | string | `"default"` |
| `draft` | bool | `false` |
| `permalink` | string | unset (conventional URL or pattern) |
| `excerpt` | string | derived (see below) |
| `paginate` | int > 0 | unset (no pagination) |
| `paginate_collection` | string | `"posts"` |
| `paginate_path` | string with `:num` | `"<page_url>page/:num/"` |

Tags strip whitespace and drop empties in both forms. Invalid values
Tags and categories strip whitespace and drop empties in both forms. Invalid values
fail with the field, the received source line, and the expectation:

```text
Expand Down Expand Up @@ -120,6 +124,40 @@ none), `previous_page_path`/`next_page_path`. Bad `paginate:` values
fail with `file:line` + fix; URL clashes fail as duplicate routes;
sitemap includes siblings; `dev` takes the full-rebuild path.

## Taxonomies

`tags:` (exists) plus `categories:`/`category:` alias group posts
into archives (see `adr/008-taxonomies.md`). Opt-in by layout
presence — no layouts, no pages, existing builds byte-identical:

- `layouts/tag.html` → `/tags/:slug/` term pages
- `layouts/tags.html` → `/tags/` index
- `layouts/category.html` → `/categories/:slug/`
- `layouts/categories.html` → `/categories/` index

```markdown
---
tags:
- crystal
categories: guides
---
```

```html
<!-- layouts/tag.html -->
<h1>{{ taxonomy.name }}</h1>
{% for post in taxonomy.items %}<a href="{{ post.url }}">{{ post.title }}</a>{% end %}

<!-- layouts/tags.html -->
{% for term in taxonomy.terms %}<a href="{{ term.url }}">{{ term.name }} ({{ term.count }})</a>{% end %}
```

Term pages get `taxonomy.type/slug/name` + `taxonomy.items`
(`title/url/excerpt/date` rows); index pages get `taxonomy.terms`
(`name/slug/url/count` rows). Clashes fail as duplicate routes;
sitemap includes archives; `dev` takes the full-rebuild path when
taxonomy layouts exist.

## Check

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

describe "Taxonomy build" do
it "emits nothing without taxonomy layouts" do
with_tempdir do |dir|
root = write_taxonomy_build_site(dir, {
"content/posts/a.md" => "---\ntitle: A\ndate: 2026-09-12\ntags: crystal\n---\n\n# A\n",
"layouts/default.html" => "<main>{{ content }}</main>\n",
})

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

result.pages.should eq(1)
File.exists?(File.join(root, "dist", "tags", "index.html")).should be_false
end
end

it "generates term and index pages when layouts opt in" do
with_tempdir do |dir|
root = write_taxonomy_build_site(dir, {
"content/posts/new.md" => "---\ntitle: New\ndate: 2026-09-12\ntags:\n - Crystal\n---\n\n# New\n",
"content/posts/old.md" => "---\ntitle: Old\ndate: 2026-09-10\ntags:\n - crystal\ncategories: guides\n---\n\n# Old\n",
"layouts/default.html" => "<main>{{ content }}</main>\n",
"layouts/tag.html" => "<h1>{{ taxonomy.name }}</h1>{% for post in taxonomy.items %}<a href=\"{{ post.url }}\">{{ post.title }}</a>{% end %}\n",
"layouts/tags.html" => "<h1>Tags</h1>{% for term in taxonomy.terms %}<a href=\"{{ term.url }}\">{{ term.name }} ({{ term.count }})</a>{% end %}\n",
"layouts/category.html" => "<h1>{{ taxonomy.name }}</h1>{% for post in taxonomy.items %}{{ post.title }}{% end %}\n",
"layouts/categories.html" => "<h1>Cats</h1>{% for term in taxonomy.terms %}{{ term.name }}{% end %}\n",
})

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

result.pages.should eq(6)
term = File.read(File.join(root, "dist", "tags", "crystal", "index.html"))
term.should contain("<h1>Crystal</h1>")
term.should contain("New")
term.should contain("Old")
index = File.read(File.join(root, "dist", "tags", "index.html"))
index.should contain("/tags/crystal/")
cat = File.read(File.join(root, "dist", "categories", "guides", "index.html"))
cat.should contain("Old")
sitemap = File.read(File.join(root, "dist", "sitemap.xml"))
sitemap.should contain("<loc>/tags/crystal/</loc>")
sitemap.should contain("<loc>/categories/</loc>")
end
end

it "fails taxonomy clashes as duplicate routes" do
with_tempdir do |dir|
root = write_taxonomy_build_site(dir, {
"content/posts/a.md" => "---\ntitle: A\ndate: 2026-09-12\ntags: crystal\n---\n\n# A\n",
"content/tags/crystal.md" => "---\ntitle: Clash\n---\n\n# Clash\n",
"layouts/default.html" => "<main>{{ content }}</main>\n",
"layouts/tag.html" => "{{ taxonomy.name }}\n",
})

expect_raises(Plombir::Router::Conflict) do
Plombir::Build::Pipeline.run(Plombir::Build::Context.new(root))
end
end
end
end

private def write_taxonomy_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
52 changes: 52 additions & 0 deletions spec/content/taxonomy_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
require "../spec_helper"

describe Plombir::Content::Taxonomy do
it "groups tags by slug with first-seen names and newest-first items" do
with_tempdir do |dir|
root = write_taxonomy_site(dir, {
"content/posts/new.md" => "---\ntitle: New\ndate: 2026-09-12\ntags:\n - Crystal\n---\n\n# New\n",
"content/posts/old.md" => "---\ntitle: Old\ndate: 2026-09-10\ntags:\n - crystal\n - news\n---\n\n# Old\n",
"content/posts/plain.md" => "---\ntitle: Plain\ndate: 2026-09-11\n---\n\n# Plain\n",
})
context = Plombir::Build::Context.new(root)
entries = Plombir::Build::Pipeline.discover(context)
routes = Plombir::Build::Pipeline.resolve(entries)
pages = entries.map { |e| {e.page, e.document} }

terms = Plombir::Content::Taxonomy.terms(pages, routes, "tags")

terms.map(&.slug).should eq(["crystal", "news"])
crystal = terms.find! { |t| t.slug == "crystal" }
crystal.name.should eq("Crystal")
crystal.items.map { |row| row["title"] }.should eq(["New", "Old"])
crystal.url("tags").should eq("/tags/crystal/")
end
end

it "groups categories including the singular alias" do
with_tempdir do |dir|
root = write_taxonomy_site(dir, {
"content/posts/a.md" => "---\ntitle: A\ndate: 2026-09-12\ncategories: guides\n---\n\n# A\n",
"content/posts/b.md" => "---\ntitle: B\ndate: 2026-09-11\ncategory: news\n---\n\n# B\n",
})
context = Plombir::Build::Context.new(root)
entries = Plombir::Build::Pipeline.discover(context)
routes = Plombir::Build::Pipeline.resolve(entries)
pages = entries.map { |e| {e.page, e.document} }

terms = Plombir::Content::Taxonomy.terms(pages, routes, "categories")

terms.map(&.slug).should eq(["guides", "news"])
Plombir::Content::Taxonomy.index_url("categories").should eq("/categories/")
end
end
end

private def write_taxonomy_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
17 changes: 17 additions & 0 deletions spec/frontmatter/parser_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,23 @@ describe Plombir::Frontmatter do
ex.message.to_s.should contain("true or false")
end

it "reads categories with a singular alias, merged deduped" do
Plombir::Frontmatter.parse("Body\n", "a.md").categories.should eq([] of String)
Plombir::Frontmatter.parse("---\ncategories: guides\n---\nBody\n", "b.md").categories.should eq(["guides"])
Plombir::Frontmatter.parse("---\ncategories:\n - a\n - b\n---\nBody\n", "c.md").categories.should eq(["a", "b"])
Plombir::Frontmatter.parse("---\ncategory: news\n---\nBody\n", "d.md").categories.should eq(["news"])
Plombir::Frontmatter.parse("---\ncategories:\n - a\ncategory: b\n---\nBody\n", "e.md").categories.should eq(["a", "b"])
end

it "rejects mapping categories with file and line" do
ex = expect_raises(Plombir::Frontmatter::Error) do
Plombir::Frontmatter.parse("---\ncategories:\n key: value\n---\nBody\n", "c.md").categories
end

ex.message.to_s.should contain("c.md:2")
ex.message.to_s.should contain("single value or a list")
end

it "reads pagination keys, defaulting collection to posts" do
plain = Plombir::Frontmatter.parse("Body\n", "a.md")
plain.paginate_per_page.should be_nil
Expand Down
13 changes: 13 additions & 0 deletions src/build/incremental.cr
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,9 @@ module Plombir
if paginated_site?
return full_as(Tier::Full, "pagination changed", started)
end
if taxonomy_layouts_present?
return full_as(Tier::Full, "taxonomies changed", started)
end
if events.any? { |e| e.kind == Watcher::Kind::Content && e.change != Watcher::Change::Modified }
return full_as(Tier::Full, "pages added or removed", started)
end
Expand Down Expand Up @@ -310,6 +313,16 @@ module Plombir
end
end

# Taxonomy archives aggregate every post, so any content change
# can move them — take the full path when their layouts opt in
# (see ADR-008). File checks only, no discovery cost.
private def taxonomy_layouts_present? : Bool
layouts = @context.layouts_dir
%w[tag.html tags.html category.html categories.html].any? do |name|
File.file?(File.join(layouts, name))
end
end

private def refresh_graph : Nil
entries = Pipeline.discover(@context)
routes = Pipeline.resolve(entries, @context.patterns)
Expand Down
Loading
Loading