documentative
Version:
a tool for generating docs from markdown
596 lines (578 loc) • 18.3 kB
JavaScript
/*
* Documentative
* (c) 2020 dragonwocky <thedragonring.bod@gmail.com>
* (https://dragonwocky.me/) under the MIT license
*/
module.exports = { build, serve };
const path = require('path'),
fs = require('fs'),
fsp = fs.promises,
klaw = require('klaw'),
resourcepath = (file) => path.join(__dirname, 'resources', file),
http = require('http'),
mime = require('mime-types'),
less = require('less'),
pug = require('pug'),
marked = require('marked'),
hljs = require('highlight.js'),
htmlescape_chars = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
function htmlescape(text) {
return text.replace(htmlescape_chars, (ch) => htmlescape_chars[ch]);
}
const $ = {
defaults: {
title: 'documentative',
primary: '#712c9c',
git: '',
footer:
'© 2020 someone, under the [MIT license](https://choosealicense.com/licenses/mit/).',
card: {
description: '',
url: '',
},
exclude: [],
overwrite: false,
ignoredotfiles: true,
},
resources: new Map(),
languages: new Set(),
};
marked.use({
highlight: (code, lang) => {
lang = hljs.getLanguage(lang) ? lang : 'plaintext';
$.languages.add(lang);
return hljs.highlight(lang, code).value;
},
langPrefix: 'lang-',
gfm: true,
renderer: {
image(href, title, text) {
return `<img loading="lazy" src="${href}" alt="${text}" title="${
title || ''
}">`;
},
code(code, infostring, escaped) {
if (infostring === 'html //example') {
return `<div class="example">${code}</div>\n`;
} else {
const lang = (infostring || '').match(/\S*/)[0];
code = this.options.highlight(code, lang) || htmlescape(code);
return `<pre><code${
lang ? ` class="${this.options.langPrefix}${htmlescape(lang)}"` : ''
}>${code}</code></pre>\n`;
}
},
},
});
async function build(inputdir, outputdir, config = {}) {
if (!inputdir)
throw Error(`documentative<build>: failed, no input dir provided`);
if (!fs.lstatSync(inputdir).isDirectory())
throw Error(`documentative<build>: failed, input dir is not a directory`);
if (!outputdir)
throw Error(`documentative<build>: failed, no output dir provided`);
[inputdir, outputdir] = [
path.relative('.', inputdir) || '.',
path.relative('.', outputdir) || '.',
];
let icon, nav;
[config, icon, nav] = parseConfig(config);
let [pages, assets] = await filelist(
inputdir,
(file) =>
!config.exclude.some((exclude) =>
exclude.endsWith('/*')
? file.startsWith(exclude.slice(0, -1))
: file === exclude
) &&
// stop re-generation of files pre-existing in outputdir
(path.relative(inputdir, outputdir).startsWith('.') ||
!path.relative(inputdir, outputdir)
? true
: !file.startsWith(
outputdir.slice(
inputdir !== '.' ? inputdir.length + path.sep.length : 0
)
)) &&
// ignore dotfiles
(!config.ignoredotfiles ||
(!(file.startsWith('.') && !file.startsWith('./')) &&
!file.includes('/.')))
);
if (!path.relative(inputdir, outputdir)) assets = [];
nav = await Promise.all(
parseNav(inputdir, pages, nav).map((entry, i, nav) =>
entry.type === 'page' ? parsePage(inputdir, entry, nav) : entry
)
);
if (!fs.existsSync(outputdir))
await fsp.mkdir(outputdir, { recursive: true });
if (!fs.lstatSync(outputdir).isDirectory())
throw Error(`documentative<build>: failed, output dir is not a directory`);
if ((await filelist(outputdir)).flat().length && !config.overwrite)
throw Error(`documentative<build>: outputdir "${outputdir}" is not empty!
empty the directory and run again, or set the config.overwrite option to true`);
await Promise.all([
loadResources(),
...assets.map(async (asset) => {
await fsp.mkdir(
path.join(outputdir, ...asset.split(path.sep).slice(0, -1)),
{ recursive: true }
);
await fsp.writeFile(
path.join(outputdir, asset),
await fsp.readFile(path.join(inputdir, asset))
);
return true;
}),
]);
nav
.filter((entry) => entry.type === 'page')
.forEach(async (page) => {
await fsp.mkdir(
path.join(outputdir, ...page.output.split(path.sep).slice(0, -1)),
{ recursive: true }
);
await fsp.writeFile(
path.join(outputdir, page.output),
$.resources.get('template')({
_: {
...page,
output: page.output.split(path.sep).join('/'),
},
config,
nav,
icon,
}),
'utf8'
);
});
if (icon.light || icon.dark) {
if (icon.light && !assets.includes(path.relative(inputdir, icon.light)))
console.warn('documentative<config.icon>: light does not exist');
if (icon.dark && !assets.includes(path.relative(inputdir, icon.dark)))
console.warn('documentative<config.icon>: dark does not exist');
} else {
fsp.writeFile(
path.join(outputdir, 'light-docs.png'),
$.resources.get('icon.light')
);
fsp.writeFile(
path.join(outputdir, 'dark-docs.png'),
$.resources.get('icon.dark')
);
}
fsp.writeFile(
path.join(outputdir, 'docs.css'),
(
await less.render(
$.resources.get('css').replace(/__primary__/g, config.primary)
)
).css +
[...$.languages]
.map(
(lang) =>
`.documentative pre .lang-${lang}::before { content: '${lang.toUpperCase()}'; }`
)
.join('\n'),
'utf8'
);
fsp.writeFile(path.join(outputdir, 'docs.js'), $.resources.get('js'), 'utf8');
return true;
}
async function serve(inputdir, port, config = {}) {
if (!inputdir)
throw Error(`documentative<serve>: failed, no input dir provided`);
if (!fs.lstatSync(inputdir).isDirectory())
throw Error(`documentative<build>: failed, input dir is not a directory`);
if (typeof port !== 'number')
throw Error(`documentative<serve>: failed, port must be a number`);
inputdir = path.relative('.', inputdir);
let icon, confNav;
[config, icon, confNav] = parseConfig(config);
await loadResources();
return http
.createServer(async (req, res) => {
let [pages, assets] = await filelist(
inputdir,
(file) =>
!config.exclude.includes(file) &&
// ignore dotfiles
(!config.ignoredotfiles ||
(!(file.startsWith('.') && !file.startsWith('./')) &&
!file.includes('/.')))
);
nav = parseNav(inputdir, pages, confNav);
let content, type;
req.url = req.url.slice(1, req.url.endsWith('/') ? -1 : undefined);
switch (req.url) {
case 'docs.css':
content =
(
await less.render(
$.resources.get('css').replace(/__primary__/g, config.primary)
)
).css +
[...$.languages]
.map(
(lang) =>
`.documentative pre .lang-${lang}::before { content: '${lang.toUpperCase()}'; }`
)
.join('\n');
type = 'text/css';
break;
case 'docs.js':
content = $.resources.get('js');
type = 'text/javascript';
break;
default:
if (icon.light || icon.dark) {
if (
icon.light &&
!assets.includes(path.relative(inputdir, icon.light))
)
console.warn('documentative<config.icon>: light does not exist');
if (
icon.dark &&
!assets.includes(path.relative(inputdir, icon.dark))
)
console.warn('documentative<config.icon>: dark does not exist');
} else if (req.url === 'light-docs.png') {
content = $.resources.get('icon.light');
type = 'image/png';
break;
} else if (req.url === 'dark-docs.png') {
content = $.resources.get('icon.dark');
type = 'image/png';
break;
}
if (!req.url) req.url = 'index.html';
if (
nav.find(
(item) =>
item.type === 'page' &&
item.output.split(path.sep).slice(0, -1).join('/') === req.url
)
)
req.url += '/index.html';
const page = nav.find((item) => item.output === req.url);
if (page) {
nav = await Promise.all(
nav.map((entry, i, nav) =>
entry.type === 'page' ? parsePage(inputdir, entry, nav) : entry
)
);
content = $.resources.get('template')({
_: {
...page,
output: page.output.split(path.sep).join('/'),
},
config,
nav,
icon,
});
type = 'text/html';
} else if (assets.includes(req.url)) {
content = await fsp.readFile(path.join(inputdir, req.url));
type = mime.lookup(req.url);
} else {
res.statusCode = 404;
res.statusMessage = http.STATUS_CODES['404'];
res.end();
return false;
}
}
res.writeHead(200, { 'Content-Type': type });
res.write(content);
res.end();
})
.listen(port);
}
function parseConfig(obj = {}) {
if (typeof obj !== 'object')
throw Error(`documentative<config>: should be an object`);
const typechecked = validateObj(obj, $.defaults);
if (obj.icon) {
if (typeof obj.icon !== 'object')
throw Error(`documentative<config.icon>: should be an object`);
if (obj.icon.light && typeof obj.icon.light !== 'string')
throw Error(
`documentative<config.icon>: light should be of type string/filepath`
);
if (obj.icon.dark && typeof obj.icon.dark !== 'string')
throw Error(
`documentative<config.icon>: dark should be of type string/filepath`
);
} else obj.icon = {};
return [
{
...typechecked,
footer: typechecked.footer
? marked(typechecked.footer)
: typechecked.footer,
},
obj.icon,
obj.nav,
];
}
function validateObj(obj, against) {
return Object.fromEntries(
Object.entries(against).map((entry) => {
let [key, val] = [entry[0], obj[entry[0]]];
switch (true) {
case [val, against[key]].some((potential) =>
[null, undefined].includes(potential)
):
return [key, against[key]];
case typeof val !== typeof against[key]:
case Array.isArray(val) !== Array.isArray(against[key]):
throw Error(
`documentative<config>: ${key} should be of type ${
Array.isArray(against[key]) ? 'array' : typeof against[key]
}`
);
case typeof val === 'object':
if (typeof against[key] === 'object' && !Array.isArray(against[key]))
val = validateObj(val, against[key]);
default:
return [key, val];
}
})
);
}
async function filelist(dir, filter = () => true) {
let files = [];
for await (const item of klaw(dir))
if (!(item.path in files)) files.push(item.path);
// [pages, assets]
return files
.map((item) =>
path
.relative('.', item)
.slice(['', '.', './'].includes(dir) ? 0 : dir.length + path.sep.length)
)
.filter(
(item) =>
item &&
!item.split(path.sep).includes('node_modules') &&
!fs.lstatSync(path.join(dir, item)).isDirectory() &&
filter(item)
)
.sort()
.reduce(
(result, item) => {
result[item.endsWith('.md') ? 0 : 1].push(item);
return result;
},
[[], []]
);
}
async function loadResources() {
if (!$.resources.has('template'))
$.resources.set('template', pug.compileFile(resourcepath('template.pug')));
if (!$.resources.has('js'))
$.resources.set('js', await fsp.readFile(resourcepath('docs.js'), 'utf8'));
if (!$.resources.has('css'))
$.resources.set(
'css',
await fsp.readFile(resourcepath('docs.less'), 'utf8')
);
if (!$.resources.has('icon.light'))
$.resources.set(
'icon.light',
await fsp.readFile(resourcepath('light-docs.png'))
);
if (!$.resources.has('icon.dark'))
$.resources.set(
'icon.dark',
await fsp.readFile(resourcepath('dark-docs.png'))
);
return true;
}
function parseNav(inputdir, files, arr = []) {
if (!Array.isArray(arr))
throw Error(`documentative<config.nav>: should be an array`);
return (arr.length
? arr.map((entry) => {
switch (typeof entry) {
case 'string':
// "title"
return {
type: 'title',
text: entry,
};
case 'object':
if (Array.isArray(entry)) {
if (entry.length === 1) entry[1] = entry[0];
if (
files.includes(
path.relative(inputdir, path.join(inputdir, entry[1]))
)
)
// ["output", "src"]
return {
type: 'page',
output: entry[0].endsWith('.html')
? entry[0]
: entry[0] + '.html',
src: entry[1],
};
// ["text", "url"]
return {
type: 'link',
text: entry[0],
url: entry[1],
};
}
// {
// type: "page" || "link" || "title"
// (page) output: output filepath
// (page) src: source filepath
// (link, title) text: displayed text
// (link) url: url
// }
switch (entry.type) {
case 'page':
if (
typeof entry.output === 'string' &&
typeof entry.src === 'string' &&
files.includes(
path.relative(inputdir, path.join(inputdir, entry.src))
)
) {
if (!entry.output.endsWith('.html')) entry.output += '.html';
return entry;
}
case 'link':
if (
typeof entry.text === 'string' &&
typeof entry.url === 'string'
)
return entry;
case 'title':
if (typeof entry.text === 'string') return entry;
}
default:
throw Error(`documentative<config.nav>: invalid entry ${entry}`);
}
})
: Object.entries(
files.reduce((prev, val) => {
const dir = val.split(path.sep).slice(0, -1).join(path.sep);
if (!prev[dir]) prev[dir] = [];
prev[dir].push({
type: 'page',
output: val.slice(0, -3) + '.html',
src: val,
});
return prev;
}, {})
)
.map((entry) => {
const index =
entry[1].find((item) =>
item.src.toLowerCase().endsWith('index.md')
) ||
entry[1].find((item) =>
item.src.toLowerCase().endsWith('readme.md')
);
if (index) {
entry[1].splice(
entry[1].findIndex((item) => item.src === index.src),
1
);
entry[1].unshift({
type: 'page',
output: [
...index.src.split(path.sep).slice(0, -1),
'index.html',
].join(path.sep),
src: index.src,
});
}
if (entry[0]) entry[1].unshift({ type: 'title', text: entry[0] });
return entry[1];
})
.flat()
).map((entry, i, nav) => {
if (entry.type === 'page') {
entry.index = i;
entry.prev = i - 1;
while (nav[entry.prev] && nav[entry.prev].type !== 'page') entry.prev--;
entry.next = i + 1;
while (nav[entry.next] && nav[entry.next].type !== 'page') entry.next++;
entry.depth = '../'.repeat(entry.output.split(path.sep).length - 1);
}
return entry;
});
}
async function parsePage(inputdir, page, nav) {
const IDs = new marked.Slugger(),
tokens = marked.lexer(
await fsp.readFile(path.join(inputdir, page.src), 'utf8')
);
page.headings = [];
for (let token of tokens) {
switch (token.type) {
case 'heading':
const ID = IDs.slug(token.text.toLowerCase());
page.headings.push({
name: token.text,
level: token.depth,
hash: ID,
});
token.type = 'html';
token.text = `
</section>
<section class="block" id="${ID}">
<h${token.depth}>
<a href="#${ID}">${token.text}</a>
</h${token.depth}>
`;
break;
}
}
page.title = page.headings.shift() || page.output.slice(0, -5);
// map src -> output (links)
nav = Object.fromEntries(
nav
.filter((entry) => entry.type === 'page')
.map((entry) => [entry.src, entry.output])
);
marked.use({
renderer: {
link(href, title, text) {
href = href.split('#');
href = [href[0], href[1] || ''];
if (href[0].endsWith('.md')) {
const output =
nav[
path.join(
page.src.split(path.sep).slice(0, -1).join(path.sep),
href[0]
)
];
let depth = page.depth.split('/');
if (depth.length == 1 && depth[0] == '') depth = [];
if (output) href[0] = [...depth, output].join('/');
}
href = href.join('#');
if (!href) return text;
return `<a href="${htmlescape(href)}" title="${
title || ''
}">${text}</a>`;
},
},
});
page.content = `
<section class="block">
${marked.parser(tokens)}
</section>`.replace(/<section class="block">\s*<\/section>/g, '');
return page;
}