Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion assets/css/doors/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@ main:lang(zh){--display:'Noto Serif CJK SC','Songti SC','STSong',SimSun,serif;--
*,*::before,*::after,.font-italic{font-style:normal!important}
em,i:not([class]),dfn,cite,var{font-weight:600}
.references-list :is(em,i),.references-page .article-style :is(em,i),
:is(h2,h3,h4)[id^=references i]~:is(ul,ol,p) :is(em,i),.csl-bib-body :is(em,i){font-style:italic!important;font-weight:400}
:is(h2,h3,h4)[id^=references i]~:is(ul,ol,p) :is(em,i),.csl-bib-body :is(em,i),
.ref-apa :is(em,i),.rl-detail-apa :is(em,i){font-style:italic!important;font-weight:400}
/* Publication titles from those lists keep their journal names and species in italics, at the title's weight. */
:is(.fr-title,.fc-title,.ref-popup-title,.rl-panel-item-title) i{font-style:italic!important;font-weight:inherit}

.wrap{width:min(1280px,calc(100% - 96px));margin-inline:auto}
.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
/* Small capitals in publication titles from Crossref (<scp>), see clusters/rich_text.html */
.scp{font-variant:small-caps}
.skip{position:fixed;top:-100px;inset-inline-start:12px;z-index:1100;background:var(--ink);color:#fff;padding:12px 16px}
.skip:focus,.skip:hover{top:8px;color:#fff}
.crumbs{font-size:15px;line-height:1.4;color:var(--ink)}
Expand Down
4 changes: 2 additions & 2 deletions layouts/partials/clusters/cluster_section.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ <h3 class="acc-label"><button type="button" class="acc-trigger" aria-expanded="f
{{ with $card.focus }}<span class="tag tag-focus">{{ . }}</span>{{ end }}
{{ with $card.resource_type }}<span class="tag tag-type">{{ . }}</span>{{ end }}
</div>
<div class="fr-title">{{ $card.title }}</div>
<div class="fr-title">{{ partial "clusters/rich_text.html" $card.title }}</div>
{{ with $card.summary }}<div class="fr-summary">{{ . }}</div>{{ end }}
<div class="fr-actions">
<button class="fr-ref-trigger" data-doi="{{ .doi }}">
Expand Down Expand Up @@ -152,7 +152,7 @@ <h3 class="acc-label"><button type="button" class="acc-trigger" aria-expanded="f
{{ with $card.focus }}<span class="tag tag-focus">{{ . }}</span>{{ end }}
{{ with $card.resource_type }}<span class="tag tag-type">{{ . }}</span>{{ end }}
</div>
<div class="fc-title">{{ $card.title }}</div>
<div class="fc-title">{{ partial "clusters/rich_text.html" $card.title }}</div>
</div>
{{ with $card.summary }}
<div class="fc-summary">{{ . }}</div>
Expand Down
4 changes: 2 additions & 2 deletions layouts/partials/clusters/featured_cards.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
{{ if .focus }}<span class="tag tag-focus">{{ .focus }}</span>{{ end }}
{{ if .resource_type }}<span class="tag tag-type">{{ .resource_type }}</span>{{ end }}
</div>
<div class="fr-title">{{ .title }}</div>
<div class="fr-title">{{ partial "clusters/rich_text.html" .title }}</div>
<div class="fr-summary">{{ .summary }}</div>
<div class="fr-actions">
<button class="fr-ref-trigger" data-doi="{{ .doi }}">
Expand Down Expand Up @@ -94,7 +94,7 @@
{{ if .focus }}<span class="tag tag-focus">{{ .focus }}</span>{{ end }}
{{ if .resource_type }}<span class="tag tag-type">{{ .resource_type }}</span>{{ end }}
</div>
<div class="fc-title">{{ .title }}</div>
<div class="fc-title">{{ partial "clusters/rich_text.html" .title }}</div>
</div>
{{ if .summary }}
<div class="fc-summary">{{ .summary }}</div>
Expand Down
8 changes: 8 additions & 0 deletions layouts/partials/clusters/rich_text.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{{- /* Publication titles from Crossref: plain text plus <i>, <em> and <scp> markup, sometimes
with HTML entities. Escape everything, then restore only those tags (scp as small caps).
static/js/featured-resources.js has the same rule (richText). */ -}}
{{- $s := . | default "" | htmlUnescape | replaceRE `\s+` " " | strings.TrimSpace | htmlEscape -}}
{{- $s = replaceRE `&lt;i&gt;(.*?)&lt;/i&gt;` "<i>$1</i>" $s -}}
{{- $s = replaceRE `&lt;em&gt;(.*?)&lt;/em&gt;` "<i>$1</i>" $s -}}
{{- $s = replaceRE `&lt;scp&gt;(.*?)&lt;/scp&gt;` `<span class="scp">$1</span>` $s -}}
{{- return ($s | safeHTML) -}}
23 changes: 23 additions & 0 deletions scripts/check_seo_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
so a section that is not rendered cannot leave an empty link in the trail.
18. No glossary page's <title> or <h1> shows Markdown emphasis as typed, e.g. "(*p*)".
19. No page loads EmailOctopus or reCAPTCHA before a visitor subscribes.
20. Cluster pages render publication-title markup (<i>, <em>, <scp>) instead of
showing it as literal or escaped text, and at least one cluster page exists.
21. The /clusters/ illustration (svg.clusters-map__art) is hidden from assistive technology
and holds no tab stop (every link has tabindex="-1"), and every in-page link in the
clusters map reaches an id on the page.
Expand Down Expand Up @@ -337,6 +339,26 @@ def check_img_alt(pages):
for p in pages if p.imgs_without_alt]


# Escaped once (&lt;i&gt;) or twice (&amp;lt;i&amp;gt;), or a raw <scp> no browser styles.
LITERAL_TITLE_MARKUP = re.compile(r"&(?:amp;)?lt;/?(?:i|em|scp)&(?:amp;)?gt;|</?scp>", re.IGNORECASE)


def check_cluster_title_markup(public_dir):
"""Cluster pages render publication-title markup (clusters/rich_text.html), never show it."""
clusters_dir = os.path.join(public_dir, "clusters")
found, scanned = [], 0
for root, _dirs, files in os.walk(clusters_dir):
if "index.html" in files:
scanned += 1
full = os.path.join(root, "index.html")
with open(full, encoding="utf-8", errors="replace") as handle:
if LITERAL_TITLE_MARKUP.search(handle.read()):
found.append(os.path.relpath(full, public_dir))
if not scanned:
return ["clusters/ (no cluster pages found)"]
return sorted(found)


def check_citation_targets(pages):
return ["%s (%s)" % (p.path, href or "missing href")
for p in pages for href in p.citations
Expand Down Expand Up @@ -651,6 +673,7 @@ def main(public_dir):
"16. glossary links reach existing English terms": check_glossary_links(pages),
"17. every breadcrumb link has a target": [p.path for p in pages if any(not h.strip() for h in p.crumb_links)],
"19. no page loads EmailOctopus or reCAPTCHA before a visitor subscribes": check_third_party_forms(pages),
"20. cluster pages show no literal <i>/<em>/<scp> title markup": check_cluster_title_markup(public_dir),
"21. the clusters illustration holds no tab stop and its links have targets": check_clusters_map(public_dir),
}
conditional = check_conditional_assets(pages)
Expand Down
23 changes: 23 additions & 0 deletions scripts/tests/test_check_seo_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ def test_raw_url_report_does_not_fail_otherwise_valid_build(self):
"<meta property='og:image:height' content='630'>"
"<meta name='twitter:card' content='summary_large_image'>")
(Path(public) / "index.html").write_text(html, encoding="utf-8")
cluster = Path(public) / "clusters" / "cluster-1" / "index.html"
cluster.parent.mkdir(parents=True)
cluster.write_text("<meta name='description' content='Cluster 1'><h1>Cluster 1</h1>", encoding="utf-8")
output = StringIO()
with redirect_stdout(output):
self.assertEqual(seo.main(public), 0)
Expand Down Expand Up @@ -243,6 +246,26 @@ def test_crumb_links_need_a_target(self):
self.assertEqual(bad.crumb_links, ["/", ""])


class ClusterTitleMarkupCheck(unittest.TestCase):
def check(self, body):
with tempfile.TemporaryDirectory() as public:
page = Path(public) / "clusters" / "cluster-1" / "index.html"
page.parent.mkdir(parents=True)
page.write_text("<div class=fc-title>%s</div>" % body, encoding="utf-8")
return seo.check_cluster_title_markup(public)

def test_rendered_markup_passes(self):
self.assertEqual(self.check("In <span class=scp>A</span>frica at <i>Cognition</i> (p &lt; .05)"), [])

def test_literal_or_unrendered_markup_fails(self):
for body in ("at &lt;i&gt;Cognition&lt;/i&gt;", "in <scp>A</scp>frica", "&lt;em&gt;AIMS&lt;/em&gt;",
"at &amp;lt;i&amp;gt;Cognition&amp;lt;/i&amp;gt;"):
self.assertEqual(self.check(body), ["clusters/cluster-1/index.html"], body)

def test_missing_cluster_pages_fail(self):
with tempfile.TemporaryDirectory() as public:
self.assertEqual(seo.check_cluster_title_markup(public), ["clusters/ (no cluster pages found)"])


def clusters_map_failures(page):
with tempfile.TemporaryDirectory() as public:
Expand Down
36 changes: 28 additions & 8 deletions static/js/featured-resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@
return d.innerHTML;
}

/** Decode HTML entities without parsing markup (a textarea's content is text only). */
function decodeEntities(s) {
var t = document.createElement('textarea');
t.innerHTML = s || '';
return t.value;
}

/** Titles and references from Crossref carry <i>, <em> and <scp>: escape, then restore those.
Same rule as layouts/partials/clusters/rich_text.html. */
function richText(s) {
return escHtml(decodeEntities(s).replace(/\s+/g, ' ').trim())
.replace(/&lt;(i|em)&gt;([\s\S]*?)&lt;\/\1&gt;/g, '<i>$2</i>')
.replace(/&lt;scp&gt;([\s\S]*?)&lt;\/scp&gt;/g, '<span class="scp">$1</span>');
}

/** The same text without markup, for copying and the PDF. */
function plainText(s) {
return decodeEntities(s).replace(/<\/?(?:i|em|scp)>/gi, '').replace(/\s+/g, ' ').trim();
}

/** Flat lookup: DOI → resource object across all clusters. */
function buildResourceIndex(data) {
var idx = {};
Expand Down Expand Up @@ -85,13 +105,13 @@
if (r.focus) html += '<span class="tag tag-focus">' + escHtml(r.focus) + '</span>';
if (r.resource_type) html += '<span class="tag tag-type">' + escHtml(r.resource_type) + '</span>';
html += '</div>';
html += '<div class="ref-popup-title">' + escHtml(r.title) + '</div>';
html += '<div class="ref-popup-title">' + richText(r.title) + '</div>';

// APA
if (r.apa) {
html += '<div class="ref-section">';
html += '<div class="ref-section-label">APA Reference</div>';
html += '<div class="ref-apa">' + escHtml(r.apa) + '</div>';
html += '<div class="ref-apa">' + richText(r.apa) + '</div>';
html += '<div class="ref-copy-row">';
html += '<button class="ref-copy-btn" data-copy="apa" data-doi="' + escHtml(doi) + '">' +
'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="8" height="10" rx="1"/><path d="M5 3V2a1 1 0 011-1h5a1 1 0 011 1v9a1 1 0 01-1 1h-1"/></svg>' +
Expand Down Expand Up @@ -154,7 +174,7 @@
var d = copyBtn.getAttribute('data-doi');
var res = resourceIdx[d];
if (!res) return;
var text = type === 'bibtex' ? res.bibtex : res.apa;
var text = type === 'bibtex' ? res.bibtex : plainText(res.apa);
navigator.clipboard.writeText(text).then(function () {
copyBtn.classList.add('copied');
var orig = copyBtn.innerHTML;
Expand Down Expand Up @@ -258,7 +278,7 @@
html += '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l4 4 4-4"/></svg>';
html += '</button>';
html += '<div class="rl-panel-item-info">';
html += '<div class="rl-panel-item-title">' + escHtml(r.title || doi) + '</div>';
html += '<div class="rl-panel-item-title">' + (r.title ? richText(r.title) : escHtml(doi)) + '</div>';
html += '<div class="rl-panel-item-ref">' + escHtml(r.short_ref || '') + '</div>';
html += '</div>';
html += '<button class="rl-panel-item-remove" data-doi="' + escHtml(doi) + '" aria-label="Remove">&times;</button>';
Expand All @@ -268,7 +288,7 @@
html += '<div class="rl-panel-item-details">';
if (r.apa) {
html += '<div class="rl-detail-ref-row">';
html += '<div class="rl-detail-apa">' + escHtml(r.apa) + '</div>';
html += '<div class="rl-detail-apa">' + richText(r.apa) + '</div>';
html += '<button class="rl-detail-copy" data-copy-apa="' + escHtml(doi) + '" title="Copy APA reference">';
html += '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="8" height="10" rx="1"/><path d="M5 3V2a1 1 0 011-1h5a1 1 0 011 1v9a1 1 0 01-1 1h-1"/></svg>';
html += '</button>';
Expand Down Expand Up @@ -365,7 +385,7 @@
var d = copyBtn.getAttribute('data-copy-apa');
var res = resourceIdx[d];
if (res && res.apa) {
navigator.clipboard.writeText(res.apa).then(function () {
navigator.clipboard.writeText(plainText(res.apa)).then(function () {
copyBtn.classList.add('copied');
setTimeout(function () { copyBtn.classList.remove('copied'); }, 1500);
});
Expand Down Expand Up @@ -467,7 +487,7 @@
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(34, 34, 34);
var titleLines = doc.splitTextToSize(r.title || doi, contentW);
var titleLines = doc.splitTextToSize(r.title ? plainText(r.title) : doi, contentW);
doc.text(titleLines, margin, y);
y += titleLines.length * 5 + 2;

Expand All @@ -477,7 +497,7 @@
doc.setFont('times', 'normal');
doc.setFontSize(9);
doc.setTextColor(68, 68, 68);
var apaLines = doc.splitTextToSize(r.apa, contentW - 10);
var apaLines = doc.splitTextToSize(plainText(r.apa), contentW - 10);
// Hanging indent: first line at margin, rest indented
apaLines.forEach(function (line, li) {
checkPage(4);
Expand Down
Loading