From 82850deed244420a9abc22b2dd380d1a3b60c72c Mon Sep 17 00:00:00 2001
From: richarddushime
Date: Mon, 7 Sep 2026 16:28:01 +0200
Subject: [PATCH 1/4] Retire the tags taxonomy; clean tags site-wide and at the
source
---
config/_default/config.toml | 10 +-
config/_default/params.toml | 9 --
content/resources/index.md | 145 ++++++++++++++++++
content/resources/resource.py | 122 ++++++++++++++-
layouts/_default/index.json | 2 +-
layouts/partials/custom_head.html | 7 -
.../partials/functions/is_thin_taxonomy.html | 35 -----
layouts/partials/functions/page_tags.html | 43 ++++++
layouts/partials/jsonld/main.html | 69 ++++-----
layouts/partials/resource_li_card.html | 5 +-
.../partials/structured-data/breadcrumbs.html | 1 -
.../structured-data/educators-corner.html | 2 +-
layouts/partials/tags.html | 23 +++
layouts/sitemap.xml | 18 +--
14 files changed, 377 insertions(+), 114 deletions(-)
delete mode 100644 layouts/partials/functions/is_thin_taxonomy.html
create mode 100644 layouts/partials/functions/page_tags.html
create mode 100644 layouts/partials/tags.html
diff --git a/config/_default/config.toml b/config/_default/config.toml
index b74ce656e62..851f487f676 100644
--- a/config/_default/config.toml
+++ b/config/_default/config.toml
@@ -47,7 +47,6 @@ ignoreFiles = ["\\.ipynb$", ".ipynb_checkpoints$", "\\.Rmd$", "\\.Rmarkdown$", "
# pages under /authors// (see layouts/authors/list.html). The taxonomy
# was disabled because it collided with the content/authors/ section, which
# suppressed every profile whose term slug matched its folder name.
- tags = "/tag/:slug/"
categories = "/category/:slug/"
publication_types = "/publication-type/:slug/"
@@ -81,7 +80,14 @@ ignoreFiles = ["\\.ipynb$", ".ipynb_checkpoints$", "\\.Rmd$", "\\.Rmarkdown$", "
# Taxonomies.
[taxonomies]
- tag = "tags"
+ # `tag = "tags"` taxonomy removed: `tags` is free text from the curated
+ # resources sheet, so it grew to ~1,700 terms of which three quarters apply to
+ # a single resource. The term pages were 47% of every page the site built and
+ # carried no search intent, and `FORRT_clusters` already provides curated
+ # topical navigation. `tags` stays in front matter as descriptive metadata --
+ # it still feeds site search (`layouts/_default/index.json`), the resource
+ # card filter (`partials/resource_li_card.html`) and the `[related]` indices
+ # below, none of which need a taxonomy. Rendered by `partials/tags.html`.
category = "categories"
publication_type = "publication_types"
# `author = "authors"` taxonomy disabled: it collided with the content/authors/
diff --git a/config/_default/params.toml b/config/_default/params.toml
index fd205669237..b5c2e57ac6b 100644
--- a/config/_default/params.toml
+++ b/config/_default/params.toml
@@ -70,15 +70,6 @@ social_profiles = [
"https://facebook.com/forrtproject",
]
-# Minimum number of entries a `tags` term needs before its archive page is left
-# open to search engines. Tags come from free text on curated resources, and
-# roughly three quarters of them are used exactly once, so without a floor the
-# site publishes ~1,500 near-empty archives that mostly restate a single card.
-# Terms below the floor are still browsable and still pass link equity — they
-# are marked `noindex, follow` and kept out of the sitemap. Set to 0 to index
-# every term again.
-tag_index_min_pages = 5
-
############################
## Site Features
############################
diff --git a/content/resources/index.md b/content/resources/index.md
index a353ef465b5..c2d96b8c91c 100644
--- a/content/resources/index.md
+++ b/content/resources/index.md
@@ -3,4 +3,149 @@
type = "widget_page"
headless = false # Homepage is headless, other widget pages are not.
title = "Resources"
+
+# Redirects for the retired `/tag//` archives. The `tags` taxonomy was
+# removed (see `[taxonomies]` in config/_default/config.toml); these are the
+# terms that were still indexable at the time, so they are the ones search
+# engines and external links can be expected to hold. This page is where they
+# led in substance -- the same resources, searchable by the very tag text that
+# named the archive. Thin terms were already `noindex` and are left to 404.
+aliases = [
+ "/tag/aging-science/",
+ "/tag/aging/",
+ "/tag/analysis/",
+ "/tag/artificial-intelligence/",
+ "/tag/bayesian-statistics/",
+ "/tag/bias/",
+ "/tag/bibliometrics/",
+ "/tag/blog/",
+ "/tag/book/",
+ "/tag/careers/",
+ "/tag/center-for-open-science/",
+ "/tag/citation-analysis/",
+ "/tag/clinical-trials/",
+ "/tag/code/",
+ "/tag/collaboration/",
+ "/tag/computer-software/",
+ "/tag/conceptual-and-statistical-knowledge/",
+ "/tag/covid-19/",
+ "/tag/credibility/",
+ "/tag/culture/",
+ "/tag/data-management/",
+ "/tag/data-processing/",
+ "/tag/data-sharing/",
+ "/tag/data/",
+ "/tag/diversity/",
+ "/tag/drug-discovery/",
+ "/tag/economics/",
+ "/tag/education/",
+ "/tag/epidemiology/",
+ "/tag/epistemology/",
+ "/tag/equity/",
+ "/tag/ethics/",
+ "/tag/experimental-design/",
+ "/tag/exploratory-research/",
+ "/tag/funders/",
+ "/tag/funding/",
+ "/tag/genomics/",
+ "/tag/git/",
+ "/tag/harking/",
+ "/tag/hypothesis-testing/",
+ "/tag/inclusion/",
+ "/tag/inside-your-classroom/",
+ "/tag/interaction/",
+ "/tag/lesson-bank/",
+ "/tag/librarians/",
+ "/tag/machine-learning/",
+ "/tag/materials/",
+ "/tag/medical-journals/",
+ "/tag/medicine/",
+ "/tag/meta-analysis/",
+ "/tag/metadata/",
+ "/tag/metascience/",
+ "/tag/methodology/",
+ "/tag/neuroscience/",
+ "/tag/open-access-publishing/",
+ "/tag/open-access/",
+ "/tag/open-code/",
+ "/tag/open-data/",
+ "/tag/open-educational-resources/",
+ "/tag/open-research/",
+ "/tag/open-scholarship-guidelines/",
+ "/tag/open-scholarship-tools-and-technologies/",
+ "/tag/open-scholarship/",
+ "/tag/open-science-framework/",
+ "/tag/open-science-practices/",
+ "/tag/open-science/",
+ "/tag/open-source-software/",
+ "/tag/open-source/",
+ "/tag/organizing/",
+ "/tag/osca-awards-2025/",
+ "/tag/osf/",
+ "/tag/p-hacking/",
+ "/tag/peer-review/",
+ "/tag/philosophy-of-science/",
+ "/tag/podcast/",
+ "/tag/policy/",
+ "/tag/pre-analysis-plans/",
+ "/tag/preprints/",
+ "/tag/preregistration/",
+ "/tag/psychology/",
+ "/tag/publication-bias/",
+ "/tag/publication-ethics/",
+ "/tag/publishers/",
+ "/tag/publishing/",
+ "/tag/python/",
+ "/tag/qualitative-methods/",
+ "/tag/qualitative-research/",
+ "/tag/qualitative/",
+ "/tag/questionable-research-practices/",
+ "/tag/r/",
+ "/tag/registered-reports/",
+ "/tag/registration/",
+ "/tag/replicability/",
+ "/tag/replication-crisis/",
+ "/tag/replication/",
+ "/tag/reporting-guidelines/",
+ "/tag/reporting/",
+ "/tag/reproducibility-crisis-and-credibility-revolution/",
+ "/tag/reproducibility-knowledge/",
+ "/tag/reproducibility/",
+ "/tag/reproducible-research/",
+ "/tag/research-administration/",
+ "/tag/research-assessment/",
+ "/tag/research-best-practices/",
+ "/tag/research-culture/",
+ "/tag/research-data-management-tools/",
+ "/tag/research-data-management/",
+ "/tag/research-design/",
+ "/tag/research-funding/",
+ "/tag/research-integrity/",
+ "/tag/research-methods/",
+ "/tag/research-reporting-guidelines/",
+ "/tag/research-transparency/",
+ "/tag/research/",
+ "/tag/researcher-degrees-of-freedom/",
+ "/tag/researchers/",
+ "/tag/science-policy/",
+ "/tag/science/",
+ "/tag/scientific-community/",
+ "/tag/scientific-publishing/",
+ "/tag/scientists/",
+ "/tag/shell/",
+ "/tag/simulation/",
+ "/tag/software/",
+ "/tag/sql/",
+ "/tag/statistical-data/",
+ "/tag/statistics/",
+ "/tag/students/",
+ "/tag/systematic-reviews/",
+ "/tag/teaching/",
+ "/tag/transparency/",
+ "/tag/tutorial/",
+ "/tag/version-control/",
+ "/tag/video/",
+ "/tag/website/",
+ "/tag/workflow-tools/",
+]
+++
diff --git a/content/resources/resource.py b/content/resources/resource.py
index fd61791b1ca..ba02c3c7b54 100644
--- a/content/resources/resource.py
+++ b/content/resources/resource.py
@@ -20,6 +20,7 @@
import os
import re
import sys
+from collections import Counter
from pathlib import Path
import pandas as pd
@@ -97,6 +98,111 @@ def wrangle_data(df):
df.fillna('', inplace=True)
+# Submitters type tags freely into one sheet cell. Commas were the only
+# documented separator, but semicolons are used just as often and newlines slip
+# in from pasted text; splitting on all three is what stops a whole list being
+# stored as the single tag "Replication; Teaching".
+TAG_SEPARATORS = re.compile(r'[,;\n]+')
+
+
+def clean_tag(raw: str) -> str:
+ """One tag, normalised, or '' if the value is not a term at all.
+
+ Collapses whitespace (including the non-breaking spaces that come from
+ pasted text), drops the leading '#' of hashtag-style submissions, and trims
+ stray punctuation from both ends -- "Diversity, Equity, & Inclusion" splits
+ on its commas and leaves "& Inclusion" behind. Returns '' for placeholders: the sheet
+ carries blanks, "-" and "." as stand-ins for "no tags", and a value with no
+ letter or digit is never a term.
+ """
+ tag = re.sub(r'\s+', ' ', raw.replace('\u00a0', ' ')).strip()
+ tag = tag.lstrip('#').strip()
+ tag = tag.strip(' .,;:-&')
+ return tag if any(ch.isalnum() for ch in tag) else ''
+
+
+def split_tags(cell: str) -> list:
+ """A tag cell as a list of clean tags, de-duplicated case-insensitively.
+
+ Duplicates are compared case-folded because the same term is submitted in
+ several casings; the first spelling in the cell wins, and `canonical_tags`
+ then settles the casing across the whole sheet.
+ """
+ tags, seen = [], set()
+ for tag in (clean_tag(part) for part in TAG_SEPARATORS.split(cell)):
+ if tag and tag.casefold() not in seen:
+ seen.add(tag.casefold())
+ tags.append(tag)
+ return tags
+
+
+# Longest all-caps spelling treated as an acronym rather than shouting. Covers
+# the acronyms the sheet actually carries (OSF, FAIR, ADHD, UNESCO, COSMIN) and
+# excludes the shouted words it also carries (LECTURE, PSYCHOLOGY, INTRODUCTION).
+# Measured over letters only, so "COVID-19" counts as five.
+ACRONYM_MAX_LETTERS = 6
+
+
+def is_shouted(spelling: str) -> bool:
+ """Whether an all-caps spelling reads as emphasis rather than an acronym."""
+ return spelling.isupper() and len([c for c in spelling if c.isalpha()]) > ACRONYM_MAX_LETTERS
+
+
+# Words English title case leaves lowercase unless they open the term. Used only
+# to score competing spellings against each other, never to rewrite a tag.
+FUNCTION_WORDS = frozenset(
+ 'a an and as at but by for from in into nor of on or the to via vs with'.split()
+)
+
+
+def title_case_score(spelling: str) -> float:
+ """How closely a spelling follows title case, from 0 to 1.
+
+ Separates "Evolution of Science" from "Evolution Of Science" and "Data
+ Curation" from "Data curation" when both are equally common. An all-caps
+ word counts as capitalised, so acronyms inside a term ("FAIR Data") are not
+ penalised.
+ """
+ words = spelling.split()
+ matches = 0
+ for position, word in enumerate(words):
+ letters = [c for c in word if c.isalpha()]
+ if not letters:
+ matches += 1
+ elif position and word.casefold().strip('.,:;') in FUNCTION_WORDS:
+ matches += not letters[0].isupper()
+ else:
+ matches += letters[0].isupper()
+ return matches / len(words)
+
+
+def canonical_tags(df):
+ """Settle on one spelling per tag across the whole sheet.
+
+ "Open Science", "Open science" and "OPEN SCIENCE" are one term submitted
+ three ways; left alone they read as three different labels on the cards.
+ The winner is chosen rather than imposed as Title Case, so acronyms and
+ product names ("OSF", "COVID-19", "RMarkdown") keep the form contributors
+ actually use: shouted spellings last, then the most common, then the
+ closest to title case ("Evolution of Science" over "Evolution Of Science"),
+ then alphabetically so a rebuild of the same sheet is reproducible. Terms
+ submitted only one way are left untouched.
+ """
+ spellings = {}
+ for tags in df['tags'].values:
+ for tag in tags:
+ spellings.setdefault(tag.casefold(), Counter())[tag] += 1
+
+ def rank(item):
+ spelling, count = item
+ return (is_shouted(spelling), -count, -title_case_score(spelling), spelling)
+
+ canonical = {key: min(counter.items(), key=rank)[0]
+ for key, counter in spellings.items()}
+
+ df['tags'] = [[canonical[tag.casefold()] for tag in tags] for tags in df['tags'].values]
+
+
def split_cells(df):
df['creators'] = [[y.strip() for y in x.split(',')] for x in df['creators'].values]
df['primary_user'] = [[y.strip() for y in x.split(',')] for x in df['primary_user'].values]
@@ -104,14 +210,11 @@ def split_cells(df):
df['education_level'] = [[y.strip() for y in x.split(',')] for x in df['education_level'].values]
df['subject_areas'] = [[y.strip() for y in x.split(',')] for x in df['subject_areas'].values]
df['FORRT_clusters'] = [[y.strip() for y in x.split(',')] for x in df['FORRT_clusters'].values]
- # `tags` becomes a Hugo taxonomy, so a blank cell would otherwise yield
- # [""] and collect every such resource into one archive; the sheet also
- # carries "-" and "." as stand-ins for "no tags". Anything without a
- # letter or digit is a placeholder, not a term, so drop it — the resource
- # itself is kept, it just ends up untagged.
- df['tags'] = [[y.strip() for y in x.split(',')
- if any(ch.isalnum() for ch in y)]
- for x in df['tags'].values]
+ # Tags get their own splitter: they are free text rather than a controlled
+ # vocabulary, so they need separator, placeholder and duplicate handling
+ # the other columns do not. A resource with no usable tags is still kept,
+ # it just ends up untagged.
+ df['tags'] = [split_tags(x) for x in df['tags'].values]
df['language'] = [[y.strip() for y in x.split(',')] for x in df['language'].values]
@@ -170,6 +273,9 @@ def main():
split_cells(FORRT)
+ # After splitting, so every tag in the sheet is compared in its final form.
+ canonical_tags(FORRT)
+
# Create files
f_path = Path.cwd() / 'content' / 'curated_resources'
diff --git a/layouts/_default/index.json b/layouts/_default/index.json
index 91ded02d25d..511a90d13aa 100644
--- a/layouts/_default/index.json
+++ b/layouts/_default/index.json
@@ -78,7 +78,7 @@
{{- end -}}
{{- end -}}
- {{- $index = $index | append (dict "objectID" .File.UniqueID "date" .Date.UTC.Unix "publishdate" .PublishDate "lastmod" .Lastmod.UTC.Unix "expirydate" .ExpiryDate.UTC.Unix "lang" .Lang "permalink" $permalink "relpermalink" $rel_permalink "title" $title "summary" (plainify $desc) "content" .Plain "authors" $authors "kind" .Kind "type" .Type "section" .Section "tags" .Params.Tags "categories" .Params.Categories "image" .Params.image) -}}
+ {{- $index = $index | append (dict "objectID" .File.UniqueID "date" .Date.UTC.Unix "publishdate" .PublishDate "lastmod" .Lastmod.UTC.Unix "expirydate" .ExpiryDate.UTC.Unix "lang" .Lang "permalink" $permalink "relpermalink" $rel_permalink "title" $title "summary" (plainify $desc) "content" .Plain "authors" $authors "kind" .Kind "type" .Type "section" .Section "tags" (partial "functions/page_tags.html" .) "categories" .Params.Categories "image" .Params.image) -}}
{{- end -}}
{{- end -}}
diff --git a/layouts/partials/custom_head.html b/layouts/partials/custom_head.html
index e2857f38ad0..10693c6d372 100644
--- a/layouts/partials/custom_head.html
+++ b/layouts/partials/custom_head.html
@@ -10,13 +10,6 @@
{{ partial "structured-data/breadcrumbs.html" . }}
-{{/* Thin `tags` archives: browsable and still passing link equity, but kept
- out of search results. See `functions/is_thin_taxonomy.html`, which the
- sitemap template consults too so the two cannot disagree. */}}
-{{ if partial "functions/is_thin_taxonomy.html" . }}
-
-{{ end }}
-
{{/* Author profile pages: noindex empty template stubs, and emit Person
structured data for the substantive ones. (Computed once.) */}}
{{ if and (eq .Section "authors") .Params.name }}
diff --git a/layouts/partials/functions/is_thin_taxonomy.html b/layouts/partials/functions/is_thin_taxonomy.html
deleted file mode 100644
index 050c9b761ee..00000000000
--- a/layouts/partials/functions/is_thin_taxonomy.html
+++ /dev/null
@@ -1,35 +0,0 @@
-{{/* Reports whether a page is a low-value `tags` archive that should be kept
- out of search results and out of the sitemap.
-
- Tags are free text entered on curated resources, so the taxonomy has grown
- to ~1,600 terms of which roughly three quarters apply to a single resource.
- Those term pages restate one card and little else, which is thin,
- near-duplicate content at a scale that dominates the sitemap.
-
- Two kinds of page qualify:
- - a term page (`/tag//`) with fewer than `tag_index_min_pages`
- entries;
- - the term index (`/tag/`) itself, which is a flat list of every term and
- serves no search intent.
-
- Only the `tags` taxonomy is affected. `categories`, `publication_types` and
- `FORRT_clusters` are curated vocabularies and are left alone.
-
- Inputs: page context. Output: bool. */}}
-
-{{ $min := site.Params.tag_index_min_pages | default 0 }}
-{{ $thin := false }}
-
-{{ if gt $min 0 }}
- {{ $taxonomy := "" }}
- {{ with .Data }}{{ $taxonomy = .Plural | default "" }}{{ end }}
- {{ if eq $taxonomy "tags" }}
- {{ if eq .Kind "term" }}
- {{ $thin = lt (len .Pages) $min }}
- {{ else if eq .Kind "taxonomy" }}
- {{ $thin = true }}
- {{ end }}
- {{ end }}
-{{ end }}
-
-{{ return $thin }}
diff --git a/layouts/partials/functions/page_tags.html b/layouts/partials/functions/page_tags.html
new file mode 100644
index 00000000000..1355caf116b
--- /dev/null
+++ b/layouts/partials/functions/page_tags.html
@@ -0,0 +1,43 @@
+{{/* A page's `tags`, cleaned, in one place for the whole site.
+
+ Tags reach the site from several sources -- the curated-resources sheet via
+ `content/resources/resource.py`, and hand-written front matter anywhere
+ else -- and every section renders them through the same path
+ (`_default/single.html` -> `page_footer` -> `tags.html`). Cleaning here
+ rather than in each template means a section added later inherits it, and
+ the badges, the search index, the resource cards and the JSON-LD keywords
+ can never disagree about what a page's tags are.
+
+ Applied to each value: split on the separators contributors actually use
+ (`,`, `;`, newline), collapse whitespace, drop a leading `#`, trim leading and trailing
+ sentence punctuation, discard anything with no letter or digit, then
+ de-duplicate case-insensitively keeping the first spelling.
+
+ Casing is deliberately left alone. Settling on one spelling per term needs
+ frequency counts across the whole corpus, which is a data-pipeline job:
+ `canonical_tags()` in `content/resources/resource.py` does it at the source,
+ where it can also fix the raw front matter that Hugo's `[related]` indices
+ read directly.
+
+ Inputs: page context. Output: slice of strings (possibly empty). */}}
+
+{{ $tags := slice }}
+{{ $seen := slice }}
+
+{{ range .Params.tags }}
+ {{ range split (replaceRE `[;\n]+` "," (printf "%v" .)) "," }}
+ {{/* `\s` does not cover the non-breaking spaces that arrive with pasted text. */}}
+ {{ $tag := replaceRE `[\s\x{00a0}]+` " " . }}
+ {{ $tag = trim (strings.TrimPrefix "#" (trim $tag " ")) " " }}
+ {{ $tag = trim $tag " .,;:-&" }}
+ {{ if findRE `[\pL\pN]` $tag 1 }}
+ {{ $key := lower $tag }}
+ {{ if not (in $seen $key) }}
+ {{ $seen = $seen | append $key }}
+ {{ $tags = $tags | append $tag }}
+ {{ end }}
+ {{ end }}
+ {{ end }}
+{{ end }}
+
+{{ return $tags }}
diff --git a/layouts/partials/jsonld/main.html b/layouts/partials/jsonld/main.html
index ce1e27272d3..78f14f46f83 100644
--- a/layouts/partials/jsonld/main.html
+++ b/layouts/partials/jsonld/main.html
@@ -35,45 +35,40 @@
{{- else if in (slice "section" "taxonomy" "term") $page.Kind -}}
- {{/* Thin tag archives are `noindex`, so describing them only adds weight. */}}
- {{ if not (partial "functions/is_thin_taxonomy.html" $page) }}
-
- {{ $collection := dict
- "@context" "https://schema.org"
- "@type" "CollectionPage"
- "@id" (printf "%s#collection" $page.Permalink)
- "url" $page.Permalink
- "name" $page.Title
- "isPartOf" (dict "@type" "WebSite" "@id" site.BaseURL "name" site.Title)
- "inLanguage" (partial "functions/page_language.html" $page)
- }}
-
- {{ with $summary }}
- {{ $collection = merge $collection (dict "description" (. | plainify | truncate 300 "…")) }}
- {{ end }}
-
- {{/* Capped: these lists run to hundreds of entries, and the point is to
- describe what the page covers, not to mirror it. */}}
- {{ $elements := slice }}
- {{ range $i, $item := first 25 $page.Pages }}
- {{ $elements = $elements | append (dict
- "@type" "ListItem"
- "position" (add $i 1)
- "url" $item.Permalink
- "name" $item.LinkTitle
- ) }}
- {{ end }}
-
- {{ if $elements }}
- {{ $collection = merge $collection (dict "mainEntity" (dict
- "@type" "ItemList"
- "numberOfItems" (len $page.Pages)
- "itemListElement" $elements
- )) }}
- {{ end }}
+ {{ $collection := dict
+ "@context" "https://schema.org"
+ "@type" "CollectionPage"
+ "@id" (printf "%s#collection" $page.Permalink)
+ "url" $page.Permalink
+ "name" $page.Title
+ "isPartOf" (dict "@type" "WebSite" "@id" site.BaseURL "name" site.Title)
+ "inLanguage" (partial "functions/page_language.html" $page)
+ }}
+
+ {{ with $summary }}
+ {{ $collection = merge $collection (dict "description" (. | plainify | truncate 300 "…")) }}
+ {{ end }}
-
+ {{/* Capped: these lists run to hundreds of entries, and the point is to
+ describe what the page covers, not to mirror it. */}}
+ {{ $elements := slice }}
+ {{ range $i, $item := first 25 $page.Pages }}
+ {{ $elements = $elements | append (dict
+ "@type" "ListItem"
+ "position" (add $i 1)
+ "url" $item.Permalink
+ "name" $item.LinkTitle
+ ) }}
+ {{ end }}
+ {{ if $elements }}
+ {{ $collection = merge $collection (dict "mainEntity" (dict
+ "@type" "ItemList"
+ "numberOfItems" (len $page.Pages)
+ "itemListElement" $elements
+ )) }}
{{ end }}
+
+
{{- end }}
diff --git a/layouts/partials/resource_li_card.html b/layouts/partials/resource_li_card.html
index 7d7e18ccab6..7839209bd66 100644
--- a/layouts/partials/resource_li_card.html
+++ b/layouts/partials/resource_li_card.html
@@ -17,6 +17,7 @@
{{/* Get metedata. */}}
{{ $resource := ($item.Resources.ByType "image").GetMatch "*featured*" }}
+{{ $tags := partial "functions/page_tags.html" $item }}
{{ $js_tag_classes := delimit (apply (apply $item.Params.FORRT_clusters "replace" "." " " "-") "printf" "js-id-%s" ".") " " }}
{{/* Build a hidden, label-free search index spanning all fields (issue #286). The
@@ -29,7 +30,7 @@
(delimit $item.Params.Material_Type " ")
(delimit $item.Params.Primary_User " ")
(delimit $item.Params.Subject_Areas " ")
- (delimit $item.Params.Tags " ")
+ (delimit $tags " ")
(delimit $item.Params.Education_Level " ")
(delimit $item.Params.Language " ")
($item.Params.Conditions_of_Use | default "")
@@ -53,7 +54,7 @@
Primary user(s): {{delimit $item.Params.Primary_User ", " }}
Subject area(s): {{delimit $item.Params.Subject_Areas ", " }}
- {{ with $item.Params.Tags }} Tag(s): {{delimit . ", " }}
{{end}}
+ {{ with $tags }} Tag(s): {{delimit . ", " }}
{{end}}
{{with $item.Params.link_to_resource }}
Link to resource
{{end}}
diff --git a/layouts/partials/structured-data/breadcrumbs.html b/layouts/partials/structured-data/breadcrumbs.html
index f8d9918171a..27b316318c1 100644
--- a/layouts/partials/structured-data/breadcrumbs.html
+++ b/layouts/partials/structured-data/breadcrumbs.html
@@ -24,7 +24,6 @@
{{ $skip := or
.IsHome
(eq .Type "clusters")
- (partial "functions/is_thin_taxonomy.html" .)
}}
{{ if not $skip }}
diff --git a/layouts/partials/structured-data/educators-corner.html b/layouts/partials/structured-data/educators-corner.html
index 3c37e6fbbc9..1d8eaca2612 100644
--- a/layouts/partials/structured-data/educators-corner.html
+++ b/layouts/partials/structured-data/educators-corner.html
@@ -24,6 +24,6 @@
"about" (dict "@type" "Thing" "name" "Open and Reproducible Science") -}}
{{- with $desc }}{{ $article = merge $article (dict "description" .) }}{{ end -}}
{{- with $authors }}{{ $article = merge $article (dict "author" .) }}{{ end -}}
- {{- with .Params.tags }}{{ $article = merge $article (dict "keywords" (delimit . ", ")) }}{{ end -}}
+ {{- with partial "functions/page_tags.html" . }}{{ $article = merge $article (dict "keywords" (delimit . ", ")) }}{{ end -}}
{{- end -}}
diff --git a/layouts/partials/tags.html b/layouts/partials/tags.html
new file mode 100644
index 00000000000..d04dac1e1df
--- /dev/null
+++ b/layouts/partials/tags.html
@@ -0,0 +1,23 @@
+{{/* Site override of the theme's `tags` partial: renders a page's tags as
+ plain labels rather than links.
+
+ `tags` is no longer a Hugo taxonomy (see `[taxonomies]` in
+ config/_default/config.toml), so `.GetTerms "tags"` -- which the theme
+ partial uses -- returns nothing and there is no term page to link to. The
+ values remain useful as descriptive metadata and are still searchable via
+ the site search index and the resource card filter.
+
+ Reached by every section through `_default/single.html` -> `page_footer`,
+ so the values come from `functions/page_tags.html` rather than raw front
+ matter: one definition of a page's tags for badges, search and structured
+ data alike.
+
+ Inputs: page context. */}}
+
+{{ with partial "functions/page_tags.html" . }}
+
+ {{ range . }}
+ {{ . }}
+ {{ end }}
+
+{{ end }}
diff --git a/layouts/sitemap.xml b/layouts/sitemap.xml
index 33ea396a0b8..f2de6d5016b 100644
--- a/layouts/sitemap.xml
+++ b/layouts/sitemap.xml
@@ -1,17 +1,14 @@
{{/* Override of Hugo's built-in sitemap template.
- Identical to the built-in output except that pages judged low-value by
- `functions/is_thin_taxonomy.html` are skipped. Those same pages are served
- with `noindex, follow` (see `partials/custom_head.html`); listing a
- noindexed URL in the sitemap sends search engines two contradictory
- signals, so both rules read the same predicate.
-
- Kept deliberately close to the built-in template so it stays easy to diff
- against upstream when Hugo changes. */ -}}
+ It previously skipped thin `tags` archives; those pages no longer exist
+ (see `[taxonomies]` in config/_default/config.toml), so the output now
+ matches the built-in except for line breaks between elements. Kept
+ as the hook for any future sitemap filtering, and deliberately close to
+ the built-in template so it stays easy to diff against upstream when Hugo
+ changes. */ -}}
{{ printf "" | safeHTML }}
- {{- range .Pages -}}
- {{- if not (partial "functions/is_thin_taxonomy.html" .) -}}
+ {{- range .Pages }}
{{ .Permalink }}
{{- if not .Lastmod.IsZero }}
@@ -29,6 +26,5 @@
{{- end }}
{{- end }}
- {{- end -}}
{{- end }}
From 35354b8437d35cb0ead711b703008eea94122e1f Mon Sep 17 00:00:00 2001
From: richarddushime
Date: Mon, 7 Sep 2026 16:43:29 +0200
Subject: [PATCH 2/4] tl
---
config/_default/config.toml | 11 +---
content/resources/index.md | 8 +--
content/resources/resource.py | 58 ++++++++-----------
layouts/partials/functions/page_tags.html | 30 ++++------
.../partials/structured-data/breadcrumbs.html | 4 +-
layouts/partials/tags.html | 19 ++----
layouts/sitemap.xml | 12 ++--
7 files changed, 49 insertions(+), 93 deletions(-)
diff --git a/config/_default/config.toml b/config/_default/config.toml
index 851f487f676..bd47dbc5fd6 100644
--- a/config/_default/config.toml
+++ b/config/_default/config.toml
@@ -80,14 +80,9 @@ ignoreFiles = ["\\.ipynb$", ".ipynb_checkpoints$", "\\.Rmd$", "\\.Rmarkdown$", "
# Taxonomies.
[taxonomies]
- # `tag = "tags"` taxonomy removed: `tags` is free text from the curated
- # resources sheet, so it grew to ~1,700 terms of which three quarters apply to
- # a single resource. The term pages were 47% of every page the site built and
- # carried no search intent, and `FORRT_clusters` already provides curated
- # topical navigation. `tags` stays in front matter as descriptive metadata --
- # it still feeds site search (`layouts/_default/index.json`), the resource
- # card filter (`partials/resource_li_card.html`) and the `[related]` indices
- # below, none of which need a taxonomy. Rendered by `partials/tags.html`.
+ # `tag = "tags"` taxonomy removed (#307): free text, ~1,700 terms, three
+ # quarters of them used once. `tags` stays in front matter — search, the
+ # resource card filter and `[related]` read it directly, no taxonomy needed.
category = "categories"
publication_type = "publication_types"
# `author = "authors"` taxonomy disabled: it collided with the content/authors/
diff --git a/content/resources/index.md b/content/resources/index.md
index c2d96b8c91c..4cd4aa7f600 100644
--- a/content/resources/index.md
+++ b/content/resources/index.md
@@ -4,12 +4,8 @@ type = "widget_page"
headless = false # Homepage is headless, other widget pages are not.
title = "Resources"
-# Redirects for the retired `/tag//` archives. The `tags` taxonomy was
-# removed (see `[taxonomies]` in config/_default/config.toml); these are the
-# terms that were still indexable at the time, so they are the ones search
-# engines and external links can be expected to hold. This page is where they
-# led in substance -- the same resources, searchable by the very tag text that
-# named the archive. Thin terms were already `noindex` and are left to 404.
+# Redirects for the retired `/tag//` archives (#307). Only the 136 terms
+# that were still indexable: the rest were `noindex`, so nothing points at them.
aliases = [
"/tag/aging-science/",
"/tag/aging/",
diff --git a/content/resources/resource.py b/content/resources/resource.py
index ba02c3c7b54..b7764dde993 100644
--- a/content/resources/resource.py
+++ b/content/resources/resource.py
@@ -98,22 +98,18 @@ def wrangle_data(df):
df.fillna('', inplace=True)
-# Submitters type tags freely into one sheet cell. Commas were the only
-# documented separator, but semicolons are used just as often and newlines slip
-# in from pasted text; splitting on all three is what stops a whole list being
-# stored as the single tag "Replication; Teaching".
+# The cell is free text: commas were the documented separator, but semicolons
+# are used just as often and newlines arrive with pasted text. Splitting on all
+# three is what stops a list being stored as the one tag "Replication; Teaching".
TAG_SEPARATORS = re.compile(r'[,;\n]+')
def clean_tag(raw: str) -> str:
"""One tag, normalised, or '' if the value is not a term at all.
- Collapses whitespace (including the non-breaking spaces that come from
- pasted text), drops the leading '#' of hashtag-style submissions, and trims
- stray punctuation from both ends -- "Diversity, Equity, & Inclusion" splits
- on its commas and leaves "& Inclusion" behind. Returns '' for placeholders: the sheet
- carries blanks, "-" and "." as stand-ins for "no tags", and a value with no
- letter or digit is never a term.
+ The '&' in the trim set is for "Diversity, Equity, & Inclusion", which the
+ comma split leaves as a stray "& Inclusion". Anything with no letter or
+ digit is a placeholder: the sheet uses blanks, "-" and "." for "no tags".
"""
tag = re.sub(r'\s+', ' ', raw.replace('\u00a0', ' ')).strip()
tag = tag.lstrip('#').strip()
@@ -124,9 +120,8 @@ def clean_tag(raw: str) -> str:
def split_tags(cell: str) -> list:
"""A tag cell as a list of clean tags, de-duplicated case-insensitively.
- Duplicates are compared case-folded because the same term is submitted in
- several casings; the first spelling in the cell wins, and `canonical_tags`
- then settles the casing across the whole sheet.
+ Case-folded because the same term is submitted in several casings; the
+ first spelling wins here and `canonical_tags` settles it sheet-wide.
"""
tags, seen = [], set()
for tag in (clean_tag(part) for part in TAG_SEPARATORS.split(cell)):
@@ -136,10 +131,9 @@ def split_tags(cell: str) -> list:
return tags
-# Longest all-caps spelling treated as an acronym rather than shouting. Covers
-# the acronyms the sheet actually carries (OSF, FAIR, ADHD, UNESCO, COSMIN) and
-# excludes the shouted words it also carries (LECTURE, PSYCHOLOGY, INTRODUCTION).
-# Measured over letters only, so "COVID-19" counts as five.
+# Longest all-caps spelling still read as an acronym. Six keeps the sheet's
+# acronyms (OSF, FAIR, UNESCO) and drops its shouting (LECTURE, PSYCHOLOGY).
+# Letters only, so "COVID-19" counts as five.
ACRONYM_MAX_LETTERS = 6
@@ -148,8 +142,8 @@ def is_shouted(spelling: str) -> bool:
return spelling.isupper() and len([c for c in spelling if c.isalpha()]) > ACRONYM_MAX_LETTERS
-# Words English title case leaves lowercase unless they open the term. Used only
-# to score competing spellings against each other, never to rewrite a tag.
+# Lowercase in title case unless they open the term. Only ever used to score
+# competing spellings against each other, never to rewrite a tag.
FUNCTION_WORDS = frozenset(
'a an and as at but by for from in into nor of on or the to via vs with'.split()
)
@@ -158,10 +152,8 @@ def is_shouted(spelling: str) -> bool:
def title_case_score(spelling: str) -> float:
"""How closely a spelling follows title case, from 0 to 1.
- Separates "Evolution of Science" from "Evolution Of Science" and "Data
- Curation" from "Data curation" when both are equally common. An all-caps
- word counts as capitalised, so acronyms inside a term ("FAIR Data") are not
- penalised.
+ Breaks the tie between "Evolution of Science" and "Evolution Of Science".
+ An all-caps word counts as capitalised, so "FAIR Data" is not penalised.
"""
words = spelling.split()
matches = 0
@@ -179,14 +171,11 @@ def title_case_score(spelling: str) -> float:
def canonical_tags(df):
"""Settle on one spelling per tag across the whole sheet.
- "Open Science", "Open science" and "OPEN SCIENCE" are one term submitted
- three ways; left alone they read as three different labels on the cards.
- The winner is chosen rather than imposed as Title Case, so acronyms and
- product names ("OSF", "COVID-19", "RMarkdown") keep the form contributors
- actually use: shouted spellings last, then the most common, then the
- closest to title case ("Evolution of Science" over "Evolution Of Science"),
- then alphabetically so a rebuild of the same sheet is reproducible. Terms
- submitted only one way are left untouched.
+ "Open Science", "Open science" and "OPEN SCIENCE" read as three labels on
+ the cards. A winner is picked from the spellings in use rather than Title
+ Case imposed, so "OSF", "COVID-19" and "RMarkdown" survive; ranked by
+ shouted last, most common, closest to title case, then alphabetically for
+ a reproducible rebuild. Terms submitted one way only are left alone.
"""
spellings = {}
for tags in df['tags'].values:
@@ -210,10 +199,9 @@ def split_cells(df):
df['education_level'] = [[y.strip() for y in x.split(',')] for x in df['education_level'].values]
df['subject_areas'] = [[y.strip() for y in x.split(',')] for x in df['subject_areas'].values]
df['FORRT_clusters'] = [[y.strip() for y in x.split(',')] for x in df['FORRT_clusters'].values]
- # Tags get their own splitter: they are free text rather than a controlled
- # vocabulary, so they need separator, placeholder and duplicate handling
- # the other columns do not. A resource with no usable tags is still kept,
- # it just ends up untagged.
+ # Tags are free text rather than a controlled vocabulary, so unlike the
+ # columns above they need separator, placeholder and duplicate handling. A
+ # resource with no usable tags is kept, just untagged.
df['tags'] = [split_tags(x) for x in df['tags'].values]
df['language'] = [[y.strip() for y in x.split(',')] for x in df['language'].values]
diff --git a/layouts/partials/functions/page_tags.html b/layouts/partials/functions/page_tags.html
index 1355caf116b..57e5e251994 100644
--- a/layouts/partials/functions/page_tags.html
+++ b/layouts/partials/functions/page_tags.html
@@ -1,25 +1,16 @@
-{{/* A page's `tags`, cleaned, in one place for the whole site.
+{{/* A page's `tags`, cleaned — the single definition for the whole site.
- Tags reach the site from several sources -- the curated-resources sheet via
- `content/resources/resource.py`, and hand-written front matter anywhere
- else -- and every section renders them through the same path
- (`_default/single.html` -> `page_footer` -> `tags.html`). Cleaning here
- rather than in each template means a section added later inherits it, and
- the badges, the search index, the resource cards and the JSON-LD keywords
- can never disagree about what a page's tags are.
+ Tags arrive from the curated-resources sheet and from hand-written front
+ matter, and four consumers read them (badges, search index, resource cards,
+ JSON-LD keywords). Cleaning here rather than in each one keeps them from
+ disagreeing, and a section added later inherits it.
- Applied to each value: split on the separators contributors actually use
- (`,`, `;`, newline), collapse whitespace, drop a leading `#`, trim leading and trailing
- sentence punctuation, discard anything with no letter or digit, then
- de-duplicate case-insensitively keeping the first spelling.
+ Casing is left alone on purpose: picking one spelling per term needs
+ frequency counts over the whole corpus, so `canonical_tags()` in
+ `content/resources/resource.py` does it at the source — which is also the
+ only place that can fix what Hugo's `[related]` indices read directly.
- Casing is deliberately left alone. Settling on one spelling per term needs
- frequency counts across the whole corpus, which is a data-pipeline job:
- `canonical_tags()` in `content/resources/resource.py` does it at the source,
- where it can also fix the raw front matter that Hugo's `[related]` indices
- read directly.
-
- Inputs: page context. Output: slice of strings (possibly empty). */}}
+ Inputs: page context. Output: slice of strings, possibly empty. */}}
{{ $tags := slice }}
{{ $seen := slice }}
@@ -29,6 +20,7 @@
{{/* `\s` does not cover the non-breaking spaces that arrive with pasted text. */}}
{{ $tag := replaceRE `[\s\x{00a0}]+` " " . }}
{{ $tag = trim (strings.TrimPrefix "#" (trim $tag " ")) " " }}
+ {{/* `&` because "Diversity, Equity, & Inclusion" splits into a "& Inclusion". */}}
{{ $tag = trim $tag " .,;:-&" }}
{{ if findRE `[\pL\pN]` $tag 1 }}
{{ $key := lower $tag }}
diff --git a/layouts/partials/structured-data/breadcrumbs.html b/layouts/partials/structured-data/breadcrumbs.html
index 27b316318c1..803bdebd695 100644
--- a/layouts/partials/structured-data/breadcrumbs.html
+++ b/layouts/partials/structured-data/breadcrumbs.html
@@ -8,9 +8,7 @@
- the home page, which is the root of every trail;
- `clusters`, which builds a richer trail of its own in
`clusters/cluster_seo_jsonld.html` — two BreadcrumbLists on one page
- would compete;
- - thin tag archives, which are `noindex` and so will never render a
- result to enhance.
+ would compete.
The skips are one wrapping conditional rather than early `return`s: a Hugo
partial either returns a value or writes output, and mixing the two makes
diff --git a/layouts/partials/tags.html b/layouts/partials/tags.html
index d04dac1e1df..4808e32469c 100644
--- a/layouts/partials/tags.html
+++ b/layouts/partials/tags.html
@@ -1,18 +1,9 @@
-{{/* Site override of the theme's `tags` partial: renders a page's tags as
- plain labels rather than links.
+{{/* Site override of the theme's `tags` partial: plain labels, not links.
- `tags` is no longer a Hugo taxonomy (see `[taxonomies]` in
- config/_default/config.toml), so `.GetTerms "tags"` -- which the theme
- partial uses -- returns nothing and there is no term page to link to. The
- values remain useful as descriptive metadata and are still searchable via
- the site search index and the resource card filter.
-
- Reached by every section through `_default/single.html` -> `page_footer`,
- so the values come from `functions/page_tags.html` rather than raw front
- matter: one definition of a page's tags for badges, search and structured
- data alike.
-
- Inputs: page context. */}}
+ `tags` is no longer a taxonomy (see `[taxonomies]` in config.toml), so the
+ theme's `.GetTerms "tags"` resolves to nothing and there is no term page to
+ link to. Reached by every section via `_default/single.html` ->
+ `page_footer`, so values come from `functions/page_tags.html`. */}}
{{ with partial "functions/page_tags.html" . }}
diff --git a/layouts/sitemap.xml b/layouts/sitemap.xml
index f2de6d5016b..f4e05c499d4 100644
--- a/layouts/sitemap.xml
+++ b/layouts/sitemap.xml
@@ -1,11 +1,7 @@
-{{/* Override of Hugo's built-in sitemap template.
-
- It previously skipped thin `tags` archives; those pages no longer exist
- (see `[taxonomies]` in config/_default/config.toml), so the output now
- matches the built-in except for line breaks between
elements. Kept
- as the hook for any future sitemap filtering, and deliberately close to
- the built-in template so it stays easy to diff against upstream when Hugo
- changes. */ -}}
+{{/* Override of Hugo's built-in sitemap template, kept deliberately close to it
+ so it stays easy to diff against upstream. The thin-`tags`-archive filter
+ it was added for went with the taxonomy, so output now differs only in line
+ breaks; retained as the hook for future filtering. */ -}}
{{ printf "" | safeHTML }}
{{- range .Pages }}
From c81d3f3e7d2da22cabca5e519eb1defa957adc72 Mon Sep 17 00:00:00 2001
From: richarddushime
Date: Mon, 7 Sep 2026 17:15:59 +0200
Subject: [PATCH 3/4] fix stale
---
content/resources/resource.py | 11 ++++++++++-
layouts/partials/functions/page_tags.html | 6 +++++-
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/content/resources/resource.py b/content/resources/resource.py
index b7764dde993..fe4271c88ff 100644
--- a/content/resources/resource.py
+++ b/content/resources/resource.py
@@ -117,6 +117,15 @@ def clean_tag(raw: str) -> str:
return tag if any(ch.isalnum() for ch in tag) else ''
+def split_hashtag_run(cell: str) -> str:
+ """Make "#a #b #c" separable, since a run of hashtags is a list.
+
+ Only applied when the cell opens with '#', so an ordinary term carrying a
+ number ("Issue #42") is left as one tag.
+ """
+ return re.sub(r'\s+#', ',#', cell) if cell.lstrip().startswith('#') else cell
+
+
def split_tags(cell: str) -> list:
"""A tag cell as a list of clean tags, de-duplicated case-insensitively.
@@ -124,7 +133,7 @@ def split_tags(cell: str) -> list:
first spelling wins here and `canonical_tags` settles it sheet-wide.
"""
tags, seen = [], set()
- for tag in (clean_tag(part) for part in TAG_SEPARATORS.split(cell)):
+ for tag in (clean_tag(part) for part in TAG_SEPARATORS.split(split_hashtag_run(cell))):
if tag and tag.casefold() not in seen:
seen.add(tag.casefold())
tags.append(tag)
diff --git a/layouts/partials/functions/page_tags.html b/layouts/partials/functions/page_tags.html
index 57e5e251994..85fe8453d9e 100644
--- a/layouts/partials/functions/page_tags.html
+++ b/layouts/partials/functions/page_tags.html
@@ -16,7 +16,11 @@
{{ $seen := slice }}
{{ range .Params.tags }}
- {{ range split (replaceRE `[;\n]+` "," (printf "%v" .)) "," }}
+ {{/* A run of hashtags is a list. Guarded on a leading `#` so an ordinary term
+ carrying a number ("Issue #42") is not split. */}}
+ {{ $cell := printf "%v" . }}
+ {{ if hasPrefix (trim $cell " ") "#" }}{{ $cell = replaceRE `\s+#` ",#" $cell }}{{ end }}
+ {{ range split (replaceRE `[;\n]+` "," $cell) "," }}
{{/* `\s` does not cover the non-breaking spaces that arrive with pasted text. */}}
{{ $tag := replaceRE `[\s\x{00a0}]+` " " . }}
{{ $tag = trim (strings.TrimPrefix "#" (trim $tag " ")) " " }}
From 1599cf5a91f9c37e07543ef41b856bf575d09cb4 Mon Sep 17 00:00:00 2001
From: richarddushime
Date: Mon, 7 Sep 2026 18:20:55 +0200
Subject: [PATCH 4/4] populated /tags/ as a page of tags
---
content/tags/_index.md | 7 ++
layouts/partials/custom_js.html | 5 ++
layouts/partials/functions/tag_counts.html | 56 +++++++++++++++
layouts/partials/tags.html | 12 ++--
layouts/tags/list.html | 41 +++++++++++
layouts/tags/list.json | 23 ++++++
static/js/tag-browser.js | 84 ++++++++++++++++++++++
7 files changed, 223 insertions(+), 5 deletions(-)
create mode 100644 content/tags/_index.md
create mode 100644 layouts/partials/functions/tag_counts.html
create mode 100644 layouts/tags/list.html
create mode 100644 layouts/tags/list.json
create mode 100644 static/js/tag-browser.js
diff --git a/content/tags/_index.md b/content/tags/_index.md
new file mode 100644
index 00000000000..acf83f05b10
--- /dev/null
+++ b/content/tags/_index.md
@@ -0,0 +1,7 @@
++++
+title = "Tags"
+description = "Every tag used across the website, with the number of pages carrying it."
+
+# JSON carries the page list the browser filters; see layouts/tags/list.json.
+outputs = ["HTML", "JSON"]
++++
diff --git a/layouts/partials/custom_js.html b/layouts/partials/custom_js.html
index ee7b8dcc214..a5144b95294 100644
--- a/layouts/partials/custom_js.html
+++ b/layouts/partials/custom_js.html
@@ -9,3 +9,8 @@
{{ if $isClustersPage }}
{{ end }}
+
+{{/* Tag browser: /tags/ filters a site-wide list of tagged pages (issue #307). */}}
+{{ if and .File (strings.HasPrefix .File.Path "tags/") }}
+
+{{ end }}
diff --git a/layouts/partials/functions/tag_counts.html b/layouts/partials/functions/tag_counts.html
new file mode 100644
index 00000000000..15ba6a980f9
--- /dev/null
+++ b/layouts/partials/functions/tag_counts.html
@@ -0,0 +1,56 @@
+{{/* Every tag in use, with the number of pages carrying it.
+
+ There is no `tags` taxonomy to read counts from any more (see
+ `[taxonomies]` in config/_default/config.toml), so this walks the pages
+ once. `Scratch.SetInMap` mutates one map in place; tallying with `merge`
+ instead would copy a ~1,650-key map on each of ~5,400 tags. The term order
+ is tracked alongside the tally because Hugo has no `keys` function.
+
+ Call through `partialCached` so the walk happens once per build.
+
+ Inputs: none (uses `site`). Output: dict with `rows` (dicts of `tag` and
+ `count`, most used first, one row per term regardless of spelling),
+ `pages` (pages carrying at least one tag) and `total` (tag applications).
+ `pages` is counted here rather than with a `where` on `Params.tags`, which
+ would also match the `tags: []` left on pages cleared in #486. */}}
+
+{{ $tally := newScratch }}
+{{ $terms := slice }}
+{{ $pages := 0 }}
+{{ $total := 0 }}
+
+{{ range site.RegularPages }}
+ {{ $tags := partial "functions/page_tags.html" . }}
+ {{ if $tags }}{{ $pages = add $pages 1 }}{{ end }}
+ {{ range $tags }}
+ {{ $total = add $total 1 }}
+
+ {{/* Tallied case-insensitively to match the card filter, which lower-cases
+ both sides. Counting per spelling would put 415 against "Open Science"
+ where the filter returns 422, reading as an off-by-seven bug. */}}
+ {{ $key := lower . }}
+ {{ $seen := int (index ($tally.Get "n" | default dict) $key | default 0) }}
+ {{ if eq $seen 0 }}{{ $terms = $terms | append $key }}{{ end }}
+ {{ $tally.SetInMap "n" $key (add $seen 1) }}
+
+ {{/* Label: the most common spelling, so it reads the way contributors
+ write it. `canonical_tags()` in content/resources/resource.py settles
+ this at the source; until a sheet rebuild lands, variants coexist. */}}
+ {{ $spellKey := printf "%s\x1e%s" $key . }}
+ {{ $mine := add 1 (int (index ($tally.Get "spell" | default dict) $spellKey | default 0)) }}
+ {{ $tally.SetInMap "spell" $spellKey $mine }}
+ {{ if gt $mine (int (index ($tally.Get "best" | default dict) $key | default 0)) }}
+ {{ $tally.SetInMap "best" $key $mine }}
+ {{ $tally.SetInMap "label" $key . }}
+ {{ end }}
+ {{ end }}
+{{ end }}
+
+{{ $counts := $tally.Get "n" | default dict }}
+{{ $labels := $tally.Get "label" | default dict }}
+{{ $rows := slice }}
+{{ range sort $terms }}
+ {{ $rows = $rows | append (dict "tag" (index $labels .) "count" (int (index $counts .))) }}
+{{ end }}
+
+{{ return (dict "rows" (sort $rows "count" "desc") "pages" $pages "total" $total) }}
diff --git a/layouts/partials/tags.html b/layouts/partials/tags.html
index 4808e32469c..a5e71fc16af 100644
--- a/layouts/partials/tags.html
+++ b/layouts/partials/tags.html
@@ -1,14 +1,16 @@
-{{/* Site override of the theme's `tags` partial: plain labels, not links.
+{{/* Site override of the theme's `tags` partial: tags link to the site-wide
+ tag browser, not to a term page.
`tags` is no longer a taxonomy (see `[taxonomies]` in config.toml), so the
- theme's `.GetTerms "tags"` resolves to nothing and there is no term page to
- link to. Reached by every section via `_default/single.html` ->
- `page_footer`, so values come from `functions/page_tags.html`. */}}
+ theme's `.GetTerms "tags"` resolves to nothing and there are no per-term
+ archives to link to. `/tags/?tag=` lists every page carrying the term
+ across all sections, which is the browse those archives stood in for.
+ Reached by every section via `_default/single.html` -> `page_footer`. */}}
{{ with partial "functions/page_tags.html" . }}
{{ range . }}
-
{{ . }}
+
{{ . }}
{{ end }}
{{ end }}
diff --git a/layouts/tags/list.html b/layouts/tags/list.html
new file mode 100644
index 00000000000..c9353d064d5
--- /dev/null
+++ b/layouts/tags/list.html
@@ -0,0 +1,41 @@
+{{ define "main" }}
+{{/* Browse index for a vocabulary that is no longer a Hugo taxonomy.
+
+ Replaces the 1,597 per-term archives and their 160-page paginated index
+ with one page: the terms are worth seeing as a set, the individual archives
+ were not (three quarters listed a single resource). Selecting a term filters
+ a site-wide list of tagged pages in place -- every section, not just curated
+ resources -- from `list.json` via `static/js/tag-browser.js`.
+
+ The cloud is server-rendered so the vocabulary is readable, linkable and
+ indexable with JavaScript off; only the result list needs the script. */}}
+
+{{ $tags := partialCached "functions/tag_counts.html" . }}
+{{ $rows := $tags.rows }}
+
+
+
{{ .Title }}
+ {{ with .Params.description }}
{{ . }}
{{ end }}
+
+ {{ len $rows }} tags across {{ $tags.pages }} pages, most used first. Tags are
+ contributor-supplied keywords; for curated topics see
+ FORRT clusters.
+
+
+ {{/* Filled by the script when a tag is selected; stays empty otherwise. */}}
+
+
+
+
+{{ end }}
diff --git a/layouts/tags/list.json b/layouts/tags/list.json
new file mode 100644
index 00000000000..0455037e3b8
--- /dev/null
+++ b/layouts/tags/list.json
@@ -0,0 +1,23 @@
+{{- /* Every tagged page on the site, for the browser on /tags/.
+
+ A dedicated index rather than the site search's `/index.json`, which
+ carries each page's full plain text and runs to ~5 MB; this is the four
+ fields the tag browser needs, for the pages that actually have tags.
+
+ Tags are lower-cased so the browser matches without repeating the casing
+ rules. Keys are short because they repeat on every row. */ -}}
+{{- $rows := slice -}}
+{{- range site.RegularPages -}}
+ {{- $page := . -}}
+ {{- $tags := partial "functions/page_tags.html" $page -}}
+ {{- if $tags -}}
+ {{- $lower := slice -}}
+ {{- range $tags -}}{{- $lower = $lower | append (lower .) -}}{{- end -}}
+ {{- $rows = $rows | append (dict
+ "t" $page.Title
+ "u" $page.RelPermalink
+ "s" ($page.Section | default "")
+ "g" $lower) -}}
+ {{- end -}}
+{{- end -}}
+{{- $rows | jsonify -}}
diff --git a/static/js/tag-browser.js b/static/js/tag-browser.js
new file mode 100644
index 00000000000..8e560831a3a
--- /dev/null
+++ b/static/js/tag-browser.js
@@ -0,0 +1,84 @@
+/**
+ * Site-wide tag browsing on /tags/.
+ *
+ * Tag badges across the site link to `/tags/?tag=` rather than to a
+ * per-term archive page; those were removed with the `tags` taxonomy (issue
+ * #307). This reads the parameter, fetches the page index built by
+ * `layouts/tags/list.json`, and lists every tagged page that carries the term
+ * -- any section, not just curated resources.
+ *
+ * With no parameter, or with JavaScript off, the page is the server-rendered
+ * tag cloud and nothing here runs.
+ */
+(function () {
+ "use strict";
+
+ var tag = new URLSearchParams(window.location.search).get("tag");
+ if (!tag) return;
+
+ var needle = tag.toLowerCase();
+
+ /** "neurodiversity-lessonbank" reads better as "Neurodiversity lessonbank". */
+ function sectionLabel(section) {
+ if (!section) return "";
+ var words = section.replace(/[-_]+/g, " ").trim();
+ return words.charAt(0).toUpperCase() + words.slice(1);
+ }
+
+ function render(pages) {
+ var results = document.getElementById("tag-results");
+ var heading = document.getElementById("tag-results-heading");
+ var list = document.getElementById("tag-results-list");
+ if (!results || !heading || !list) return;
+
+ heading.textContent = pages.length
+ ? pages.length + (pages.length === 1 ? " page tagged " : " pages tagged ") + "“" + tag + "”"
+ : "No pages are tagged “" + tag + "”";
+
+ pages.forEach(function (page) {
+ var item = document.createElement("li");
+ item.className = "mb-2";
+
+ var link = document.createElement("a");
+ link.href = page.u;
+ link.textContent = page.t;
+ item.appendChild(link);
+
+ var label = sectionLabel(page.s);
+ if (label) {
+ var badge = document.createElement("span");
+ badge.className = "badge badge-light ml-2";
+ badge.textContent = label;
+ item.appendChild(badge);
+ }
+ list.appendChild(item);
+ });
+
+ results.hidden = false;
+ // The cloud stays available below the results, so no need to hide it.
+ results.scrollIntoView({ block: "nearest" });
+ }
+
+ document.addEventListener("DOMContentLoaded", function () {
+ // Resolved against this page so it works under a baseURL sub-path.
+ fetch(new URL("index.json", window.location.href).toString())
+ .then(function (response) {
+ if (!response.ok) throw new Error("tag index " + response.status);
+ return response.json();
+ })
+ .then(function (rows) {
+ render(
+ rows.filter(function (row) {
+ return row.g && row.g.indexOf(needle) !== -1;
+ })
+ );
+ })
+ .catch(function () {
+ var heading = document.getElementById("tag-results-heading");
+ var results = document.getElementById("tag-results");
+ if (!heading || !results) return;
+ heading.textContent = "Could not load the tag index. Reload to try again.";
+ results.hidden = false;
+ });
+ });
+})();