UNPKG

auspice

Version:

Web app for visualizing pathogen evolution

225 lines (193 loc) 7.99 kB
/* 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 };