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 @@

Type of resources: {{delimit $item.Params.Material_Type ", " }}

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" . }} + +{{ 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" . }}