diff --git a/config/_default/config.toml b/config/_default/config.toml
index b74ce656e62..bd47dbc5fd6 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,9 @@ ignoreFiles = ["\\.ipynb$", ".ipynb_checkpoints$", "\\.Rmd$", "\\.Rmarkdown$", "
# Taxonomies.
[taxonomies]
- tag = "tags"
+ # `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/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..4cd4aa7f600 100644
--- a/content/resources/index.md
+++ b/content/resources/index.md
@@ -3,4 +3,145 @@
type = "widget_page"
headless = false # Homepage is headless, other widget pages are not.
title = "Resources"
+
+# 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/",
+ "/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..fe4271c88ff 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,109 @@ def wrangle_data(df):
df.fillna('', inplace=True)
+# 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.
+
+ 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()
+ tag = tag.strip(' .,;:-&')
+ 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.
+
+ 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(split_hashtag_run(cell))):
+ if tag and tag.casefold() not in seen:
+ seen.add(tag.casefold())
+ tags.append(tag)
+ return tags
+
+
+# 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
+
+
+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
+
+
+# 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()
+)
+
+
+def title_case_score(spelling: str) -> float:
+ """How closely a spelling follows title case, from 0 to 1.
+
+ 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
+ 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" 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:
+ 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 +208,10 @@ 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 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]
@@ -170,6 +270,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/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/_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/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/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..85fe8453d9e
--- /dev/null
+++ b/layouts/partials/functions/page_tags.html
@@ -0,0 +1,39 @@
+{{/* A page's `tags`, cleaned — the single definition for the whole site.
+
+ 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.
+
+ 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.
+
+ Inputs: page context. Output: slice of strings, possibly empty. */}}
+
+{{ $tags := slice }}
+{{ $seen := slice }}
+
+{{ range .Params.tags }}
+ {{/* 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 " ")) " " }}
+ {{/* `&` because "Diversity, Equity, & Inclusion" splits into a "& Inclusion". */}}
+ {{ $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/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/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..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
@@ -24,7 +22,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..a5e71fc16af
--- /dev/null
+++ b/layouts/partials/tags.html
@@ -0,0 +1,16 @@
+{{/* 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 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/sitemap.xml b/layouts/sitemap.xml
index 33ea396a0b8..f4e05c499d4 100644
--- a/layouts/sitemap.xml
+++ b/layouts/sitemap.xml
@@ -1,17 +1,10 @@
-{{/* 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. */ -}}
+{{/* 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 -}}
- {{- if not (partial "functions/is_thin_taxonomy.html" .) -}}
+ {{- range .Pages }}
{{ .Permalink }}
{{- if not .Lastmod.IsZero }}
@@ -29,6 +22,5 @@
{{- end }}
{{- end }}
- {{- 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;
+ });
+ });
+})();