@canalplus/readme.doc
Version:
Readme's an Extremely Accessible Documentation MakEr
250 lines (249 loc) • 9.62 kB
JavaScript
import * as fs from "fs";
import * as path from "path";
import { promisify } from "util";
import { load } from "cheerio";
import convertMDToHTML from "./convert_MD_to_HMTL.js";
import generatePageHtml from "./generate_page_html.js";
import getSearchDataForContent from "./get_search_data_for_content.js";
import log from "./log.js";
import { mkdirParent, toUriCompatibleRelativePath } from "./utils.js";
/**
* All characters stripped from an "anchor" in the form of a negated set
* ([^...]), meaning that everything that is listed here is what is authrized.
*
* Also note that spaces are replaced by dashes.
*
* We may want to authorize everything that can be added to an URL fragment,
* but we also want very simple rules predicitible for the user and mostly
* compatible to other markdown viewers (GitHub, GitLab...), so we
* basically only authorize lowercase alphanumeric characters, dashes and
* underscores.
*/
const BLACKLIST_ANCHOR = /[^a-z0-9_-]/g;
/**
* Create and write HTML page output file from the markdown input file.
* @param {Object} options
* @returns {Promise}
*/
export default async function createDocumentationPage({ baseOutDir, cssUrls, faviconUrl, inputFile, linkTranslator, navBarHtml, nextPageInfo, outputFile, pageListHtml, pageTitle, prevPageInfo, scriptUrls, searchIndex, sidebarHtml, }) {
const rootUrl = toUriCompatibleRelativePath(path.resolve(baseOutDir), path.dirname(outputFile));
const outputUrlFromRoot = toUriCompatibleRelativePath(outputFile, baseOutDir);
const outputDir = path.dirname(outputFile);
let data;
try {
data = await promisify(fs.readFile)(inputFile, "utf8");
}
catch (err) {
const errorStr = err instanceof Error ? String(err) : "Unknown Error";
log("WARNING", "error reading file: " + errorStr);
return { anchors: [] };
}
const inputDir = path.dirname(inputFile);
const { anchors, html: resHtml, tocMd, nbTocElements, } = await parseMD(data, inputDir, outputDir, baseOutDir, linkTranslator);
const searchData = getSearchDataForContent(resHtml, outputUrlFromRoot);
searchIndex.push({
file: outputUrlFromRoot,
index: searchData,
});
const contentHtml = resHtml + constructNextPreviousPage(prevPageInfo, nextPageInfo);
const tocHtml = nbTocElements > 1 ? constructTocBarHtml(tocMd) : "";
const html = generatePageHtml({
contentHtml,
cssUrls,
faviconUrl,
navBarHtml,
pageListHtml,
rootUrl,
scriptUrls,
sidebarHtml,
title: pageTitle,
tocHtml,
});
try {
await promisify(fs.writeFile)(outputFile, html);
}
catch (err) {
const errorStr = err instanceof Error ? String(err) : "Unknown Error";
log("WARNING", "Error writing file: " + errorStr);
return { anchors: [] };
}
return { anchors };
}
/**
* Check that a media asset referenced in a media Element is valid (e.g. it
* exists and is inside the root directory and copy it into the output directory
* if so.
* @param {Object} mediaTag
* @param {string} inputDir - The directory where the current input file where
* that media tag was found is.
* @param {string} outputDir - The directory where the wanted corresponding
* generated documentation file will be.
* @param {string} baseOutDir - The root output directory where all output files
* will be copied.
* @returns {Promise} - Promise which resolves on success and reject if things
* get wrong.
*/
async function checkAndCopyMediaAsset(mediaTag, inputDir, outputDir, baseOutDir) {
var _a, _b;
const src = mediaTag.attr("src");
if (src === null || src === undefined || src === "") {
return;
}
// TODO more protocols
if (/^https?:\/\//g.test(src)) {
return;
}
const inputFile = path.join(inputDir, src);
const outputFile = path.join(outputDir, src);
const outDir = path.dirname(outputFile);
const relativeDir = path.relative(baseOutDir, outDir);
const isSubdir = !relativeDir.startsWith("..");
if (!isSubdir) {
throw new Error("You're trying to copy a media asset outside of your root directory (" +
src +
"). This is for forbidden for now.");
}
// TODO check if already done in the current invokation
const doesOutDirExists = await promisify(fs.exists)(outDir);
if (!doesOutDirExists) {
try {
await mkdirParent(outDir);
}
catch (err) {
const srcMessage = (_b = ((_a = err) !== null && _a !== void 0 ? _a : {}).message) !== null && _b !== void 0 ? _b : "Unknown error";
throw new Error(`Could not create "${outDir}" directory: ${srcMessage}`);
}
}
await promisify(fs.copyFile)(inputFile, outputFile);
}
/**
* Construct the next and previous page nav Element.
* @param {Object|null} prevPageInfo
* @param {Object|null} nextPageInfo
* @returns {string}
*/
function constructNextPreviousPage(prevPageInfo, nextPageInfo) {
if (prevPageInfo === null && nextPageInfo === null) {
return "";
}
const prevPageElt = createNextPrevElt(prevPageInfo, false);
const nextPageElt = createNextPrevElt(nextPageInfo, true);
return (`<nav class="next-previous-page-wrapper" aria-label="Navigate between pages">` +
prevPageElt +
nextPageElt +
`</nav>`);
function createNextPrevElt(pageInfo, isNext) {
const base = `<div class="next-or-previous-page${isNext ? " next-page" : ""}">`;
if (pageInfo === null) {
return base + "</div>";
}
return (base +
`<a class="next-or-previous-page-link" href="${pageInfo.link}">` +
`<div class="next-or-previous-page-link-label">` +
(isNext ? "Next" : "Previous") +
"</div>" +
`<div class="next-or-previous-page-link-name">${pageInfo.name}</div>` +
"</a></div>");
}
}
/**
* Convert Markdown to HTML.
* @param {string} data - Markdown to convert
* @param {string} inputDir - Directory the Markdown file is in.
* Can be used to copy image/video/audio files.
* @param {string} outputDir - Directory the HTML file will be in.
* Can be used to copy image/video/audio files.
* @param {Function|null|undefined} linkTranslator - Allow to translate links
* from markdown to HTML. Is given the orginal link in the markdown and should
* return the converted link.
* If null or undefined, the links won't be converted.
* @returns {Promise.<string>}
*/
async function parseMD(data, inputDir, outputDir, baseOutDir, linkTranslator) {
const $ = load(convertMDToHTML(data));
const generatedAnchors = {};
const tocLines = [];
const anchors = [];
// Go through link translator for every links originally in the file
if (linkTranslator) {
$("a").each((_, elem) => {
const href = $(elem).attr("href");
if (typeof href === "string") {
$(elem).attr("href", linkTranslator(href));
}
});
}
// Copy linked image, audio and video assets into output directory
const imgTags = $("img").toArray();
for (let i = 0; i < imgTags.length; i++) {
await checkAndCopyMediaAsset($(imgTags[i]), inputDir, outputDir, baseOutDir);
}
const audioTags = $("audio").toArray();
for (let i = 0; i < audioTags.length; i++) {
await checkAndCopyMediaAsset($(audioTags[i]), inputDir, outputDir, baseOutDir);
}
const videoTags = $("video").toArray();
for (let i = 0; i < videoTags.length; i++) {
await checkAndCopyMediaAsset($(videoTags[i]), inputDir, outputDir, baseOutDir);
}
// Generate headers anchor links, just before headers are declared.
const hLinks = $("h1, h2, h3").toArray();
for (let i = 0; i < hLinks.length; i++) {
const linkElt = hLinks[i];
const linkText = $(linkElt).text();
const uri = generateAnchorName(linkText);
const tagName = hLinks[i].tagName.toLowerCase();
let prefix;
if (tagName === "h1") {
prefix = "";
}
else if (tagName === "h2") {
prefix = " - ";
}
else {
prefix = " - ";
}
anchors.push(uri);
tocLines.push(`${prefix}[${linkText}](#${uri})`);
$(`<a name="${uri}"></a>`).insertBefore(linkElt);
}
return {
html: $.html(),
tocMd: tocLines.join("\n"),
nbTocElements: tocLines.length,
anchors,
};
function generateAnchorName(title) {
const baseUri = encodeURI(title
.trim()
.toLowerCase()
.replace(/ /g, "-")
.replace(BLACKLIST_ANCHOR, ""));
if (generatedAnchors[baseUri] !== true) {
generatedAnchors[baseUri] = true;
return baseUri;
}
let i = 1;
let resultUri;
do {
resultUri = `${baseUri}_(${i})`;
i++;
} while (generatedAnchors[resultUri] === true);
generatedAnchors[resultUri] = true;
return resultUri;
}
}
/**
* Construct the table of contents part of the HTML page, containing various
* links to the current documentation page.
* @param {string} tocMd - Markdown for the table of contents under a list form.
* @returns {string} - sidebar div tag
*/
function constructTocBarHtml(tocMd) {
const tocHtml = convertMDToHTML(tocMd);
return ('<div class="tocbar-wrapper">' +
'<div class="tocbar">' +
tocHtml +
"</div>" +
"</div>");
}