Add controlled tag taxonomy and filters
Deploy production / deploy (push) Successful in 1s

This commit is contained in:
2026-08-11 22:01:22 -07:00
parent bdcad1321f
commit 2b4eb9f0f6
26 changed files with 438 additions and 36 deletions
+23
View File
@@ -35,6 +35,26 @@ making changes; it is the authoritative operations and deployment runbook.
- Make focused changes. Avoid opportunistic refactors, design changes, or
content rewrites outside the requested task.
## Tags and filtering
- Treat the child records under `content/tags/` as the single controlled tag
vocabulary. Their lowercase, hyphenated directory names are the canonical
slugs stored in entry `tags` fields and used in public tag URLs.
- Keep the entry model's checkbox choices dynamically sourced from
`content/tags/`; do not duplicate the vocabulary in model configuration or
introduce free-form spelling and capitalization variants.
- Add a tag record before assigning its slug to an entry. When renaming or
retiring a tag, update all references deliberately and verify that no orphan
slug or broken tag URL remains.
- Preserve normal links to dedicated tag pages as the no-JavaScript baseline.
Client-side filtering must remain a progressive enhancement with semantic
buttons, keyboard support, and an announced result count.
- Tag-directory counts and dedicated tag pages must include both blog entries
and articles, but not project records unless the documented product scope is
explicitly changed.
- When `README.md` changes, keep the README-derived example blog entry aligned
with the runbook content it demonstrates.
## Validation
- Inspect the existing implementation before editing; do not infer that a file,
@@ -46,6 +66,9 @@ making changes; it is the authoritative operations and deployment runbook.
- When template changes rely on updated CSS or other static assets, increment
the relevant cache-busting version in the template and verify that generated
pages reference the new asset URL.
- For taxonomy changes, verify every entry tag has a matching `content/tags/`
record, tag counts match rendered blog/article usage, dedicated tag URLs
build, and listing filters work with valid, absent, and unknown query values.
- Run `git diff --check` and review the exact diff before committing.
- If a push was requested, verify the Gitea Action result, deployed revision,
and site health instead of treating a successful `git push` as completion.
+52 -1
View File
@@ -59,9 +59,10 @@ server is not part of production serving; nginx serves generated files only.
```text
Labyricorn.lektorproject Lektor project definition
content/ Editable site content (`contents.lr` files)
content/tags/ Controlled tag vocabulary and dedicated tag routes
models/ Lektor content models
templates/ Jinja templates
assets/static/ CSS, favicon, and other static source assets
assets/static/ CSS, filtering JavaScript, favicon, and static assets
.gitignore Excludes generated and local files
README.md This runbook
```
@@ -99,6 +100,56 @@ Do not edit generated files under `/srv/labyricorn/current` or
`/srv/labyricorn/releases`. They will be replaced by deployment and are not
source-controlled.
## Tags and filtering workflow
`content/tags/` is the single source of truth for the controlled tag
vocabulary. Each child directory is a normal Lektor record whose directory name
is the canonical lowercase, hyphenated slug used in entry data and URLs:
```text
content/tags/deployment/contents.lr -> /tags/deployment/
```
The tag record supplies the editor label and public description:
_model: tag
---
title: Deployment
---
summary: Build, release, rollout, verification, and rollback workflows.
The entry model reads its checkbox choices directly from these records. Blog
entries and articles store the selected slugs as a comma-separated `tags`
value:
```text
tags: lektor, deployment, operations
```
To add a tag, create its `content/tags/<slug>/contents.lr` record, run a build,
and then select it on the relevant entries. Do not add a slug directly to an
entry before its tag record exists. To rename or retire a tag, update every
referencing entry deliberately and verify that no old slug remains before
renaming or removing its record.
`/tags/` lists every approved tag and counts its usage across blog entries and
articles. Dedicated tag URLs list matching entries from both sections. Tag
links are normal links and work without JavaScript. On the Blog and Articles
listing pages, `assets/static/tags.js` progressively adds instant filtering and
keeps the selected filter in the `tag` query parameter.
After taxonomy or filtering changes:
1. Run `lektor build --output-path build`.
2. Verify `/tags/`, at least one dedicated tag URL, and both section listings.
3. Confirm tag counts cover blog entries and articles and that every entry slug
has a matching tag record.
4. Test filtering with JavaScript enabled and confirm tag links remain usable
without JavaScript.
5. Check keyboard operation plus desktop and narrow layouts.
6. If filtering JavaScript or tag styles changed, increment the corresponding
cache-busting version in `templates/base.html`.
## Local development
Install Lektor in an isolated Python environment. For example:
+60 -8
View File
@@ -59,6 +59,63 @@ h1 { max-width: 650px; margin: 0; font-size: clamp(30px, 4vw, 45px); font-style:
.card-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 1px; background: var(--rule); border: 1px solid var(--rule); }
.entry-card { min-height: 245px; padding: 28px; background: var(--bg); }
.entry-tags { display: flex; flex-wrap: wrap; gap: 7px; margin: 16px 0; }
.entry-tags a,
.post-tags a,
.post-tags span {
border: 1px solid var(--teal);
padding: 3px 7px;
color: var(--teal);
font: 500 9px var(--mono);
letter-spacing: .08em;
text-transform: uppercase;
}
.entry-tags a:hover,
.entry-tags a:focus-visible,
.post-tags a:hover,
.post-tags a:focus-visible { border-color: var(--ink); color: var(--ink); }
.tag-filter { display: none; flex-wrap: wrap; gap: 8px; padding: 22px 0; }
.js .tag-filter { display: flex; }
.tag-filter-button {
border: 1px solid var(--rule);
background: transparent;
padding: 7px 10px;
color: var(--muted);
cursor: pointer;
font: 500 9px var(--mono);
letter-spacing: .08em;
text-transform: uppercase;
}
.tag-filter-button:hover,
.tag-filter-button:focus-visible,
.tag-filter-button.is-active { border-color: var(--teal); color: var(--teal); }
.filter-status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.filter-empty,
.empty-state { margin: 28px 0 0; color: var(--muted); }
.tag-directory { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; border: 1px solid var(--rule); background: var(--rule); }
.tag-directory-card {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px 24px;
min-height: 170px;
padding: 28px;
background: var(--bg);
}
.tag-directory-card:hover,
.tag-directory-card:focus-visible { background: var(--panel); }
.tag-directory-name { font: 600 21px/1.3 var(--serif); }
.tag-directory-count { color: var(--teal); font: 500 9px var(--mono); letter-spacing: .1em; text-transform: uppercase; }
.tag-directory-summary { grid-column: 1 / -1; color: var(--muted); }
.content-section { padding: 55px 0 80px; }
.prose { max-width: 720px; }
.prose p { color: #c8c5bc; }
@@ -109,14 +166,6 @@ h1 { max-width: 650px; margin: 0; font-size: clamp(30px, 4vw, 45px); font-style:
}
.post-info dd { margin: 0; color: var(--ink); font-size: 14px; }
.post-tags { display: flex; flex-wrap: wrap; gap: 7px; }
.post-tags span {
border: 1px solid var(--teal);
padding: 3px 7px;
color: var(--teal);
font: 500 9px var(--mono);
letter-spacing: .08em;
text-transform: uppercase;
}
.article-info .post-info-title { color: var(--teal); }
.publication-links { display: grid; gap: 9px; margin: 0; padding: 0; list-style: none; }
.publication-links a {
@@ -143,6 +192,9 @@ h1 { max-width: 650px; margin: 0; font-size: clamp(30px, 4vw, 45px); font-style:
.hero, .hero.compact { padding-block: 44px; }
h1 { font-size: 30px; }
.card-grid { grid-template-columns: 1fr; }
.tag-directory { grid-template-columns: 1fr; }
.tag-filter { padding-block: 18px; }
.tag-directory-card { min-height: 0; }
.blog-post-layout,
.article-post-layout {
grid-template-columns: 1fr;
+57
View File
@@ -0,0 +1,57 @@
(() => {
document.documentElement.classList.add("js");
for (const filter of document.querySelectorAll("[data-tag-filter]")) {
const section = filter.closest(".activity-section");
const cards = [...section.querySelectorAll("[data-filter-item]")];
const buttons = [...filter.querySelectorAll("[data-tag-value]")];
const count = section.querySelector("[data-entry-count]");
const status = section.querySelector("[data-filter-status]");
const empty = section.querySelector("[data-filter-empty]");
const availableTags = new Set(buttons.map((button) => button.dataset.tagValue));
const applyFilter = (tag, updateUrl) => {
let visibleCount = 0;
for (const card of cards) {
const tags = new Set((card.dataset.tags || "").split(" ").filter(Boolean));
const visible = !tag || tags.has(tag);
card.hidden = !visible;
if (visible) visibleCount += 1;
}
for (const button of buttons) {
const active = button.dataset.tagValue === tag;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", String(active));
}
const entryLabel = visibleCount === 1 ? "entry" : "entries";
count.textContent = `${visibleCount} ${entryLabel}`;
status.textContent = tag
? `Showing ${visibleCount} ${entryLabel} tagged ${buttons.find((button) => button.dataset.tagValue === tag)?.textContent.trim()}.`
: `Showing all ${visibleCount} ${entryLabel}.`;
empty.hidden = visibleCount !== 0;
if (updateUrl) {
const url = new URL(window.location.href);
if (tag) url.searchParams.set("tag", tag);
else url.searchParams.delete("tag");
window.history.replaceState({}, "", url);
}
};
for (const button of buttons) {
button.addEventListener("click", () => applyFilter(button.dataset.tagValue, true));
button.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
applyFilter(button.dataset.tagValue, true);
}
});
}
const requestedTag = new URL(window.location.href).searchParams.get("tag") || "";
applyFilter(availableTags.has(requestedTag) ? requestedTag : "", false);
}
})();
@@ -8,12 +8,7 @@ updated: 2026-08-11
---
author: Example Article Author
---
tags:
Lorem Ipsum
Information Card
Field Mapping
Example
tags: lorem-ipsum, information-card, field-mapping, example
---
kicker: Article
---
@@ -38,7 +33,7 @@ follows:
- **Updated** is read from the optional `updated` field.
- **Reading time** is calculated automatically from the words in the `body`
field, using approximately 200 words per minute.
- **Topics** are read from the multiline `tags` field.
- **Topics** are read from the controlled checkbox `tags` field.
- **Published online** loops over every item in the multiline `published_urls`
field.
@@ -6,12 +6,7 @@ date: 2026-08-11
---
author: Example Blog Author
---
tags:
Lektor
Deployment
Operations
Example
tags: lektor, deployment, operations, example
---
kicker: Blog
---
@@ -27,7 +22,7 @@ back to this record's `contents.lr` file:
- **Author** is read from the `author` field.
- **Published** is read from the `date` field.
- **Tags** are read from the multiline `tags` field.
- **Tags** are read from the controlled checkbox `tags` field.
The title, summary, and article body are supplied by `title`, `summary`, and
`body`, respectively, but those values appear outside the information card.
@@ -93,9 +88,10 @@ server is not part of production serving; nginx serves generated files only.
```text
Labyricorn.lektorproject Lektor project definition
content/ Editable site content (`contents.lr` files)
content/tags/ Controlled tag vocabulary and dedicated tag routes
models/ Lektor content models
templates/ Jinja templates
assets/static/ CSS, favicon, and other static source assets
assets/static/ CSS, filtering JavaScript, favicon, and static assets
.gitignore Excludes generated and local files
README.md This runbook
```
@@ -133,6 +129,56 @@ Do not edit generated files under `/srv/labyricorn/current` or
`/srv/labyricorn/releases`. They will be replaced by deployment and are not
source-controlled.
## Tags and filtering workflow
`content/tags/` is the single source of truth for the controlled tag
vocabulary. Each child directory is a normal Lektor record whose directory name
is the canonical lowercase, hyphenated slug used in entry data and URLs:
```text
content/tags/deployment/contents.lr -> /tags/deployment/
```
The tag record supplies the editor label and public description:
_model: tag
---
title: Deployment
---
summary: Build, release, rollout, verification, and rollback workflows.
The entry model reads its checkbox choices directly from these records. Blog
entries and articles store the selected slugs as a comma-separated `tags`
value:
```text
tags: lektor, deployment, operations
```
To add a tag, create its `content/tags/<slug>/contents.lr` record, run a build,
and then select it on the relevant entries. Do not add a slug directly to an
entry before its tag record exists. To rename or retire a tag, update every
referencing entry deliberately and verify that no old slug remains before
renaming or removing its record.
`/tags/` lists every approved tag and counts its usage across blog entries and
articles. Dedicated tag URLs list matching entries from both sections. Tag
links are normal links and work without JavaScript. On the Blog and Articles
listing pages, `assets/static/tags.js` progressively adds instant filtering and
keeps the selected filter in the `tag` query parameter.
After taxonomy or filtering changes:
1. Run `lektor build --output-path build`.
2. Verify `/tags/`, at least one dedicated tag URL, and both section listings.
3. Confirm tag counts cover blog entries and articles and that every entry slug
has a matching tag record.
4. Test filtering with JavaScript enabled and confirm tag links remain usable
without JavaScript.
5. Check keyboard operation plus desktop and narrow layouts.
6. If filtering JavaScript or tag styles changed, increment the corresponding
cache-busting version in `templates/base.html`.
## Local development
Install Lektor in an isolated Python environment. For example:
+1 -5
View File
@@ -6,11 +6,7 @@ date: 2026-08-11
---
author: Labyricorn
---
tags:
Lektor
Git
Publishing
tags: lektor, git, publishing
---
kicker: Blog
---
+7
View File
@@ -0,0 +1,7 @@
_model: tag-index
---
title: Tags
---
eyebrow: Taxonomy
---
summary: Browse the controlled vocabulary used across blog entries and articles.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Deployment
---
summary: Build, release, rollout, verification, and rollback workflows.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Example
---
summary: Demonstration content that explains how the publishing system works.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Field Mapping
---
summary: Connections between Lektor content fields and their rendered output.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Git
---
summary: Version control, review, and repository-centered publishing.
@@ -0,0 +1,5 @@
_model: tag
---
title: Information Card
---
summary: Metadata presentation for posts and long-form articles.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Lektor
---
summary: Content modeling, authoring, and static-site generation with Lektor.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Lorem Ipsum
---
summary: Placeholder prose used to exercise article layouts and metadata.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Operations
---
summary: Hosting, service management, observability, and production procedures.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Publishing
---
summary: Editorial workflows, content delivery, and production releases.
+4 -1
View File
@@ -20,7 +20,10 @@ type = string
[fields.tags]
label = Tags
type = strings
type = checkboxes
source = site.query('/tags')
item_label = {{ this.title }}
description = Select tags from the controlled vocabulary in content/tags.
[fields.kicker]
label = Type
+18
View File
@@ -0,0 +1,18 @@
[model]
name = Tag Index
label = {{ this.title }}
[children]
order_by = title
[fields.title]
label = Title
type = string
[fields.eyebrow]
label = Eyebrow
type = string
[fields.summary]
label = Summary
type = text
+11
View File
@@ -0,0 +1,11 @@
[model]
name = Tag
label = {{ this.title }}
[fields.title]
label = Display Name
type = string
[fields.summary]
label = Summary
type = text
+3 -1
View File
@@ -9,7 +9,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500&family=Playfair+Display:ital,wght@0,600;0,700;1,500;1,600&display=swap" rel="stylesheet">
<link rel="icon" href="{{ '/static/favicon.svg'|url }}" type="image/svg+xml">
<link rel="stylesheet" href="{{ '/static/style.css'|url }}?v=3">
<link rel="stylesheet" href="{{ '/static/style.css'|url }}?v=4">
<script src="{{ '/static/tags.js'|url }}?v=1" defer></script>
</head>
<body>
<header class="site-header">
@@ -42,6 +43,7 @@
<span>© 2026 Labyricorn — GENERATED FROM GIT, NEVER HAND-EDITED</span>
<nav aria-label="Footer navigation">
<a href="mailto:[email protected]">Contact</a>
<a href="{{ '/tags'|url }}">Tags</a>
<a href="https://github.com/">GitHub</a>
<a href="https://git.labyricorn.com/">Gitea</a>
<a href="https://ko-fi.com/">Ko-fi</a>
+3 -2
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% from "macros/tag-links.html" import tag_links with context %}
{% block body %}
{% if this.parent._id == 'blog' %}
<article class="blog-post-layout shell">
@@ -22,7 +23,7 @@
<div class="post-info-row">
<dt>Tags</dt>
<dd class="post-tags">
{% for tag in this.tags %}<span>{{ tag }}</span>{% endfor %}
{{ tag_links(this.tags) }}
</dd>
</div>
</dl>
@@ -70,7 +71,7 @@
<div class="post-info-row">
<dt>Topics</dt>
<dd class="post-tags">
{% for tag in this.tags %}<span>{{ tag }}</span>{% endfor %}
{{ tag_links(this.tags) }}
</dd>
</div>
{% endif %}
+6
View File
@@ -0,0 +1,6 @@
{% macro tag_links(tags) -%}
{% for tag in tags %}
{% set tag_record = site.get('/tags/' ~ tag) %}
{% if tag_record %}<a href="{{ tag_record|url }}" rel="tag">{{ tag_record.title }}</a>{% else %}<span>{{ tag }}</span>{% endif %}
{% endfor %}
{%- endmacro %}
+23 -3
View File
@@ -1,5 +1,14 @@
{% extends "base.html" %}
{% from "macros/tag-links.html" import tag_links with context %}
{% block body %}
{% set filter_tags = namespace(values=[]) %}
{% if this._id in ['blog', 'articles'] %}
{% for item in this.children %}
{% for tag in item.tags %}
{% if tag not in filter_tags.values %}{% set filter_tags.values = filter_tags.values + [tag] %}{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
<section class="hero compact">
<div class="shell narrow">
<p class="eyebrow">{{ this.eyebrow }}</p>
@@ -8,18 +17,29 @@
</section>
<section class="activity-section">
<div class="shell">
<div class="section-heading"><span>{{ this.title }}</span><span>{{ this.children.count() }} entries</span></div>
<div class="section-heading"><span>{{ this.title }}</span><span data-entry-count>{{ this.children.count() }} entries</span></div>
{% if filter_tags.values %}
<div class="tag-filter" data-tag-filter aria-label="Filter {{ this.title }} by tag">
<button class="tag-filter-button is-active" type="button" data-tag-value="" aria-pressed="true">All</button>
{% for tag in filter_tags.values|sort %}
{% set tag_record = site.get('/tags/' ~ tag) %}
{% if tag_record %}<button class="tag-filter-button" type="button" data-tag-value="{{ tag }}" aria-pressed="false">{{ tag_record.title }}</button>{% endif %}
{% endfor %}
</div>
<p class="filter-status" data-filter-status aria-live="polite"></p>
{% endif %}
<div class="card-grid">
{% for item in this.children %}
<article class="entry-card accent-{{ item.kicker|lower }}">
<article class="entry-card accent-{{ item.kicker|lower }}"{% if filter_tags.values %} data-filter-item data-tags="{{ item.tags|join(' ') }}"{% endif %}>
<div class="entry-meta"><span class="label">{{ item.kicker }}</span><time>{{ item.date|dateformat('YYYY.MM.dd') }}</time></div>
<h2><a href="{{ item|url }}">{{ item.title }}</a></h2>
<p>{{ item.summary }}</p>
{% if item.tags %}<div class="entry-tags">{{ tag_links(item.tags) }}</div>{% endif %}
<a class="read-more" href="{{ item|url }}">Read entry →</a>
</article>
{% endfor %}
</div>
{% if filter_tags.values %}<p class="filter-empty" data-filter-empty hidden>No entries use this tag.</p>{% endif %}
</div>
</section>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block body %}
<section class="hero compact">
<div class="shell narrow">
<p class="eyebrow">{{ this.eyebrow }}</p>
<h1>{{ this.summary }}</h1>
</div>
</section>
<section class="activity-section">
<div class="shell">
<div class="section-heading"><span>{{ this.title }}</span><span>{{ this.children.count() }} approved tags</span></div>
<div class="tag-directory">
{% for tag in this.children %}
{% set usage = namespace(count=0) %}
{% for section_id in ['blog', 'articles'] %}
{% for item in site.query('/' ~ section_id) %}
{% if tag._id in item.tags %}{% set usage.count = usage.count + 1 %}{% endif %}
{% endfor %}
{% endfor %}
<a class="tag-directory-card" href="{{ tag|url }}">
<span class="tag-directory-name">{{ tag.title }}</span>
<span class="tag-directory-count">{{ usage.count }} {% if usage.count == 1 %}entry{% else %}entries{% endif %}</span>
<span class="tag-directory-summary">{{ tag.summary }}</span>
</a>
{% endfor %}
</div>
</div>
</section>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% from "macros/tag-links.html" import tag_links with context %}
{% block body %}
{% set matches = namespace(items=[]) %}
{% for section_id in ['blog', 'articles'] %}
{% for item in site.query('/' ~ section_id) %}
{% if this._id in item.tags %}{% set matches.items = matches.items + [item] %}{% endif %}
{% endfor %}
{% endfor %}
<section class="hero compact">
<div class="shell narrow">
<p class="eyebrow">Tag</p>
<h1>{{ this.title }}</h1>
<p class="dek">{{ this.summary }}</p>
</div>
</section>
<section class="activity-section">
<div class="shell">
<div class="section-heading">
<span>Tagged {{ this.title }}</span>
<span>{{ matches.items|length }} {% if matches.items|length == 1 %}entry{% else %}entries{% endif %}</span>
</div>
{% if matches.items %}
<div class="card-grid">
{% for item in matches.items|sort(attribute='date', reverse=true) %}
<article class="entry-card accent-{{ item.kicker|lower }}">
<div class="entry-meta"><span class="label">{{ item.kicker }}</span><time>{{ item.date|dateformat('YYYY.MM.dd') }}</time></div>
<h2><a href="{{ item|url }}">{{ item.title }}</a></h2>
<p>{{ item.summary }}</p>
{% if item.tags %}<div class="entry-tags">{{ tag_links(item.tags) }}</div>{% endif %}
<a class="read-more" href="{{ item|url }}">Read entry →</a>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">No blog entries or articles use this tag yet.</p>
{% endif %}
</div>
</section>
{% endblock %}