UNPKG

documentative

Version:
526 lines (510 loc) 16.1 kB
/* * 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'); const $ = { defaults: { title: 'documentative', primary: '#712c9c', copyright: { text: '© 2020 dragonwocky, under the MIT license', url: 'https://dragonwocky.me/#mit' }, exclude: [], overwrite: false }, marked: { highlight: (code, lang) => { lang = hljs.getLanguage(lang) ? lang : 'plaintext'; $.languages.add(lang); return hljs.highlight(lang, code).value; }, langPrefix: 'lang-', gfm: true }, resources: new Map(), languages: new Set() }; 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.includes(file) && (path.relative(inputdir, outputdir).startsWith('.') || !path.relative(inputdir, outputdir) ? true : !file.startsWith(outputdir.slice(inputdir.length + path.sep.length))) ); 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); 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 populateDirs(outputdir, asset); 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 populateDirs(outputdir, page.output); await fsp.writeFile( path.join(outputdir, page.output), $.resources.get('template')({ _: page, config, nav, icon }), 'utf8' ); }); if ([null, undefined].includes(icon)) { if (!$.resources.has('icon')) $.resources.set( 'icon', await fsp.readFile(resourcepath('documentative.ico')) ); fsp.writeFile( path.join(outputdir, 'documentative.ico'), $.resources.get('icon') ); } else { if (typeof icon !== 'string') throw Error(`documentative<config.icon>: should be a string/filepath`); if (!assets.includes(path.relative(inputdir, icon))) console.warn('documentative<config.icon>: does not exist'); } fsp.writeFile( path.join(outputdir, 'styles.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, 'scrollnav.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); if (![null, undefined].includes(icon) && typeof icon !== 'string') throw Error(`documentative<config.icon>: should be a string/filepath`); await loadResources(); return http .createServer(async (req, res) => { let [pages, assets] = await filelist( inputdir, file => !config.exclude.includes(file) ); nav = parseNav(inputdir, pages, confNav); let content, type; switch (req.url) { case '/styles.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 '/scrollnav.js': content = $.resources.get('js'); type = 'text/javascript'; break; default: if (![null, undefined].includes(icon)) { if (!assets.includes(path.relative(inputdir, icon))) console.warn('documentative<config.icon>: does not exist'); } else if (req.url === '/documentative.ico') { if (!$.resources.has('icon')) $.resources.set( 'icon', await fsp.readFile(resourcepath('documentative.ico')) ); content = $.resources.get('icon'); type = 'image/x-icon'; break; } if (assets.includes(req.url.slice(1))) { content = await fs.readFile(path.join(inputdir, req.url.slice(1))); type = mime.lookup(req.url); } else { if (req.url.endsWith('/')) req.url = req.url.slice(0, -1); if ( !req.url || nav.find( item => item.type === 'page' && item.output .split(path.sep) .slice(0, -1) .join('/') === req.url.slice(1) ) ) req.url += '/index.html'; if (nav.find(item => item.output === req.url.slice(1))) { nav = await Promise.all( nav.map((entry, i, nav) => entry.type === 'page' ? parsePage(inputdir, entry, nav) : entry ) ); content = $.resources.get('template')({ _: nav.find(item => item.output === req.url.slice(1)), config, nav, icon }); type = 'text/html'; } 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`); return [validateObj(obj, $.defaults), 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(dir.length && dir !== '.' ? dir.length + 1 : 0) ) .filter( item => item && !item.split(path.sep).includes('node_modules') && !item.split(path.sep).includes('.git') ) .filter( item => !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 populateDirs(loc, from) { let dirs = from.split(path.sep); for (let i = 1; i < dirs.length; i++) { let dir = path.join(loc, dirs.slice(0, i).join(path.sep)); if (!fs.existsSync(dir)) await fsp.mkdir(dir); if (!fs.lstatSync(dir).isDirectory()) { await fsp.unlink(dir); await fsp.mkdir(dir); } } return true; } 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('scrollnav.js'), 'utf8') ); if (!$.resources.has('css')) $.resources.set( 'css', await fsp.readFile(resourcepath('styles.less'), 'utf8') ); 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"> <h${token.depth} id="${ID}"> <a href="#${ID}">${token.text}</a> </h${token.depth}> `; break; case 'code': if (token.lang === 'html //example') { token.type = 'html'; token.text = ` <div class="example"> ${token.text} </div> `; } 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]) ); const renderer = new marked.Renderer(); renderer.ordinaryLink = renderer.link; renderer.link = function(href, title, text) { if (href.endsWith('.md')) { const output = nav[ path.join( page.src .split(path.sep) .slice(0, -1) .join(path.sep), href ) ]; if (output) href = [...page.depth.split('/'), output].join('/'); } return this.ordinaryLink(href, title, text); }; page.content = ` <section class="block"> ${marked.parser(tokens, { ...$.marked, renderer })} </section>`.replace(/<section class="block">\s*<\/section>/g, ''); return page; }