Add projects to shared tag discovery
Deploy production / deploy (push) Successful in 3s

This commit is contained in:
2026-08-11 23:43:41 -07:00
parent ec6e21bb25
commit 14cec442fa
24 changed files with 235 additions and 46 deletions
+9 -5
View File
@@ -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.
+20 -9
View File
@@ -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.
+7 -2
View File
@@ -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; }
@@ -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.
+1 -1
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: HTML
---
summary: Semantic markup and document structure for the web.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: JavaScript
---
summary: Web and application development using JavaScript.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Local-first
---
summary: Software that keeps user data and primary operation on the local device.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Provenance
---
summary: Inspectable records of origin, transformation, and creative process.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: React
---
summary: User interfaces and application architecture built with React.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Rust
---
summary: Systems and application development using the Rust language.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: SQLite
---
summary: Embedded relational data storage powered by SQLite.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Tauri
---
summary: Native desktop applications built with the Tauri framework.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: TypeScript
---
summary: Statically typed JavaScript development with TypeScript.
+5
View File
@@ -0,0 +1,5 @@
_model: tag
---
title: Writing
---
summary: Tools, workflows, and ideas for writing and editorial work.
+20
View File
@@ -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
+77 -1
View File
@@ -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] = {
+1 -1
View File
@@ -9,7 +9,7 @@
<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=5">
<link rel="stylesheet" href="{{ '/static/style.css'|url }}?v=6">
<script src="{{ '/static/tags.js'|url }}?v=1" defer></script>
</head>
<body>
+10
View File
@@ -4,3 +4,13 @@
{% if tag_record %}<a href="{{ tag_record|url }}" rel="tag">{{ tag_record.title }}</a>{% else %}<span>{{ tag }}</span>{% 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 %}<a href="{{ tag_record|url }}" rel="tag">{{ parts[1] }}</a>{% else %}<span class="non-taxonomy-label">{{ parts[1] }}</span>{% endif %}
{% else %}<span class="non-taxonomy-label">{{ parts[1] }}</span>{% endif %}
{% endfor %}
{%- endmacro %}
+6 -5
View File
@@ -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') %}
<article class="project-page">
@@ -48,10 +49,10 @@
<dd>{{ this.repository_license }}</dd>
</div>
</div>
{% if this.repository_languages %}
{% if this.repository_language_labels %}
<div class="post-info-row">
<dt>Languages</dt>
<dd class="technology-list">{% for language in this.repository_languages %}<span>{{ language }}</span>{% endfor %}</dd>
<dd class="technology-list">{{ taxonomy_labels(this.repository_language_labels) }}</dd>
</div>
{% endif %}
<div class="repository-pair repository-counts">
@@ -71,11 +72,11 @@
</aside>
</div>
{% if this.technology_tags %}
{% if this.technology_labels %}
<section class="project-technologies">
<div class="shell">
<div class="section-heading"><span>Built with</span><span>{{ this.technology_tags|length }} technologies</span></div>
<div class="technology-list project-technology-list">{% for tag in this.technology_tags %}<span>{{ tag }}</span>{% endfor %}</div>
<div class="section-heading"><span>Built with</span><span>{{ this.technology_labels|length }} technologies</span></div>
<div class="technology-list project-technology-list">{{ taxonomy_labels(this.technology_labels) }}</div>
</div>
</section>
{% endif %}
+7 -6
View File
@@ -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 %}
<div class="card-grid">
{% for item in this.children %}
<article class="entry-card accent-{{ item.kicker|lower }}"{% if filter_tags.values %} data-filter-item data-tags="{{ item.tags|join(' ') }}"{% endif %}>
{% set item_tags = item.taxonomy_tags if item._model == 'project' else item.tags %}
<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 %}
{% if item._model == 'project' %}<div class="technology-list entry-tags">{% for tag in item.technology_tags %}<span>{{ tag }}</span>{% endfor %}</div>
{% else %}<div class="entry-tags">{{ tag_links(item.tags) }}</div>{% endif %}
{% if item_tags %}
<div class="entry-tags{% if item._model == 'project' %} technology-list{% endif %}">{{ tag_links(item_tags) }}</div>
{% endif %}
<a class="read-more" href="{{ item|url }}">{% if item._model == 'project' %}View project{% else %}Read entry{% endif %} →</a>
</article>
+3 -2
View File
@@ -12,9 +12,10 @@
<div class="tag-directory">
{% for tag in this.children %}
{% set usage = namespace(count=0) %}
{% for section_id in ['blog', 'articles'] %}
{% for section_id in ['projects', 'blog', 'articles'] %}
{% for item in site.query('/' ~ section_id) %}
{% if tag._id in item.tags %}{% set usage.count = usage.count + 1 %}{% endif %}
{% set item_tags = item.taxonomy_tags if item._model == 'project' else item.tags %}
{% if tag._id in item_tags %}{% set usage.count = usage.count + 1 %}{% endif %}
{% endfor %}
{% endfor %}
<a class="tag-directory-card" href="{{ tag|url }}">
+7 -5
View File
@@ -2,9 +2,10 @@
{% from "macros/tag-links.html" import tag_links with context %}
{% block body %}
{% set matches = namespace(items=[]) %}
{% for section_id in ['blog', 'articles'] %}
{% for section_id in ['projects', 'blog', 'articles'] %}
{% for item in site.query('/' ~ section_id) %}
{% if this._id in item.tags %}{% set matches.items = matches.items + [item] %}{% endif %}
{% set item_tags = item.taxonomy_tags if item._model == 'project' else item.tags %}
{% if this._id in item_tags %}{% set matches.items = matches.items + [item] %}{% endif %}
{% endfor %}
{% endfor %}
<section class="hero compact">
@@ -23,17 +24,18 @@
{% if matches.items %}
<div class="card-grid">
{% for item in matches.items|sort(attribute='date', reverse=true) %}
{% set item_tags = item.taxonomy_tags if item._model == 'project' else item.tags %}
<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>
{% if item_tags %}<div class="entry-tags">{{ tag_links(item_tags) }}</div>{% endif %}
<a class="read-more" href="{{ item|url }}">{% if item._model == 'project' %}View project{% else %}Read entry{% endif %}</a>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">No blog entries or articles use this tag yet.</p>
<p class="empty-state">No projects, blog entries, or articles use this tag yet.</p>
{% endif %}
</div>
</section>
+7
View File
@@ -17,6 +17,7 @@ from project_sources import ( # noqa: E402
metadata_digest,
parse_record,
split_list,
taxonomy_label_items,
validate_image,
validate_url,
)
@@ -61,6 +62,12 @@ class ProjectSourceTests(unittest.TestCase):
with self.assertRaises(RepositoryNotPublicError):
fetch_json("https://git.example.test/api/repo", require_public=True)
def test_unapproved_labels_remain_visible_without_becoming_tags(self) -> None:
items = taxonomy_label_items(
["TypeScript", "Unlisted Tool"], ["typescript"]
)
self.assertEqual(items, ["typescript\tTypeScript", "-\tUnlisted Tool"])
if __name__ == "__main__":
unittest.main()