#!/usr/bin/env node const path = require('path'); const fs = require('fs'); const yaml = require('yaml'); const cmark = require('cmark-gfm'); const mdx = require('@mdx-js/mdx'); const mkdirp = require('mkdirp'); const jsdom = require('jsdom'); const npm = require('../lib/npm.js') const config = require('./config.json'); const docsRoot = __dirname; const inputRoot = path.join(docsRoot, 'content'); const outputRoot = path.join(docsRoot, 'output'); const template = fs.readFileSync('template.html').toString(); const run = async function() { try { await walk(inputRoot); } catch (error) { console.error(error); } } run(); async function walk(root, dirRelative) { const dirPath = dirRelative ? path.join(root, dirRelative) : root; const children = fs.readdirSync(dirPath); for (const childFilename of children) { const childRelative = dirRelative ? path.join(dirRelative, childFilename) : childFilename; const childPath = path.join(root, childRelative); if (fs.lstatSync(childPath).isDirectory()) { await walk(root, childRelative); } else { await translate(childRelative); } } } async function translate(childPath) { const inputPath = path.join(inputRoot, childPath); if (!inputPath.match(/\.md$/)) { console.log(`warning: unknown file type ${inputPath}, ignored`); return; } const outputPath = path.join(outputRoot, childPath.replace(/\.md$/, '.html')); let md = fs.readFileSync(inputPath).toString(); let frontmatter = { }; // Take the leading frontmatter out of the markdown md = md.replace(/^---\n([\s\S]+)\n---\n/, (header, fm) => { frontmatter = yaml.parse(fm, 'utf8'); return ''; }); // Replace any tokens in the source md = md.replace(/@VERSION@/, npm.version); // Render the markdown into an HTML snippet using a GFM renderer. const content = cmark.renderHtmlSync(md, { 'smart': true, 'githubPreLang': true, 'strikethroughDoubleTilde': true, 'unsafe': false, extensions: { 'table': true, 'strikethrough': true, 'tagfilter': true, 'autolink': true } }); // Test that mdx can parse this markdown file. We don't actually // use the output, it's just to ensure that the upstream docs // site (docs.npmjs.com) can parse it when this file gets there. try { await mdx(md, { skipExport: true }); } catch (error) { throw new MarkdownError(childPath, error); } // Inject this data into the template, using a mustache-like // replacement scheme. const html = template.replace(/\{\{\s*([\w\.]+)\s*\}\}/g, (token, key) => { switch (key) { case 'content': return `
${content}
`; case 'path': return childPath; case 'url_path': return encodeURI(childPath); case 'toc': return '
'; case 'title': case 'section': case 'description': return frontmatter[key]; case 'config.github_repo': case 'config.github_branch': case 'config.github_path': return config[key.replace(/^config\./, '')]; default: console.log(`warning: unknown token '${token}' in ${inputPath}`); return ''; } console.log(key); return key; }); const dom = new jsdom.JSDOM(html); const document = dom.window.document; // Rewrite relative URLs in links and image sources to be relative to // this file; this is for supporting `file://` links. HTML pages need // suffix appended. const links = [ { tag: 'a', attr: 'href', suffix: '.html' }, { tag: 'img', attr: 'src' } ]; for (let linktype of links) { for (let tag of document.querySelectorAll(linktype.tag)) { let url = tag.getAttribute(linktype.attr); if (url.startsWith('/')) { const childDepth = childPath.split('/').length - 1; const prefix = childDepth > 0 ? '../'.repeat(childDepth) : './'; url = url.replace(/^\//, prefix); if (linktype.suffix) { url += linktype.suffix; } tag.setAttribute(linktype.attr, url); } } } // Give headers a unique id so that they can be linked within the doc const headerIds = [ ]; for (let header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { if (header.getAttribute('id')) { headerIds.push(header.getAttribute('id')); continue; } const headerText = header.textContent.replace(/[A-Z]/g, x => x.toLowerCase()).replace(/ /g, '-').replace(/[^a-z0-9\-]/g, ''); let headerId = headerText; let headerIncrement = 1; while (document.getElementById(headerId) !== null) { headerId = headerText + (++headerIncrement); } headerIds.push(headerId); header.setAttribute('id', headerId); } // Walk the dom and build a table of contents const toc = document.getElementById('_table_of_contents'); if (toc) { toc.appendChild(generateTableOfContents(document)); } // Write the final output const output = dom.serialize(); mkdirp.sync(path.dirname(outputPath)); fs.writeFileSync(outputPath, output); } function generateTableOfContents(document) { const headers = [ ]; walkHeaders(document.getElementById('_content'), headers); let parent = null; // The nesting depth of headers are not necessarily the header level. // (eg, h1 > h3 > h5 is a depth of three even though there's an h5.) const hierarchy = [ ]; for (let header of headers) { const level = headerLevel(header); while (hierarchy.length && hierarchy[hierarchy.length - 1].headerLevel > level) { hierarchy.pop(); } if (!hierarchy.length || hierarchy[hierarchy.length - 1].headerLevel < level) { const newList = document.createElement('ul'); newList.headerLevel = level; if (hierarchy.length) { hierarchy[hierarchy.length - 1].appendChild(newList); } hierarchy.push(newList); } const element = document.createElement('li'); const link = document.createElement('a'); link.setAttribute('href', `#${header.getAttribute('id')}`); link.innerHTML = header.innerHTML; element.appendChild(link); const list = hierarchy[hierarchy.length - 1]; list.appendChild(element); } return hierarchy[0]; } function walkHeaders(element, headers) { for (let child of element.childNodes) { if (headerLevel(child)) { headers.push(child); } walkHeaders(child, headers); } } function headerLevel(node) { const level = node.tagName ? node.tagName.match(/^[Hh]([123456])$/) : null; return level ? level[1] : 0; } function debug(str) { console.log(str); } class MarkdownError extends Error { constructor(file, inner) { super(`failed to parse ${file}`); this.file = file; this.inner = inner; } }