diff --git a/AGENTS.md b/AGENTS.md
index 588a9a2..b0f484d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -52,9 +52,12 @@ making changes; it is the authoritative operations and deployment runbook.
- 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.
+- Tag-directory counts and dedicated tag pages must include projects, blog
+ entries, and articles in the same discovery feed.
+- For remote projects, keep repository language labels and project-declared
+ technology labels separate from their derived controlled tag slugs. Approved
+ labels link to tag pages; unapproved labels remain visible, non-clickable, and
+ mustard-colored but do not affect tag feeds or counts.
- When `README.md` changes, keep the README-derived example blog entry aligned
with the runbook content it demonstrates.
@@ -75,8 +78,9 @@ making changes; it is the authoritative operations and deployment runbook.
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.
+ record, tag counts match rendered project/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 c005ae0..29a8f2c 100644
--- a/README.md
+++ b/README.md
@@ -144,18 +144,27 @@ 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.
+`/tags/` lists every approved tag and counts its usage across projects, blog
+entries, and articles. Dedicated tag URLs combine matching records from all
+three sections in one chronological discovery feed. Tag links are normal links
+and work without JavaScript. On the Projects, Blog, and Articles listing pages,
+`assets/static/tags.js` progressively adds instant filtering and keeps the
+selected filter in the `tag` query parameter.
+
+Remote-project languages and declared technologies are normalized to slugs and
+matched only against this controlled vocabulary. Their approved matches become
+clickable tags and a deduplicated `taxonomy_tags` field used by filters, counts,
+and tag pages. A label that has no approved record remains visible and
+non-clickable in mustard, produces a build warning, and is excluded from tag
+discovery. Remote data never creates a tag record automatically.
After taxonomy or filtering changes:
1. Run `python scripts/build_with_projects.py --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.
+2. Verify `/tags/`, at least one dedicated tag URL, and all three section listings.
+3. Confirm tag counts cover projects, blog entries, and articles; every stored
+ entry slug has a matching tag record; and unapproved project labels are not
+ counted.
4. Test filtering with JavaScript enabled and confirm tag links remain usable
without JavaScript.
5. Check keyboard operation plus desktop and narrow layouts.
@@ -225,7 +234,9 @@ revisions, metadata digests, synchronization status, and synchronization time.
The project card combines repository-owned content with public provider and Git
metadata: source and README links, default branch, latest commit, license,
languages, open issues, stars, forks, latest release, imported revision, and
-sync time. API metadata failure uses cached metadata and is identified in the
+sync time. Approved language and technology labels link into the shared tag
+taxonomy, allowing a project to lead visitors to related writing. API metadata
+failure uses cached metadata and is identified in the
sync status; it never causes an unvalidated new snapshot to replace a working
one.
diff --git a/assets/static/style.css b/assets/static/style.css
index 30a9d71..ab1db66 100644
--- a/assets/static/style.css
+++ b/assets/static/style.css
@@ -6,6 +6,7 @@
--dim: #666b70;
--rule: #273039;
--teal: #51b6a5;
+ --mustard: #c9a84c;
--magenta: #d26186;
--violet: #8c78d8;
--mono: "IBM Plex Mono", ui-monospace, monospace;
@@ -212,14 +213,18 @@ h1 { max-width: 650px; margin: 0; font-size: clamp(30px, 4vw, 45px); font-style:
.repository-counts dd { font: 600 22px/1 var(--serif); }
.technology-list { display: flex; flex-wrap: wrap; gap: 7px; }
-.technology-list span {
+.technology-list span,
+.technology-list a {
border: 1px solid var(--teal);
padding: 3px 7px;
- color: var(--teal);
font: 500 9px var(--mono);
letter-spacing: .08em;
text-transform: uppercase;
}
+.technology-list a { color: var(--teal); }
+.technology-list span { border-color: var(--mustard); color: var(--mustard); }
+.technology-list a:hover,
+.technology-list a:focus-visible { background: var(--teal); color: var(--bg); }
.project-technologies { border-block: 1px solid var(--rule); padding: 36px 0 42px; background: var(--panel); }
.project-technology-list { padding-top: 24px; }
.project-devlog-preview { padding: 52px 0 80px; }
diff --git a/content/blog/example-deployment-runbook/contents.lr b/content/blog/example-deployment-runbook/contents.lr
index 95c7d9d..08bc6f2 100644
--- a/content/blog/example-deployment-runbook/contents.lr
+++ b/content/blog/example-deployment-runbook/contents.lr
@@ -161,18 +161,19 @@ 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.
+`/tags/` lists every approved tag and counts its usage across projects, blog
+entries, and articles. Dedicated tag URLs combine matching records from all
+three sections. Tag links are normal links and work without JavaScript. On the
+Projects, 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.
+1. Run `python scripts/build_with_projects.py --output-path build`.
+2. Verify `/tags/`, at least one dedicated tag URL, and all three section listings.
+3. Confirm tag counts cover projects, blog entries, and articles and that every
+ stored 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.
diff --git a/content/tags/contents.lr b/content/tags/contents.lr
index 4cac5d7..7dc707d 100644
--- a/content/tags/contents.lr
+++ b/content/tags/contents.lr
@@ -4,4 +4,4 @@ title: Tags
---
eyebrow: Taxonomy
---
-summary: Browse the controlled vocabulary used across blog entries and articles.
+summary: Browse the controlled vocabulary used across projects, blog entries, and articles.
diff --git a/content/tags/html/contents.lr b/content/tags/html/contents.lr
new file mode 100644
index 0000000..488413f
--- /dev/null
+++ b/content/tags/html/contents.lr
@@ -0,0 +1,5 @@
+_model: tag
+---
+title: HTML
+---
+summary: Semantic markup and document structure for the web.
diff --git a/content/tags/javascript/contents.lr b/content/tags/javascript/contents.lr
new file mode 100644
index 0000000..ab20fbe
--- /dev/null
+++ b/content/tags/javascript/contents.lr
@@ -0,0 +1,5 @@
+_model: tag
+---
+title: JavaScript
+---
+summary: Web and application development using JavaScript.
diff --git a/content/tags/local-first/contents.lr b/content/tags/local-first/contents.lr
new file mode 100644
index 0000000..c77d73e
--- /dev/null
+++ b/content/tags/local-first/contents.lr
@@ -0,0 +1,5 @@
+_model: tag
+---
+title: Local-first
+---
+summary: Software that keeps user data and primary operation on the local device.
diff --git a/content/tags/provenance/contents.lr b/content/tags/provenance/contents.lr
new file mode 100644
index 0000000..6a3d422
--- /dev/null
+++ b/content/tags/provenance/contents.lr
@@ -0,0 +1,5 @@
+_model: tag
+---
+title: Provenance
+---
+summary: Inspectable records of origin, transformation, and creative process.
diff --git a/content/tags/react/contents.lr b/content/tags/react/contents.lr
new file mode 100644
index 0000000..db3de07
--- /dev/null
+++ b/content/tags/react/contents.lr
@@ -0,0 +1,5 @@
+_model: tag
+---
+title: React
+---
+summary: User interfaces and application architecture built with React.
diff --git a/content/tags/rust/contents.lr b/content/tags/rust/contents.lr
new file mode 100644
index 0000000..5175287
--- /dev/null
+++ b/content/tags/rust/contents.lr
@@ -0,0 +1,5 @@
+_model: tag
+---
+title: Rust
+---
+summary: Systems and application development using the Rust language.
diff --git a/content/tags/sqlite/contents.lr b/content/tags/sqlite/contents.lr
new file mode 100644
index 0000000..8512021
--- /dev/null
+++ b/content/tags/sqlite/contents.lr
@@ -0,0 +1,5 @@
+_model: tag
+---
+title: SQLite
+---
+summary: Embedded relational data storage powered by SQLite.
diff --git a/content/tags/tauri/contents.lr b/content/tags/tauri/contents.lr
new file mode 100644
index 0000000..b9c4ebd
--- /dev/null
+++ b/content/tags/tauri/contents.lr
@@ -0,0 +1,5 @@
+_model: tag
+---
+title: Tauri
+---
+summary: Native desktop applications built with the Tauri framework.
diff --git a/content/tags/typescript/contents.lr b/content/tags/typescript/contents.lr
new file mode 100644
index 0000000..f5ca3f1
--- /dev/null
+++ b/content/tags/typescript/contents.lr
@@ -0,0 +1,5 @@
+_model: tag
+---
+title: TypeScript
+---
+summary: Statically typed JavaScript development with TypeScript.
diff --git a/content/tags/writing/contents.lr b/content/tags/writing/contents.lr
new file mode 100644
index 0000000..1d21d77
--- /dev/null
+++ b/content/tags/writing/contents.lr
@@ -0,0 +1,5 @@
+_model: tag
+---
+title: Writing
+---
+summary: Tools, workflows, and ideas for writing and editorial work.
diff --git a/models/project.ini b/models/project.ini
index d4bf4a2..e563b38 100644
--- a/models/project.ini
+++ b/models/project.ini
@@ -58,6 +58,18 @@ type = strings
label = Imported technologies
type = strings
+[fields.technology_labels]
+label = Technology display labels
+type = strings
+
+[fields.technology_tag_slugs]
+label = Approved technology tags
+type = strings
+
+[fields.taxonomy_tags]
+label = Unified taxonomy tags
+type = strings
+
[fields.body]
label = Exhibition narrative
type = markdown
@@ -106,6 +118,14 @@ type = string
label = Languages
type = strings
+[fields.repository_language_tags]
+label = Approved language tags
+type = strings
+
+[fields.repository_language_labels]
+label = Language display labels
+type = strings
+
[fields.repository_open_issues]
label = Open issues
type = integer
diff --git a/scripts/project_sources.py b/scripts/project_sources.py
index 9ff9a62..318030f 100644
--- a/scripts/project_sources.py
+++ b/scripts/project_sources.py
@@ -321,6 +321,50 @@ def parse_record(data: bytes, path: str) -> tuple[dict[str, str], str]:
return values, text
+def load_tag_vocabulary(site_root: Path) -> set[str]:
+ tags_root = site_root / "content" / "tags"
+ vocabulary: set[str] = set()
+ if not tags_root.is_dir():
+ raise ProjectSourceError("controlled tag vocabulary is missing")
+ for record_path in tags_root.glob("*/contents.lr"):
+ slug = record_path.parent.name
+ if not SLUG_RE.fullmatch(slug):
+ raise ProjectSourceError(f"invalid controlled tag slug: {slug}")
+ record, _text = parse_record(record_path.read_bytes(), str(record_path))
+ if record.get("_model") != "tag" or not record.get("title"):
+ raise ProjectSourceError(f"invalid controlled tag record: {record_path}")
+ vocabulary.add(slug)
+ if not vocabulary:
+ raise ProjectSourceError("controlled tag vocabulary is empty")
+ return vocabulary
+
+
+def normalize_tag_value(value: str) -> str:
+ return re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-")
+
+
+def approved_tag_slugs(
+ project_id: str, source_name: str, values: list[str], vocabulary: set[str]
+) -> list[str]:
+ approved: list[str] = []
+ for value in values:
+ slug = normalize_tag_value(value)
+ if slug not in vocabulary:
+ log(f"{project_id}: ignoring unapproved {source_name} tag {value!r}")
+ continue
+ if slug not in approved:
+ approved.append(slug)
+ return approved
+
+
+def taxonomy_label_items(values: list[str], approved_slugs: list[str]) -> list[str]:
+ approved = set(approved_slugs)
+ return [
+ f"{normalize_tag_value(value) if normalize_tag_value(value) in approved else '-'}\t{value.replace(chr(9), ' ')}"
+ for value in values
+ ]
+
+
def validate_image(path: str, data: bytes) -> None:
suffix = PurePosixPath(path).suffix.lower()
signatures = {
@@ -529,12 +573,33 @@ def materialize(
metadata: dict[str, Any],
synchronized_at: str,
sync_status: str,
+ tag_vocabulary: set[str],
) -> None:
project_root = destination_root / "content" / "projects" / source["project_id"]
if project_root.exists():
raise ProjectSourceError(f"refusing to overwrite existing project path: {project_root}")
(project_root / "devlog").mkdir(parents=True)
+ technology_values = split_list(
+ records[".labyricorn/project/contents.lr"]["tags"]
+ )
+ language_values = list(metadata["languages"])
+ technology_tag_slugs = approved_tag_slugs(
+ source["project_id"],
+ "technology",
+ technology_values,
+ tag_vocabulary,
+ )
+ repository_language_tags = approved_tag_slugs(
+ source["project_id"],
+ "language",
+ language_values,
+ tag_vocabulary,
+ )
+ taxonomy_tags = list(
+ dict.fromkeys(technology_tag_slugs + repository_language_tags)
+ )
+
project_fields = {
"kicker": "Project",
"date": records[".labyricorn/project/contents.lr"]["started"],
@@ -554,7 +619,16 @@ def materialize(
"repository_latest_release_url": metadata["latest_release_url"],
"synchronized_at": synchronized_at,
"sync_status": sync_status,
- "technology_tags": split_list(records[".labyricorn/project/contents.lr"]["tags"]),
+ "technology_tags": technology_values,
+ "technology_labels": taxonomy_label_items(
+ technology_values, technology_tag_slugs
+ ),
+ "technology_tag_slugs": technology_tag_slugs,
+ "repository_language_labels": taxonomy_label_items(
+ language_values, repository_language_tags
+ ),
+ "repository_language_tags": repository_language_tags,
+ "taxonomy_tags": taxonomy_tags,
}
for source_path, data in files.items():
@@ -592,6 +666,7 @@ def metadata_digest(metadata: dict[str, Any]) -> str:
def sync_projects(site_root: Path, destination_root: Path, cache_root: Path) -> dict[str, Any]:
sources = load_registry(site_root)
+ tag_vocabulary = load_tag_vocabulary(site_root)
cache_root.mkdir(parents=True, exist_ok=True)
state = load_state(cache_root)
next_projects_state = dict(state["projects"])
@@ -644,6 +719,7 @@ def sync_projects(site_root: Path, destination_root: Path, cache_root: Path) ->
metadata,
synchronized_at,
sync_status,
+ tag_vocabulary,
)
public_metadata = {key: value for key, value in metadata.items() if key != "mirror_path"}
next_projects_state[project_id] = {
diff --git a/templates/base.html b/templates/base.html
index 08de12a..7720de6 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -9,7 +9,7 @@
-
+
diff --git a/templates/macros/tag-links.html b/templates/macros/tag-links.html
index cdd556c..b474250 100644
--- a/templates/macros/tag-links.html
+++ b/templates/macros/tag-links.html
@@ -4,3 +4,13 @@
{% if tag_record %}{{ tag_record.title }}{% else %}{{ tag }}{% endif %}
{% endfor %}
{%- endmacro %}
+
+{% macro taxonomy_labels(items) -%}
+ {% for item in items %}
+ {% set parts = item.split('\t', 1) %}
+ {% if parts[0] != '-' %}
+ {% set tag_record = site.get('/tags/' ~ parts[0]) %}
+ {% if tag_record %}{{ parts[1] }}{% else %}{{ parts[1] }}{% endif %}
+ {% else %}{{ parts[1] }}{% endif %}
+ {% endfor %}
+{%- endmacro %}
diff --git a/templates/project.html b/templates/project.html
index 4a28e0a..683121a 100644
--- a/templates/project.html
+++ b/templates/project.html
@@ -1,4 +1,5 @@
{% extends "base.html" %}
+{% from "macros/tag-links.html" import tag_links, taxonomy_labels with context %}
{% block body %}
{% set devlog = site.get(this.path ~ '/devlog') %}
@@ -48,10 +49,10 @@
{{ this.repository_license }}
- {% if this.repository_languages %}
+ {% if this.repository_language_labels %}
Languages
-
{% for language in this.repository_languages %}{{ language }}{% endfor %}
- {% if this.technology_tags %}
+ {% if this.technology_labels %}
-
Built with{{ this.technology_tags|length }} technologies
-
{% for tag in this.technology_tags %}{{ tag }}{% endfor %}
+
Built with{{ this.technology_labels|length }} technologies
+
{{ taxonomy_labels(this.technology_labels) }}
{% endif %}
diff --git a/templates/section.html b/templates/section.html
index aab6115..a417519 100644
--- a/templates/section.html
+++ b/templates/section.html
@@ -2,9 +2,10 @@
{% from "macros/tag-links.html" import tag_links with context %}
{% block body %}
{% set filter_tags = namespace(values=[]) %}
-{% if this._id in ['blog', 'articles'] %}
+{% if this._id in ['blog', 'articles', 'projects'] %}
{% for item in this.children %}
- {% for tag in item.tags %}
+ {% set item_tags = item.taxonomy_tags if item._model == 'project' else item.tags %}
+ {% for tag in item_tags %}
{% if tag not in filter_tags.values %}{% set filter_tags.values = filter_tags.values + [tag] %}{% endif %}
{% endfor %}
{% endfor %}
@@ -30,13 +31,13 @@
{% endif %}
{% for item in this.children %}
-
+ {% set item_tags = item.taxonomy_tags if item._model == 'project' else item.tags %}
+