auspice
Version:
Web app for visualizing pathogen evolution
225 lines (193 loc) • 7.99 kB
JavaScript
/* eslint no-param-reassign: off */
const { safeLoadFront } = require('yaml-front-matter');
const queryString = require("query-string");
const parseMarkdownNarrativeFile = (fileContents, markdownParser) => {
const frontMatter = safeLoadFront(fileContents);
if (Object.keys(frontMatter).length === 1) {
const notMarkdownError = new Error();
notMarkdownError.fileContents = fileContents;
notMarkdownError.message = "YAML frontmatter missing or incomplete from narrative file";
throw notMarkdownError;
}
const titleSlide = createTitleSlideFromFrontmatter(frontMatter, markdownParser);
return [
titleSlide,
...parseNarrativeBody(frontMatter.__content, titleSlide.dataset, markdownParser)
];
};
function createTitleSlideFromFrontmatter(frontMatter, markdownParser) {
let markdown = ""; // A markdown interpretation of the YAML for display
markdown += parseTitleSlideTitle(frontMatter);
markdown += parseNarrativeAuthors(frontMatter);
markdown += parseNarrativeTranslators(frontMatter);
if (frontMatter.abstract && typeof frontMatter.abstract === "string") {
markdown += `### ${frontMatter.abstract}\n`;
}
if (frontMatter.date && typeof frontMatter.date === "string") {
markdown += `#### Created: ${frontMatter.date}\n`;
}
if (frontMatter.updated && typeof frontMatter.updated === "string") {
markdown += `#### Updated: ${frontMatter.updated}\n`;
}
const license = parseAttributions(frontMatter, "license", "licenseLink");
if (license) markdown += `#### License: ${license}\n`;
return {
...parseUrl(frontMatter.dataset),
__html: markdownParser(markdown)
};
}
function parseTitleSlideTitle(frontMatter) {
if (!frontMatter.title) throw new Error("Narrative YAML frontmatter must define a title!");
return `## ${frontMatter.title}\n`;
}
function parseUrl(urlString, fallbackDataset=false) {
let url, dataset;
try {
url = new URL(urlString);
} catch (err) {
if (fallbackDataset) {
console.error(`Couldn't parse the URL "${urlString}". Falling back to "${fallbackDataset}".`);
return {dataset: fallbackDataset, query: ""};
}
throw new Error("Narrative YAML frontmatter must define a valid dataset URL!");
}
if (url.pathname) {
dataset = url.pathname;
} else if (fallbackDataset) {
console.error(`The URL "${urlString}" didn't define a dataset! Falling back to "${fallbackDataset}".`);
dataset = fallbackDataset;
} else {
throw new Error("Narrative YAML frontmatter must define a valid dataset URL!");
}
const query = queryString.stringify(queryString.parse(url.search));
return {dataset, query};
}
function* parseNarrativeBody(markdown, fallbackDataset, markdownParser) {
const html = markdownParser(markdown);
const doc = (new DOMParser()).parseFromString(html, "text/html");
/* Each <h1> with only a link (in Markdown "# [text](href)") defines a new
* slide. Note this skips over and ignores anything before the first slide.
*
* I'd use "h1:has(> a:only-child)" if it was broadly supported, as this
* would select the <h1> instead of the inner <a> and make it possible to use
* node.matches(…) instead of node.querySelector().
* -trs, 21 Nov 2022
*/
const titleLinkSelector = "h1 > a:only-child";
const isTitle = (node) =>
node.nodeType === Node.ELEMENT_NODE &&
node.querySelector(titleLinkSelector) !== null;
for (const titleLink of doc.querySelectorAll(titleLinkSelector)) {
const slide = doc.createElement("slide");
const {dataset, query} = parseUrl(titleLink.href, fallbackDataset);
/* Turn <h1><a>some title</a></h1> into <h1>some title</h1>; we've
* extracted the link itself above with parseUrl().
*/
const h1 = titleLink.parentElement;
titleLink.replaceWith(titleLink.textContent);
/* Start collecting the slide's content into a <slide> container.
*
* First, turn <h1>some title</h1> into <slide><h1>some title</h1></slide>.
*/
h1.replaceWith(slide);
slide.appendChild(h1);
/* Then, walk forward moving nodes into this <slide> until we hit the next
* slide's title (or the end of the document).
*/
let sibling;
while ((sibling = slide.nextSibling)) {
if (isTitle(sibling)) break;
slide.appendChild(sibling);
}
/* Finally, extract the text of any auspiceMainDisplayMarkdown code blocks
* and remove them from the slide itself (for handling later).
*/
let mainDisplayMarkdown = "";
for (const code of slide.querySelectorAll("pre > code.language-auspiceMainDisplayMarkdown")) {
mainDisplayMarkdown += code.textContent + "\n";
code.parentElement.remove();
}
// We're done with this slide, emit it!
yield {
dataset,
query,
mainDisplayMarkdown,
__html: slide.innerHTML
};
}
}
function parseNarrativeAuthors(frontMatter) {
let authorMd = "";
const authors = parseAttributions(frontMatter, "authors", "authorLinks");
if (authors) {
authorMd += `### Author: ${authors}`;
if (frontMatter.affiliations && typeof frontMatter.affiliations === "string") {
authorMd += " <sup> 1 </sup>";
authorMd += `\n<sub><sup> 1 </sup> ${frontMatter.affiliations}</sub>`;
}
}
return authorMd+"\n";
}
function parseNarrativeTranslators(frontMatter) {
const translators = parseAttributions(frontMatter, "translators", "translatorLinks");
if (translators) return `### Translators: ${translators}\n`;
return "";
}
/**
* A helper function for parsing a key with (optional) links.
* The complexity here comes from the desire to be backward compatible with a
* number of prototype frontmatter schemas. See the tests for this function for
* more details, in lieu of some proper documentation.
*/
function parseAttributions(frontMatter, attributionsKey, attributionLinksKey) {
const attributions = frontMatter[attributionsKey];
const attributionLinks = frontMatter[attributionLinksKey];
if (Array.isArray(attributions)) {
return parseAttributionsArray(attributions, attributionLinks, attributionsKey, attributionLinksKey);
} else if (typeof attributions === 'string') {
return parseAttributionsString(attributions, attributionLinks, attributionsKey, attributionLinksKey);
}
return undefined;
}
function parseAttributionsArray(attributions, attributionLinks, attributionsKey, attributionLinksKey) {
// validate links
if (attributionLinks) {
if (!Array.isArray(attributionLinks)) {
console.warn(`Narrative parsing - if ${attributionsKey} is an array, then ${attributionLinksKey} must also be an array. Skipping links.`);
attributionLinks = undefined;
} else if (attributionLinks.length !== attributions.length) {
console.warn(`Narrative parsing - the length of ${attributionsKey} and ${attributionLinksKey} did not match. Skipping links.`);
attributionLinks = undefined;
}
}
if (attributionLinks) {
attributions = attributions.map((attribution, idx) => {
return attributionLink(attribution, attributionLinks[idx]);
});
}
return attributions.join(", ");
}
function parseAttributionsString(attributions, attributionLinks, attributionsKey, attributionLinksKey) {
// validate links
if (attributionLinks) {
if (typeof attributionLinks !== "string") {
console.warn(`Narrative parsing - if ${attributionsKey} is a string, then ${attributionLinksKey} must also be a string. Skipping links.`);
attributionLinks = undefined;
}
}
return attributionLink(attributions, attributionLinks);
}
function attributionLink(attribution, attributionLinkValue) {
if (attributionLinkValue) {
return `[${attribution}](${attributionLinkValue})`;
}
return attribution;
}
module.exports = {
parseMarkdownNarrativeFile,
/* following functions exported for unit testing */
createTitleSlideFromFrontmatter,
parseTitleSlideTitle,
parseNarrativeAuthors,
parseNarrativeTranslators
};