From 2b4eb9f0f62637670bc3658dc137f91571d6d4a9 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Tue, 11 Aug 2026 22:01:22 -0700 Subject: [PATCH] Add controlled tag taxonomy and filters --- AGENTS.md | 23 +++++++ README.md | 53 ++++++++++++++- assets/static/style.css | 68 ++++++++++++++++--- assets/static/tags.js | 57 ++++++++++++++++ .../example-information-card/contents.lr | 9 +-- .../example-deployment-runbook/contents.lr | 62 ++++++++++++++--- content/blog/hello-world/contents.lr | 6 +- content/tags/contents.lr | 7 ++ content/tags/deployment/contents.lr | 5 ++ content/tags/example/contents.lr | 5 ++ content/tags/field-mapping/contents.lr | 5 ++ content/tags/git/contents.lr | 5 ++ content/tags/information-card/contents.lr | 5 ++ content/tags/lektor/contents.lr | 5 ++ content/tags/lorem-ipsum/contents.lr | 5 ++ content/tags/operations/contents.lr | 5 ++ content/tags/publishing/contents.lr | 5 ++ models/entry.ini | 5 +- models/tag-index.ini | 18 +++++ models/tag.ini | 11 +++ templates/base.html | 4 +- templates/entry.html | 5 +- templates/macros/tag-links.html | 6 ++ templates/section.html | 26 ++++++- templates/tag-index.html | 29 ++++++++ templates/tag.html | 40 +++++++++++ 26 files changed, 438 insertions(+), 36 deletions(-) create mode 100644 assets/static/tags.js create mode 100644 content/tags/contents.lr create mode 100644 content/tags/deployment/contents.lr create mode 100644 content/tags/example/contents.lr create mode 100644 content/tags/field-mapping/contents.lr create mode 100644 content/tags/git/contents.lr create mode 100644 content/tags/information-card/contents.lr create mode 100644 content/tags/lektor/contents.lr create mode 100644 content/tags/lorem-ipsum/contents.lr create mode 100644 content/tags/operations/contents.lr create mode 100644 content/tags/publishing/contents.lr create mode 100644 models/tag-index.ini create mode 100644 models/tag.ini create mode 100644 templates/macros/tag-links.html create mode 100644 templates/tag-index.html create mode 100644 templates/tag.html diff --git a/AGENTS.md b/AGENTS.md index 3d5d37a..ce46dce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index e442408..00abc7b 100644 --- a/README.md +++ b/README.md @@ -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//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: diff --git a/assets/static/style.css b/assets/static/style.css index 283c377..bf46aa8 100644 --- a/assets/static/style.css +++ b/assets/static/style.css @@ -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; diff --git a/assets/static/tags.js b/assets/static/tags.js new file mode 100644 index 0000000..5f04512 --- /dev/null +++ b/assets/static/tags.js @@ -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); + } +})(); diff --git a/content/articles/example-information-card/contents.lr b/content/articles/example-information-card/contents.lr index ff17aeb..c2aa206 100644 --- a/content/articles/example-information-card/contents.lr +++ b/content/articles/example-information-card/contents.lr @@ -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. diff --git a/content/blog/example-deployment-runbook/contents.lr b/content/blog/example-deployment-runbook/contents.lr index 6422efd..95c7d9d 100644 --- a/content/blog/example-deployment-runbook/contents.lr +++ b/content/blog/example-deployment-runbook/contents.lr @@ -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//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: diff --git a/content/blog/hello-world/contents.lr b/content/blog/hello-world/contents.lr index 353e960..77457af 100644 --- a/content/blog/hello-world/contents.lr +++ b/content/blog/hello-world/contents.lr @@ -6,11 +6,7 @@ date: 2026-08-11 --- author: Labyricorn --- -tags: - -Lektor -Git -Publishing +tags: lektor, git, publishing --- kicker: Blog --- diff --git a/content/tags/contents.lr b/content/tags/contents.lr new file mode 100644 index 0000000..4cac5d7 --- /dev/null +++ b/content/tags/contents.lr @@ -0,0 +1,7 @@ +_model: tag-index +--- +title: Tags +--- +eyebrow: Taxonomy +--- +summary: Browse the controlled vocabulary used across blog entries and articles. diff --git a/content/tags/deployment/contents.lr b/content/tags/deployment/contents.lr new file mode 100644 index 0000000..88ae887 --- /dev/null +++ b/content/tags/deployment/contents.lr @@ -0,0 +1,5 @@ +_model: tag +--- +title: Deployment +--- +summary: Build, release, rollout, verification, and rollback workflows. diff --git a/content/tags/example/contents.lr b/content/tags/example/contents.lr new file mode 100644 index 0000000..f7af10b --- /dev/null +++ b/content/tags/example/contents.lr @@ -0,0 +1,5 @@ +_model: tag +--- +title: Example +--- +summary: Demonstration content that explains how the publishing system works. diff --git a/content/tags/field-mapping/contents.lr b/content/tags/field-mapping/contents.lr new file mode 100644 index 0000000..ed6187b --- /dev/null +++ b/content/tags/field-mapping/contents.lr @@ -0,0 +1,5 @@ +_model: tag +--- +title: Field Mapping +--- +summary: Connections between Lektor content fields and their rendered output. diff --git a/content/tags/git/contents.lr b/content/tags/git/contents.lr new file mode 100644 index 0000000..5515026 --- /dev/null +++ b/content/tags/git/contents.lr @@ -0,0 +1,5 @@ +_model: tag +--- +title: Git +--- +summary: Version control, review, and repository-centered publishing. diff --git a/content/tags/information-card/contents.lr b/content/tags/information-card/contents.lr new file mode 100644 index 0000000..c0709c6 --- /dev/null +++ b/content/tags/information-card/contents.lr @@ -0,0 +1,5 @@ +_model: tag +--- +title: Information Card +--- +summary: Metadata presentation for posts and long-form articles. diff --git a/content/tags/lektor/contents.lr b/content/tags/lektor/contents.lr new file mode 100644 index 0000000..3ddd4fe --- /dev/null +++ b/content/tags/lektor/contents.lr @@ -0,0 +1,5 @@ +_model: tag +--- +title: Lektor +--- +summary: Content modeling, authoring, and static-site generation with Lektor. diff --git a/content/tags/lorem-ipsum/contents.lr b/content/tags/lorem-ipsum/contents.lr new file mode 100644 index 0000000..ebf7f9e --- /dev/null +++ b/content/tags/lorem-ipsum/contents.lr @@ -0,0 +1,5 @@ +_model: tag +--- +title: Lorem Ipsum +--- +summary: Placeholder prose used to exercise article layouts and metadata. diff --git a/content/tags/operations/contents.lr b/content/tags/operations/contents.lr new file mode 100644 index 0000000..3393f70 --- /dev/null +++ b/content/tags/operations/contents.lr @@ -0,0 +1,5 @@ +_model: tag +--- +title: Operations +--- +summary: Hosting, service management, observability, and production procedures. diff --git a/content/tags/publishing/contents.lr b/content/tags/publishing/contents.lr new file mode 100644 index 0000000..2b8d8a5 --- /dev/null +++ b/content/tags/publishing/contents.lr @@ -0,0 +1,5 @@ +_model: tag +--- +title: Publishing +--- +summary: Editorial workflows, content delivery, and production releases. diff --git a/models/entry.ini b/models/entry.ini index 75455c6..c51b1a4 100644 --- a/models/entry.ini +++ b/models/entry.ini @@ -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 diff --git a/models/tag-index.ini b/models/tag-index.ini new file mode 100644 index 0000000..806caee --- /dev/null +++ b/models/tag-index.ini @@ -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 diff --git a/models/tag.ini b/models/tag.ini new file mode 100644 index 0000000..af47c36 --- /dev/null +++ b/models/tag.ini @@ -0,0 +1,11 @@ +[model] +name = Tag +label = {{ this.title }} + +[fields.title] +label = Display Name +type = string + +[fields.summary] +label = Summary +type = text diff --git a/templates/base.html b/templates/base.html index d8b9727..af273e6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,7 +9,8 @@ - + +