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
2 changes: 1 addition & 1 deletion src/js/components/navigator.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ define([

// clear any metadata added to head on the previous page
$('head')
.find('meta[data-highwire]')
.find('meta[data-highwire], script[data-ads-jsonld]')
.remove();
this._updateDocumentTitle(transition.title);
defer.resolve();
Expand Down
133 changes: 133 additions & 0 deletions src/js/widgets/meta_tags/jsonld.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Build a Schema.org ScholarlyArticle JSON-LD object for an ADS record.
* This is a lean implementation emitted for all doctypes; it uses only
* fields already fetched on the abstract page (identifier/UAT/encoding
* enrichment is deferred to a follow-up).
*
* Must be called with the raw record, before the meta_tags widget reshapes
* `author` and flattens `doi`.
*/
define(['underscore'], function(_) {
function firstString(v) {
if (_.isArray(v)) {
return v.length ? String(v[0]) : '';
}
return v == null ? '' : String(v);
}

function stripHtml(s) {
return String(s == null ? '' : s)
.replace(/<[^>]*>/g, '')
.trim();
}

function toArray(v) {
if (_.isArray(v)) {
return v;
}
return v == null ? [] : [v];
}

// Normalize ADS pubdate-like strings to valid ISO 8601. ADS uses "-00"
// placeholders for unknown month/day (e.g. "2017-06-00", "2017-00-00");
// day/month 00 is not a valid ISO date, so strip the placeholder segments
// down to YYYY-MM or YYYY. Reject anything that isn't a clean YYYY[-MM[-DD]].
function normalizePubdate(d) {
const raw = (d == null ? '' : String(d)).trim().replace(/(-00)+$/, '');
return /^\d{4}(-\d{2}){0,2}$/.test(raw) ? raw : undefined;
}

// Remove undefined/null, empty strings, and empty arrays; keep booleans.
function prune(obj) {
const out = {};
_.each(obj, function(v, k) {
if (v === undefined || v === null) {
return;
}
if (typeof v === 'string' && v.trim() === '') {
return;
}
if (_.isArray(v) && v.length === 0) {
return;
}
out[k] = v;
});
return out;
}

// Accept either a raw author string or an already-reshaped { name, aff }
// object (the meta_tags widget rewrites `author` in place before render).
function authorName(entry) {
if (entry == null) {
return '';
}
if (typeof entry === 'object') {
return String(entry.name == null ? '' : entry.name).trim();
}
return String(entry).trim();
}

function buildAuthors(doc) {
return _.map(toArray(doc.author), function(entry) {
return { '@type': 'Person', name: authorName(entry) };
});
}

function buildKeywords(doc) {
return _.filter(
_.map(toArray(doc.keyword), function(k) {
return String(k == null ? '' : k).trim();
}),
function(k) {
return k.length > 0;
}
);
}

function buildIsPartOf(doc) {
const pub = (doc.pub == null ? '' : String(doc.pub)).trim();
if (!pub) {
return undefined;
}
const periodical = { '@type': 'Periodical', name: pub };
const volume = (doc.volume == null ? '' : String(doc.volume)).trim();
if (!volume) {
return periodical;
}
return {
'@type': 'PublicationVolume',
volumeNumber: volume,
isPartOf: periodical,
};
}

function buildJsonLd(doc, canonicalUrl) {
doc = doc || {};
const title = stripHtml(firstString(doc.title));
const name = title || firstString(doc.bibcode) || 'Untitled';
const abstract = stripHtml(firstString(doc.abstract));
const url = canonicalUrl || undefined;

return prune({
'@context': 'https://schema.org',
'@type': 'ScholarlyArticle',
'@id': url,
url: url,
name: name,
headline: name,
abstract: abstract || undefined,
inLanguage: 'en',
// Only claim free access when the record is flagged open access;
// asserting it for paywalled content is a false structured-data signal.
isAccessibleForFree: _.contains(toArray(doc.property), 'OPENACCESS')
? true
: undefined,
datePublished: normalizePubdate(doc.pubdate),
author: buildAuthors(doc),
keywords: buildKeywords(doc),
isPartOf: buildIsPartOf(doc),
});
}

return { buildJsonLd: buildJsonLd };
});
39 changes: 39 additions & 0 deletions src/js/widgets/meta_tags/scholar_doctypes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Doctypes permitted to emit Google Scholar-compatible (Highwire
* citation_*) meta tags on the abstract page. Only these doctypes display
* the tags; any other or unknown doctype omits them. Schema.org JSON-LD is
* emitted for all records regardless and is not gated by this list.
*/
define([], function() {
const GOOGLE_SCHOLAR_DOCTYPES = [
'article',
'eprint',
'phdthesis',
'circular',
'inbook',
'erratum',
'book',
'mastersthesis',
'inproceedings',
'abstract',
'techreport',
'bookreview',
'proceedings',
'editorial',
'newsletter',
'obituary',
];

// Case-insensitive to tolerate inconsistent casing in upstream data.
function showsGoogleScholarTags(doctype) {
if (!doctype || typeof doctype !== 'string') {
return false;
}
return GOOGLE_SCHOLAR_DOCTYPES.indexOf(doctype.toLowerCase()) !== -1;
}

return {
GOOGLE_SCHOLAR_DOCTYPES: GOOGLE_SCHOLAR_DOCTYPES,
showsGoogleScholarTags: showsGoogleScholarTags,
};
});
6 changes: 6 additions & 0 deletions src/js/widgets/meta_tags/template/metatags.html
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
<meta name="og:type" content="article" data-highwire="true">
<meta name="twitter:card" content="summary" data-highwire="true">

{{#if showScholarTags}}
<meta name="citation_title" content="{{title}}" data-highwire="true">
{{/if}}
<meta name="og:title" content="{{title}}" data-highwire="true">
<meta name="twitter:title" content="{{title}}" data-highwire="true">

<meta name="og:url" content="{{url}}" data-highwire="true">
<meta name="twitter:url" content="{{url}}" data-highwire="true">
{{#if showScholarTags}}
<meta name="citation_abstract_html_url" content="{{url}}" data-highwire="true">
{{/if}}

<meta name="og:image" content="https://ui.adsabs.harvard.edu/styles/img/transparent_logo.svg" data-highwire="true">
<meta name="twitter:image" content="https://ui.adsabs.harvard.edu/styles/img/transparent_logo.svg" data-highwire="true">
Expand All @@ -16,6 +20,7 @@
<meta name="og:description" content="{{abstract}}" data-highwire="true">
<meta name="twitter:description" content="{{abstract}}" data-highwire="true">
{{/if}}
{{#if showScholarTags}}
{{#each author}}
<meta name="citation_author" content="{{this.name}}" data-highwire="true">
<meta xmlns="http://www.w3.org/1999/xhtml" name="citation_author_institution" content="{{this.aff}}" data-highwire="true">
Expand Down Expand Up @@ -47,3 +52,4 @@
{{#if pdfUrl}}
<meta name="citation_pdf_url" content="{{pdfUrl}}" data-highwire="true">
{{/if}}
{{/if}}
52 changes: 39 additions & 13 deletions src/js/widgets/meta_tags/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ define([
'js/widgets/base/base_widget',
'hbs!js/widgets/meta_tags/template/metatags',
'js/mixins/link_generator_mixin',
], function($, Backbone, _, BaseWidget, metatagsTemplate, LinkGenerator) {
'js/widgets/meta_tags/jsonld',
'js/widgets/meta_tags/scholar_doctypes',
], function(
$,
Backbone,
_,
BaseWidget,
metatagsTemplate,
LinkGenerator,
JsonLd,
ScholarDoctypes
) {
var View = Backbone.View.extend({
destroy: function() {
this.remove();
Expand Down Expand Up @@ -78,6 +89,12 @@ define([
updateMetaTags: function(data) {
data.url = Backbone.history.location.href;

// Build JSON-LD from the raw record before the mutations below reshape
// `author` and flatten `doi`. Emitted for all doctypes; the citation_*
// tags are gated by the doctype whitelist.
const jsonld = JsonLd.buildJsonLd(data, data.url);
data.showScholarTags = ScholarDoctypes.showsGoogleScholarTags(data.doctype);

var sources = {};
try {
sources = this.parseResourcesData(data);
Expand Down Expand Up @@ -111,17 +128,26 @@ define([
});
}

// Update the <head> with the meta tags
$('head').append(function() {
return $(metatagsTemplate(data)).filter(function() {
var name = $(this).attr('name');
if (name) {
// check to see if the tag already exists
return !$('head>meta[name="' + name + '"]').length;
}
return true;
});
});
// Clear the widget's prior tags before rendering so the current record
// is authoritative. The old append-if-absent logic could leave stale
// citation_* tags in <head> when a later record omits them.
$('head')
.find('meta[data-highwire="true"]')
.remove();
$('head').append(metatagsTemplate(data));

// Replace any prior JSON-LD node so repeated invocations (search
// results + abstract display) leave exactly one block, reflecting the
// most recently displayed record.
$('head')
.find('script[type="application/ld+json"][data-ads-jsonld]')
Comment on lines +139 to +143
.remove();
$('head').append(
$('<script>', {
type: 'application/ld+json',
'data-ads-jsonld': 'true',
}).text(JSON.stringify(jsonld))
);

// fire off dom events
this.emitDOMEvents();
Expand All @@ -141,7 +167,7 @@ define([
},
defaultQueryArguments: {
fl:
'links_data,[citations],keyword,property,first_author,year,issn,isbn,title,aff,abstract,bibcode,pub,pub_raw,volume,author,issue,pubdate,doi,page,esources,data',
'links_data,[citations],keyword,property,first_author,year,issn,isbn,title,aff,abstract,bibcode,pub,pub_raw,volume,author,issue,pubdate,doi,page,esources,data,doctype',
rows: 1,
},
});
Expand Down
Loading
Loading