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 %}{% 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 %}{% 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 %}
+
{{ taxonomy_labels(this.repository_language_labels) }}
{% endif %}
@@ -71,11 +72,11 @@
- {% 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 %} + diff --git a/templates/tag-index.html b/templates/tag-index.html index 1e056e5..e41529e 100644 --- a/templates/tag-index.html +++ b/templates/tag-index.html @@ -12,9 +12,10 @@
{% 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 %} diff --git a/templates/tag.html b/templates/tag.html index 0d801cb..868ae09 100644 --- a/templates/tag.html +++ b/templates/tag.html @@ -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 %}
@@ -23,17 +24,18 @@ {% if matches.items %} {% else %} -

No blog entries or articles use this tag yet.

+

No projects, blog entries, or articles use this tag yet.

{% endif %}
diff --git a/tests/test_project_sources.py b/tests/test_project_sources.py index 2a8b56a..6efc1f5 100644 --- a/tests/test_project_sources.py +++ b/tests/test_project_sources.py @@ -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()