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

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..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" . }} + +{{ 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. */}} + + +
+ {{ range $rows }} + {{ .tag }} + {{ .count }} + {{ end }} +
+
+{{ 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; + }); + }); +})();