npm/docs/dockhand.js

334 lines
8.5 KiB
JavaScript

#!/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 {
const navPaths = await getNavigationPaths()
const fsPaths = await renderFilesystemPaths()
if (!ensureNavigationComplete(navPaths, fsPaths)) {
process.exit(1)
}
} catch (error) {
console.error(error)
}
}
run()
function ensureNavigationComplete (navPaths, fsPaths) {
const unmatchedNav = {}
const unmatchedFs = {}
for (const navPath of navPaths) {
unmatchedNav[navPath] = true
}
for (let fsPath of fsPaths) {
fsPath = '/' + fsPath.replace(/\.md$/, '')
if (unmatchedNav[fsPath]) {
delete unmatchedNav[fsPath]
} else {
unmatchedFs[fsPath] = true
}
}
const missingNav = Object.keys(unmatchedNav).sort()
const missingFs = Object.keys(unmatchedFs).sort()
if (missingNav.length > 0 || missingFs.length > 0) {
let message = 'Error: documentation navigation (nav.yml) does not match filesystem.\n'
if (missingNav.length > 0) {
message += '\nThe following path(s) exist on disk but are not present in nav.yml:\n\n'
for (const nav of missingNav) {
message += ` ${nav}\n`
}
}
if (missingNav.length > 0 && missingFs.length > 0) {
message += '\nThe following path(s) exist in nav.yml but are not present on disk:\n\n'
for (const fs of missingFs) {
message += ` ${fs}\n`
}
}
message += '\nUpdate nav.yml to ensure that all files are listed in the appropriate place.'
console.error(message)
return false
}
return true
}
function getNavigationPaths () {
const navFilename = path.join(docsRoot, 'nav.yml')
const nav = yaml.parse(fs.readFileSync(navFilename).toString(), 'utf8')
return walkNavigation(nav)
}
function walkNavigation (entries) {
const paths = []
for (const entry of entries) {
if (entry.children) {
paths.push(...walkNavigation(entry.children))
} else {
paths.push(entry.url)
}
}
return paths
}
async function renderFilesystemPaths () {
return await walkFilesystem(inputRoot)
}
async function walkFilesystem (root, dirRelative) {
const paths = []
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()) {
paths.push(...(await walkFilesystem(root, childRelative)))
} else {
await renderFile(childRelative)
paths.push(childRelative)
}
}
return paths
}
async function renderFile (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: false,
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 `<div id="_content">${content}</div>`
case 'path':
return childPath
case 'url_path':
return encodeURI(childPath)
case 'toc':
return '<div id="_table_of_contents"></div>'
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 ''
}
})
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 (const linktype of links) {
for (const 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 (const 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)
// 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 (const 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 (const 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
}
class MarkdownError extends Error {
constructor (file, inner) {
super(`failed to parse ${file}`)
this.file = file
this.inner = inner
}
}