jats-xml
Version:
Types and utilities for working with JATS in Typescript
478 lines (477 loc) • 20.5 kB
JavaScript
import { toText } from 'myst-common';
import { xml2js } from 'xml-js';
import { doi } from 'doi-utils';
import { validatePageFrontmatter } from 'myst-frontmatter';
import { select as unistSelect, selectAll } from 'unist-util-select';
import { Tags } from 'jats-tags';
import { findArticleId, processAffiliation, processContributor } from './utils.js';
import { tic } from 'myst-cli-utils';
import { recordJatsMessage } from './messages.js';
import { knownXmlDefectRepairMessage, repairKnownXmlDefects } from './repairKnownXmlDefects.js';
import { sanitizeXmlEntities } from './sanitizeXmlEntities.js';
import { articleMetaOrder, tableWrapOrder } from './order.js';
import { serializeJatsXml, convertToUnist, convertToXml, toDate, } from 'jats-utils';
function select(selector, node) {
var _a, _b;
try {
return ((_a = unistSelect(selector, node)) !== null && _a !== void 0 ? _a : undefined);
}
catch (error) {
const nodeType = (_b = node === null || node === void 0 ? void 0 : node.type) !== null && _b !== void 0 ? _b : '(undefined)';
const msg = error instanceof Error ? error.message : String(error);
throw new Error(`[jats-xml/select] selector="${selector}" nodeType="${nodeType}" failed: ${msg}`);
}
}
const DEFAULT_DOCTYPE = 'article PUBLIC "-//NLM//DTD JATS (Z39.96) Journal Archiving and Interchange DTD with MathML3 v1.3 20210610//EN" "http://jats.nlm.nih.gov/publishing/1.3/JATS-archivearticle1-3-mathml3.dtd"';
/**
* Drop comments and whitespace-only text nodes introduced by xml-js when
* `captureSpacesBetweenElements` is true (ignorable XML whitespace).
*/
function significantChildElements(elements) {
return elements === null || elements === void 0 ? void 0 : elements.filter((elem) => {
var _a;
if (elem.type === 'comment')
return false;
if (elem.type === 'text') {
const t = String((_a = elem.text) !== null && _a !== void 0 ? _a : '');
if (!t.trim())
return false;
}
return true;
});
}
/**
* Drop top-level processing instructions (e.g. xml-stylesheet) from the prolog.
* Malformed prologs such as <?version xml="1.0"?> are parsed this way by xml-js.
*/
function dropTopLevelInstructions(elements, onInstruction) {
return elements === null || elements === void 0 ? void 0 : elements.filter((elem) => {
if (elem.type === 'instruction') {
onInstruction === null || onInstruction === void 0 ? void 0 : onInstruction(elem);
return false;
}
return true;
});
}
export class Jats {
constructor(data, opts) {
var _a;
this.frontmatterMessagesRecorded = false;
const toc = tic();
this.log = opts === null || opts === void 0 ? void 0 : opts.log;
if (opts === null || opts === void 0 ? void 0 : opts.source)
this.source = opts.source;
this.vfile = opts === null || opts === void 0 ? void 0 : opts.vfile;
const warnProlog = (reason, note) => {
recordJatsMessage(this.vfile, reason, { note });
};
const { xml: afterDefectRepairs, applied: defectRepairs } = repairKnownXmlDefects(data);
defectRepairs.forEach(({ repair, count }) => {
const reason = knownXmlDefectRepairMessage(repair.from, repair.to);
const note = count === 1 ? undefined : `${count} occurrences`;
warnProlog(reason, note);
});
const { xml: parseInput, escapedBareAmpersandCount } = sanitizeXmlEntities(afterDefectRepairs);
if (escapedBareAmpersandCount > 0) {
const note = escapedBareAmpersandCount === 1
? '1 bare & rewritten to &'
: `${escapedBareAmpersandCount} bare & rewritten to &`;
warnProlog('Escaped bare ampersand(s) before XML parse', note);
}
try {
this.raw = xml2js(parseInput, {
compact: false,
// Preserve whitespace-only text nodes between elements. This is usually unnecessary except inside <preformat>.
// convertToUnist drops these outside preformat so other content is processed independent of arbitrary xml whitespace.
captureSpacesBetweenElements: true,
});
}
catch (error) {
throw new Error('Problem parsing the JATS document, please ensure it is XML');
}
const { declaration, elements } = this.raw;
this.declaration = declaration === null || declaration === void 0 ? void 0 : declaration.attributes;
const filteredElements = dropTopLevelInstructions(significantChildElements(elements), (instruction) => {
var _a, _b;
const name = (_a = instruction.name) !== null && _a !== void 0 ? _a : '(unnamed)';
const body = String((_b = instruction.instruction) !== null && _b !== void 0 ? _b : '').trim();
const note = body ? `name=${name} ${body}` : `name=${name}`;
warnProlog('Removed top-level XML processing instruction from prolog', note);
});
if ((filteredElements === null || filteredElements === void 0 ? void 0 : filteredElements.length) && filteredElements[0].type !== 'doctype') {
warnProlog('JATS is missing DOCTYPE declaration; inserted empty doctype');
filteredElements.unshift({ type: 'doctype' });
}
if (!((filteredElements === null || filteredElements === void 0 ? void 0 : filteredElements.length) === 2 &&
filteredElements[0].type === 'doctype' &&
hasSingleArticle(filteredElements[1]))) {
throw new Error('JATS must be structured as <!DOCTYPE><article>...</article>');
}
this.doctype = filteredElements[0].doctype;
if (filteredElements[1].name === 'pmc-articleset') {
warnProlog('JATS root is pmc-articleset wrapper', 'Using nested article element');
}
const converted = convertToUnist(filteredElements[1]);
this.tree = select('article', converted);
(_a = this.log) === null || _a === void 0 ? void 0 : _a.debug(toc('Parsed and converted JATS to unist tree in %s'));
}
get frontmatter() {
var _a, _b, _c, _d, _e, _f, _g, _h;
const title = this.articleTitle;
const subtitle = this.articleSubtitle;
const short_title = this.articleAltTitle;
const titleText = title ? toText(title).trim() : '';
const authors = ((_a = this.articleAuthors) !== null && _a !== void 0 ? _a : []).map((auth) => processContributor(auth));
const { date, datePick } = this.resolvePublicationDate();
this.recordFrontmatterMessages({
date,
datePick,
titleText,
authorCount: authors.length,
});
const affiliations = (_b = this.articleAffiliations) === null || _b === void 0 ? void 0 : _b.map((aff) => {
return processAffiliation(aff);
});
const keywords = (_d = (_c = this.keywords) === null || _c === void 0 ? void 0 : _c.map((k) => toText(k))) !== null && _d !== void 0 ? _d : [];
const subjectScope = (_e = this.articleCategories) !== null && _e !== void 0 ? _e : this.front;
const journalCollSubject = select(`${Tags.subjGroup}[subj-group-type="hwp-journal-coll"] ${Tags.subject}`, subjectScope);
const articleSubject = journalCollSubject !== null && journalCollSubject !== void 0 ? journalCollSubject : select(Tags.subject, subjectScope);
const journalTitle = select(Tags.journalTitle, this.front);
const license = this.license;
let licenseString = null;
if (license === null || license === void 0 ? void 0 : license['xlink:href']) {
licenseString = license['xlink:href'];
}
else if (license && select('[type=ali\\:license_ref]', license)) {
licenseString = toText(select('[type=ali\\:license_ref]', license));
}
else if (selectAll('ext-link', license).length === 1) {
// this should only happen if there is only one ext-link
licenseString = (_f = select('ext-link', license)['xlink:href']) !== null && _f !== void 0 ? _f : null;
}
else if (license) {
licenseString = toText(license);
}
let openAccess;
const licenseType = (_g = license === null || license === void 0 ? void 0 : license['license-type']) === null || _g === void 0 ? void 0 : _g.toLowerCase();
if (licenseType && ['openaccess', 'open-access'].includes(licenseType)) {
openAccess = true;
}
else if (licenseString === null || licenseString === void 0 ? void 0 : licenseString.match(/^\s*Open Access\s*This/)) {
licenseString = licenseString.replace(/^\s*Open Access\s*/, '');
openAccess = true;
}
else if (licenseString === null || licenseString === void 0 ? void 0 : licenseString.toLowerCase().startsWith('this is an open access article')) {
openAccess = true;
}
const pmc = this.pmc;
const identifiers = pmc ? { pmcid: `PMC${pmc}` } : undefined;
const frontmatter = validatePageFrontmatter({
title: titleText || undefined,
subtitle: subtitle ? toText(subtitle) : undefined,
short_title: short_title ? toText(short_title) : undefined,
doi: (_h = this.doi) !== null && _h !== void 0 ? _h : undefined,
identifiers,
date,
authors: authors.length ? authors : undefined,
// editors,
affiliations: affiliations.length ? affiliations : undefined,
keywords: keywords.length ? keywords : undefined,
venue: journalTitle ? { title: toText(journalTitle) } : undefined,
subject: articleSubject ? toText(articleSubject) : undefined,
license: licenseString !== null && licenseString !== void 0 ? licenseString : undefined,
open_access: openAccess,
}, { property: 'frontmatter', messages: {} });
return frontmatter;
}
get front() {
return select(Tags.front, this.tree);
}
get articleMeta() {
return select(Tags.articleMeta, this.tree);
}
get permissions() {
return select(Tags.permissions, this.front);
}
get doi() {
var _a;
return doi.normalize((_a = findArticleId(this.front, 'doi')) !== null && _a !== void 0 ? _a : '');
}
get pmc() {
var _a;
return (_a = findArticleId(this.front, 'pmc')) === null || _a === void 0 ? void 0 : _a.replace(/^PMC:?/, '');
}
get pmid() {
return findArticleId(this.front, 'pmid');
}
get publicationDates() {
return selectAll(Tags.pubDate, this.front);
}
get publicationDate() {
return this.publicationDates.find((d) => !!select(Tags.day, d));
}
/**
* JATS `<history>` can include multiple `<date date-type="...">` nodes,
* often including an "accepted" date that is a better default for project/page `date`.
*/
get historyDates() {
var _a;
return selectAll('history date', (_a = this.articleMeta) !== null && _a !== void 0 ? _a : this.front);
}
/**
* Prefer a fully-specified publication date (day/month/year).
* If publication date is incomplete (e.g. year-only), fall back through history dates.
*/
resolvePublicationDate() {
const datePick = this.pickPublicationDate();
let date;
if (datePick) {
const d = toDate(datePick.node);
if (d) {
const year = d.getUTCFullYear();
const month = (d.getUTCMonth() + 1).toString().padStart(2, '0');
const day = d.getUTCDate().toString().padStart(2, '0');
date = `${year}-${month}-${day}`;
}
}
return { date, datePick };
}
recordFrontmatterMessages(input) {
var _a;
if (this.frontmatterMessagesRecorded)
return;
this.frontmatterMessagesRecorded = true;
if ((_a = input.datePick) === null || _a === void 0 ? void 0 : _a.warn) {
recordJatsMessage(this.vfile, input.datePick.warn, { note: input.datePick.note });
}
if (!input.date) {
recordJatsMessage(this.vfile, 'No publication date found in JATS', {
note: 'article-meta/pub-date or history/pub-history dates',
});
}
if (!input.titleText) {
recordJatsMessage(this.vfile, 'No article title found in JATS', {
note: 'article-meta/title-group/article-title',
});
}
if (input.authorCount === 0) {
recordJatsMessage(this.vfile, 'No authors found in JATS', {
note: 'article-meta/contrib-group/contrib[@contrib-type="author"]',
});
}
}
pickPublicationDate() {
if (this.publicationDate) {
return { node: this.publicationDate };
}
if (this.pubHistoryPubDate) {
return { node: this.pubHistoryPubDate };
}
if (this.pubHistoryAcceptedDate) {
return { node: this.pubHistoryAcceptedDate };
}
if (this.historyAcceptedDate) {
return { node: this.historyAcceptedDate };
}
if (this.pubHistoryFallbackDate) {
return this.fallbackDatePick(this.pubHistoryFallbackDate, 'pub-history');
}
if (this.historyFallbackDate) {
return this.fallbackDatePick(this.historyFallbackDate, 'history');
}
return undefined;
}
jatsDateType(node) {
var _a;
return String((_a = node['date-type']) !== null && _a !== void 0 ? _a : '')
.trim()
.toLowerCase();
}
dateHasDay(node) {
return !!select(Tags.day, node);
}
/** Last `<date>` with a `<day>` whose `date-type` is not in `excludeTypes`. */
lastDateWithDay(dates, excludeTypes) {
const eligible = dates.filter((d) => {
if (!this.dateHasDay(d))
return false;
return !excludeTypes.has(this.jatsDateType(d));
});
return eligible.at(-1);
}
fallbackDatePick(node, source) {
const dateType = this.jatsDateType(node) || 'unknown';
const xpath = source === 'pub-history'
? `article-meta/pub-history/event/date[-type="${dateType}"]`
: `article-meta/history/date[-type="${dateType}"]`;
return {
node,
warn: `Using JATS ${source} date as publication date`,
note: xpath,
};
}
findHistoryDateByType(types) {
const wanted = new Set(types.map((t) => t.toLowerCase()));
const found = this.historyDates.find((d) => {
return wanted.has(this.jatsDateType(d));
});
if (found && this.dateHasDay(found))
return found;
return undefined;
}
get historyAcceptedDate() {
return this.findHistoryDateByType(['accepted', 'accept']);
}
get pubHistoryFallbackDate() {
return this.lastDateWithDay(this.pubHistoryDates, Jats.PUB_HISTORY_PRIMARY_DATE_TYPES);
}
get historyFallbackDate() {
return this.lastDateWithDay(this.historyDates, Jats.HISTORY_PRIMARY_DATE_TYPES);
}
/**
* Some sources store important dates under:
* `<article-meta><pub-history><event><date date-type="...">...</date></event></pub-history>`.
*/
get pubHistoryDates() {
var _a;
return selectAll('pub-history event date', (_a = this.articleMeta) !== null && _a !== void 0 ? _a : this.front);
}
findPubHistoryDateByType(types) {
const wanted = new Set(types.map((t) => t.toLowerCase()));
const found = this.pubHistoryDates.find((d) => wanted.has(this.jatsDateType(d)));
if (found && this.dateHasDay(found))
return found;
return undefined;
}
get pubHistoryPubDate() {
return this.findPubHistoryDateByType(['pub', 'published', 'publication']);
}
get pubHistoryAcceptedDate() {
return this.findPubHistoryDateByType(['accepted', 'accept']);
}
get license() {
return select(Tags.license, this.permissions);
}
get keywordGroup() {
return select(Tags.kwdGroup, this.front);
}
/** The first keywords */
get keywords() {
return selectAll(Tags.kwd, this.keywordGroup);
}
get keywordGroups() {
return selectAll(Tags.kwdGroup, this.front);
}
get articleCategories() {
return select(Tags.articleCategories, this.front);
}
get titleGroup() {
return select(Tags.titleGroup, this.front);
}
get articleTitle() {
return select(Tags.articleTitle, this.titleGroup);
}
get articleSubtitle() {
return select(Tags.subtitle, this.titleGroup);
}
get articleAltTitle() {
return select(Tags.altTitle, this.titleGroup);
}
get abstract() {
return select(Tags.abstract, this.front);
}
get abstracts() {
return selectAll(Tags.abstract, this.front);
}
get contribGroup() {
return select(Tags.contribGroup, this.front);
}
get contribGroups() {
return selectAll(Tags.contribGroup, this.front);
}
get articleAuthors() {
const contribs = selectAll(Tags.contrib, {
type: 'contribGroups',
children: this.contribGroups,
});
const authors = contribs.filter((contrib) => {
const contribType = contrib['contrib-type'];
return !contribType || contribType === 'author';
});
return authors;
}
get articleAffiliations() {
return selectAll(`${Tags.aff}[id]`, this.front);
}
get body() {
return select(Tags.body, this.tree);
}
get back() {
return select(Tags.back, this.tree);
}
get subArticles() {
return selectAll(Tags.subArticle, this.tree);
}
/** First `ref-list` in back matter. */
get refList() {
return this.refLists[0];
}
/** All `ref-list` elements under `back`, in document order. */
get refLists() {
if (!this.back)
return [];
return selectAll(Tags.refList, this.back);
}
/** Every `ref` from every `ref-list` under `back`, in document order. */
get references() {
return this.refLists.flatMap((list) => selectAll(Tags.ref, list));
}
sort() {
var _a;
if (this.articleMeta) {
this.articleMeta.children = (_a = this.articleMeta) === null || _a === void 0 ? void 0 : _a.children.sort((a, b) => articleMetaOrder.findIndex((x) => x === a.type) -
articleMetaOrder.findIndex((x) => x === b.type));
}
selectAll('table-wrap', this.tree).forEach((tw) => {
tw.children = tw.children.sort((a, b) => { var _a, _b; return ((_a = tableWrapOrder[a.type]) !== null && _a !== void 0 ? _a : -1) - ((_b = tableWrapOrder[b.type]) !== null && _b !== void 0 ? _b : -1); });
});
}
serialize(opts) {
var _a;
this.sort();
const body = convertToXml(this.tree);
const element = (opts === null || opts === void 0 ? void 0 : opts.bodyOnly)
? body
: {
type: 'element',
elements: [
{
type: 'doctype',
doctype: this.doctype || DEFAULT_DOCTYPE,
},
body,
],
declaration: { attributes: (_a = this.declaration) !== null && _a !== void 0 ? _a : { version: '1.0', encoding: 'UTF-8' } },
};
const xml = serializeJatsXml(element, opts);
return xml;
}
}
Jats.PUB_HISTORY_PRIMARY_DATE_TYPES = new Set([
'pub',
'published',
'publication',
'accepted',
'accept',
]);
Jats.HISTORY_PRIMARY_DATE_TYPES = new Set(['accepted', 'accept']);
function hasSingleArticle(element) {
if (element.name === 'article') {
return true;
}
if (element.name === 'pmc-articleset') {
const children = significantChildElements(element.elements);
return (children === null || children === void 0 ? void 0 : children.length) === 1 && children[0].name === 'article';
}
return false;
}