UNPKG

18.3 kBJavaScriptView Raw
1/*
2 * Documentative
3 * (c) 2020 dragonwocky <thedragonring.bod@gmail.com>
4 * (https://dragonwocky.me/) under the MIT license
5 */
6
7module.exports = { build, serve };
8
9const path = require('path'),
10 fs = require('fs'),
11 fsp = fs.promises,
12 klaw = require('klaw'),
13 resourcepath = (file) => path.join(__dirname, 'resources', file),
14 http = require('http'),
15 mime = require('mime-types'),
16 less = require('less'),
17 pug = require('pug'),
18 marked = require('marked'),
19 hljs = require('highlight.js'),
20 htmlescape_chars = {
21 '&': '&amp;',
22 '<': '&lt;',
23 '>': '&gt;',
24 '"': '&quot;',
25 "'": '&#39;',
26 };
27
28function htmlescape(text) {
29 return text.replace(htmlescape_chars, (ch) => htmlescape_chars[ch]);
30}
31
32const $ = {
33 defaults: {
34 title: 'documentative',
35 primary: '#712c9c',
36 git: '',
37 footer:
38 '© 2020 someone, under the [MIT license](https://choosealicense.com/licenses/mit/).',
39 card: {
40 description: '',
41 url: '',
42 },
43 exclude: [],
44 overwrite: false,
45 ignoredotfiles: true,
46 },
47 resources: new Map(),
48 languages: new Set(),
49};
50marked.use({
51 highlight: (code, lang) => {
52 lang = hljs.getLanguage(lang) ? lang : 'plaintext';
53 $.languages.add(lang);
54 return hljs.highlight(lang, code).value;
55 },
56 langPrefix: 'lang-',
57 gfm: true,
58 renderer: {
59 image(href, title, text) {
60 return `<img loading="lazy" src="${href}" alt="${text}" title="${
61 title || ''
62 }">`;
63 },
64 code(code, infostring, escaped) {
65 if (infostring === 'html //example') {
66 return `<div class="example">${code}</div>\n`;
67 } else {
68 const lang = (infostring || '').match(/\S*/)[0];
69 code = this.options.highlight(code, lang) || htmlescape(code);
70 return `<pre><code${
71 lang ? ` class="${this.options.langPrefix}${htmlescape(lang)}"` : ''
72 }>${code}</code></pre>\n`;
73 }
74 },
75 },
76});
77
78async function build(inputdir, outputdir, config = {}) {
79 if (!inputdir)
80 throw Error(`documentative<build>: failed, no input dir provided`);
81 if (!fs.lstatSync(inputdir).isDirectory())
82 throw Error(`documentative<build>: failed, input dir is not a directory`);
83 if (!outputdir)
84 throw Error(`documentative<build>: failed, no output dir provided`);
85 [inputdir, outputdir] = [
86 path.relative('.', inputdir) || '.',
87 path.relative('.', outputdir) || '.',
88 ];
89
90 let icon, nav;
91 [config, icon, nav] = parseConfig(config);
92 let [pages, assets] = await filelist(
93 inputdir,
94 (file) =>
95 !config.exclude.some((exclude) =>
96 exclude.endsWith('/*')
97 ? file.startsWith(exclude.slice(0, -1))
98 : file === exclude
99 ) &&
100 // stop re-generation of files pre-existing in outputdir
101 (path.relative(inputdir, outputdir).startsWith('.') ||
102 !path.relative(inputdir, outputdir)
103 ? true
104 : !file.startsWith(
105 outputdir.slice(
106 inputdir !== '.' ? inputdir.length + path.sep.length : 0
107 )
108 )) &&
109 // ignore dotfiles
110 (!config.ignoredotfiles ||
111 (!(file.startsWith('.') && !file.startsWith('./')) &&
112 !file.includes('/.')))
113 );
114 if (!path.relative(inputdir, outputdir)) assets = [];
115 nav = await Promise.all(
116 parseNav(inputdir, pages, nav).map((entry, i, nav) =>
117 entry.type === 'page' ? parsePage(inputdir, entry, nav) : entry
118 )
119 );
120
121 if (!fs.existsSync(outputdir))
122 await fsp.mkdir(outputdir, { recursive: true });
123 if (!fs.lstatSync(outputdir).isDirectory())
124 throw Error(`documentative<build>: failed, output dir is not a directory`);
125 if ((await filelist(outputdir)).flat().length && !config.overwrite)
126 throw Error(`documentative<build>: outputdir "${outputdir}" is not empty!
127 empty the directory and run again, or set the config.overwrite option to true`);
128
129 await Promise.all([
130 loadResources(),
131 ...assets.map(async (asset) => {
132 await fsp.mkdir(
133 path.join(outputdir, ...asset.split(path.sep).slice(0, -1)),
134 { recursive: true }
135 );
136 await fsp.writeFile(
137 path.join(outputdir, asset),
138 await fsp.readFile(path.join(inputdir, asset))
139 );
140 return true;
141 }),
142 ]);
143 nav
144 .filter((entry) => entry.type === 'page')
145 .forEach(async (page) => {
146 await fsp.mkdir(
147 path.join(outputdir, ...page.output.split(path.sep).slice(0, -1)),
148 { recursive: true }
149 );
150 await fsp.writeFile(
151 path.join(outputdir, page.output),
152 $.resources.get('template')({
153 _: {
154 ...page,
155 output: page.output.split(path.sep).join('/'),
156 },
157 config,
158 nav,
159 icon,
160 }),
161 'utf8'
162 );
163 });
164
165 if (icon.light || icon.dark) {
166 if (icon.light && !assets.includes(path.relative(inputdir, icon.light)))
167 console.warn('documentative<config.icon>: light does not exist');
168 if (icon.dark && !assets.includes(path.relative(inputdir, icon.dark)))
169 console.warn('documentative<config.icon>: dark does not exist');
170 } else {
171 fsp.writeFile(
172 path.join(outputdir, 'light-docs.png'),
173 $.resources.get('icon.light')
174 );
175 fsp.writeFile(
176 path.join(outputdir, 'dark-docs.png'),
177 $.resources.get('icon.dark')
178 );
179 }
180 fsp.writeFile(
181 path.join(outputdir, 'docs.css'),
182 (
183 await less.render(
184 $.resources.get('css').replace(/__primary__/g, config.primary)
185 )
186 ).css +
187 [...$.languages]
188 .map(
189 (lang) =>
190 `.documentative pre .lang-${lang}::before { content: '${lang.toUpperCase()}'; }`
191 )
192 .join('\n'),
193 'utf8'
194 );
195 fsp.writeFile(path.join(outputdir, 'docs.js'), $.resources.get('js'), 'utf8');
196 return true;
197}
198
199async function serve(inputdir, port, config = {}) {
200 if (!inputdir)
201 throw Error(`documentative<serve>: failed, no input dir provided`);
202 if (!fs.lstatSync(inputdir).isDirectory())
203 throw Error(`documentative<build>: failed, input dir is not a directory`);
204 if (typeof port !== 'number')
205 throw Error(`documentative<serve>: failed, port must be a number`);
206 inputdir = path.relative('.', inputdir);
207 let icon, confNav;
208 [config, icon, confNav] = parseConfig(config);
209 await loadResources();
210
211 return http
212 .createServer(async (req, res) => {
213 let [pages, assets] = await filelist(
214 inputdir,
215 (file) =>
216 !config.exclude.includes(file) &&
217 // ignore dotfiles
218 (!config.ignoredotfiles ||
219 (!(file.startsWith('.') && !file.startsWith('./')) &&
220 !file.includes('/.')))
221 );
222 nav = parseNav(inputdir, pages, confNav);
223 let content, type;
224 req.url = req.url.slice(1, req.url.endsWith('/') ? -1 : undefined);
225 switch (req.url) {
226 case 'docs.css':
227 content =
228 (
229 await less.render(
230 $.resources.get('css').replace(/__primary__/g, config.primary)
231 )
232 ).css +
233 [...$.languages]
234 .map(
235 (lang) =>
236 `.documentative pre .lang-${lang}::before { content: '${lang.toUpperCase()}'; }`
237 )
238 .join('\n');
239 type = 'text/css';
240 break;
241 case 'docs.js':
242 content = $.resources.get('js');
243 type = 'text/javascript';
244 break;
245 default:
246 if (icon.light || icon.dark) {
247 if (
248 icon.light &&
249 !assets.includes(path.relative(inputdir, icon.light))
250 )
251 console.warn('documentative<config.icon>: light does not exist');
252 if (
253 icon.dark &&
254 !assets.includes(path.relative(inputdir, icon.dark))
255 )
256 console.warn('documentative<config.icon>: dark does not exist');
257 } else if (req.url === 'light-docs.png') {
258 content = $.resources.get('icon.light');
259 type = 'image/png';
260 break;
261 } else if (req.url === 'dark-docs.png') {
262 content = $.resources.get('icon.dark');
263 type = 'image/png';
264 break;
265 }
266
267 if (!req.url) req.url = 'index.html';
268 if (
269 nav.find(
270 (item) =>
271 item.type === 'page' &&
272 item.output.split(path.sep).slice(0, -1).join('/') === req.url
273 )
274 )
275 req.url += '/index.html';
276 const page = nav.find((item) => item.output === req.url);
277 if (page) {
278 nav = await Promise.all(
279 nav.map((entry, i, nav) =>
280 entry.type === 'page' ? parsePage(inputdir, entry, nav) : entry
281 )
282 );
283 content = $.resources.get('template')({
284 _: {
285 ...page,
286 output: page.output.split(path.sep).join('/'),
287 },
288 config,
289 nav,
290 icon,
291 });
292 type = 'text/html';
293 } else if (assets.includes(req.url)) {
294 content = await fsp.readFile(path.join(inputdir, req.url));
295 type = mime.lookup(req.url);
296 } else {
297 res.statusCode = 404;
298 res.statusMessage = http.STATUS_CODES['404'];
299 res.end();
300 return false;
301 }
302 }
303 res.writeHead(200, { 'Content-Type': type });
304 res.write(content);
305 res.end();
306 })
307 .listen(port);
308}
309
310function parseConfig(obj = {}) {
311 if (typeof obj !== 'object')
312 throw Error(`documentative<config>: should be an object`);
313 const typechecked = validateObj(obj, $.defaults);
314 if (obj.icon) {
315 if (typeof obj.icon !== 'object')
316 throw Error(`documentative<config.icon>: should be an object`);
317 if (obj.icon.light && typeof obj.icon.light !== 'string')
318 throw Error(
319 `documentative<config.icon>: light should be of type string/filepath`
320 );
321 if (obj.icon.dark && typeof obj.icon.dark !== 'string')
322 throw Error(
323 `documentative<config.icon>: dark should be of type string/filepath`
324 );
325 } else obj.icon = {};
326 return [
327 {
328 ...typechecked,
329 footer: typechecked.footer
330 ? marked(typechecked.footer)
331 : typechecked.footer,
332 },
333 obj.icon,
334 obj.nav,
335 ];
336}
337function validateObj(obj, against) {
338 return Object.fromEntries(
339 Object.entries(against).map((entry) => {
340 let [key, val] = [entry[0], obj[entry[0]]];
341 switch (true) {
342 case [val, against[key]].some((potential) =>
343 [null, undefined].includes(potential)
344 ):
345 return [key, against[key]];
346 case typeof val !== typeof against[key]:
347 case Array.isArray(val) !== Array.isArray(against[key]):
348 throw Error(
349 `documentative<config>: ${key} should be of type ${
350 Array.isArray(against[key]) ? 'array' : typeof against[key]
351 }`
352 );
353 case typeof val === 'object':
354 if (typeof against[key] === 'object' && !Array.isArray(against[key]))
355 val = validateObj(val, against[key]);
356 default:
357 return [key, val];
358 }
359 })
360 );
361}
362
363async function filelist(dir, filter = () => true) {
364 let files = [];
365 for await (const item of klaw(dir))
366 if (!(item.path in files)) files.push(item.path);
367 // [pages, assets]
368 return files
369 .map((item) =>
370 path
371 .relative('.', item)
372 .slice(['', '.', './'].includes(dir) ? 0 : dir.length + path.sep.length)
373 )
374 .filter(
375 (item) =>
376 item &&
377 !item.split(path.sep).includes('node_modules') &&
378 !fs.lstatSync(path.join(dir, item)).isDirectory() &&
379 filter(item)
380 )
381 .sort()
382 .reduce(
383 (result, item) => {
384 result[item.endsWith('.md') ? 0 : 1].push(item);
385 return result;
386 },
387 [[], []]
388 );
389}
390async function loadResources() {
391 if (!$.resources.has('template'))
392 $.resources.set('template', pug.compileFile(resourcepath('template.pug')));
393 if (!$.resources.has('js'))
394 $.resources.set('js', await fsp.readFile(resourcepath('docs.js'), 'utf8'));
395 if (!$.resources.has('css'))
396 $.resources.set(
397 'css',
398 await fsp.readFile(resourcepath('docs.less'), 'utf8')
399 );
400 if (!$.resources.has('icon.light'))
401 $.resources.set(
402 'icon.light',
403 await fsp.readFile(resourcepath('light-docs.png'))
404 );
405 if (!$.resources.has('icon.dark'))
406 $.resources.set(
407 'icon.dark',
408 await fsp.readFile(resourcepath('dark-docs.png'))
409 );
410 return true;
411}
412
413function parseNav(inputdir, files, arr = []) {
414 if (!Array.isArray(arr))
415 throw Error(`documentative<config.nav>: should be an array`);
416 return (arr.length
417 ? arr.map((entry) => {
418 switch (typeof entry) {
419 case 'string':
420 // "title"
421 return {
422 type: 'title',
423 text: entry,
424 };
425 case 'object':
426 if (Array.isArray(entry)) {
427 if (entry.length === 1) entry[1] = entry[0];
428 if (
429 files.includes(
430 path.relative(inputdir, path.join(inputdir, entry[1]))
431 )
432 )
433 // ["output", "src"]
434 return {
435 type: 'page',
436 output: entry[0].endsWith('.html')
437 ? entry[0]
438 : entry[0] + '.html',
439 src: entry[1],
440 };
441 // ["text", "url"]
442 return {
443 type: 'link',
444 text: entry[0],
445 url: entry[1],
446 };
447 }
448 // {
449 // type: "page" || "link" || "title"
450 // (page) output: output filepath
451 // (page) src: source filepath
452 // (link, title) text: displayed text
453 // (link) url: url
454 // }
455 switch (entry.type) {
456 case 'page':
457 if (
458 typeof entry.output === 'string' &&
459 typeof entry.src === 'string' &&
460 files.includes(
461 path.relative(inputdir, path.join(inputdir, entry.src))
462 )
463 ) {
464 if (!entry.output.endsWith('.html')) entry.output += '.html';
465 return entry;
466 }
467 case 'link':
468 if (
469 typeof entry.text === 'string' &&
470 typeof entry.url === 'string'
471 )
472 return entry;
473 case 'title':
474 if (typeof entry.text === 'string') return entry;
475 }
476 default:
477 throw Error(`documentative<config.nav>: invalid entry ${entry}`);
478 }
479 })
480 : Object.entries(
481 files.reduce((prev, val) => {
482 const dir = val.split(path.sep).slice(0, -1).join(path.sep);
483 if (!prev[dir]) prev[dir] = [];
484 prev[dir].push({
485 type: 'page',
486 output: val.slice(0, -3) + '.html',
487 src: val,
488 });
489 return prev;
490 }, {})
491 )
492 .map((entry) => {
493 const index =
494 entry[1].find((item) =>
495 item.src.toLowerCase().endsWith('index.md')
496 ) ||
497 entry[1].find((item) =>
498 item.src.toLowerCase().endsWith('readme.md')
499 );
500 if (index) {
501 entry[1].splice(
502 entry[1].findIndex((item) => item.src === index.src),
503 1
504 );
505 entry[1].unshift({
506 type: 'page',
507 output: [
508 ...index.src.split(path.sep).slice(0, -1),
509 'index.html',
510 ].join(path.sep),
511 src: index.src,
512 });
513 }
514 if (entry[0]) entry[1].unshift({ type: 'title', text: entry[0] });
515 return entry[1];
516 })
517 .flat()
518 ).map((entry, i, nav) => {
519 if (entry.type === 'page') {
520 entry.index = i;
521 entry.prev = i - 1;
522 while (nav[entry.prev] && nav[entry.prev].type !== 'page') entry.prev--;
523 entry.next = i + 1;
524 while (nav[entry.next] && nav[entry.next].type !== 'page') entry.next++;
525 entry.depth = '../'.repeat(entry.output.split(path.sep).length - 1);
526 }
527 return entry;
528 });
529}
530async function parsePage(inputdir, page, nav) {
531 const IDs = new marked.Slugger(),
532 tokens = marked.lexer(
533 await fsp.readFile(path.join(inputdir, page.src), 'utf8')
534 );
535 page.headings = [];
536 for (let token of tokens) {
537 switch (token.type) {
538 case 'heading':
539 const ID = IDs.slug(token.text.toLowerCase());
540 page.headings.push({
541 name: token.text,
542 level: token.depth,
543 hash: ID,
544 });
545 token.type = 'html';
546 token.text = `
547 </section>
548 <section class="block" id="${ID}">
549 <h${token.depth}>
550 <a href="#${ID}">${token.text}</a>
551 </h${token.depth}>
552 `;
553 break;
554 }
555 }
556 page.title = page.headings.shift() || page.output.slice(0, -5);
557
558 // map src -> output (links)
559 nav = Object.fromEntries(
560 nav
561 .filter((entry) => entry.type === 'page')
562 .map((entry) => [entry.src, entry.output])
563 );
564 marked.use({
565 renderer: {
566 link(href, title, text) {
567 href = href.split('#');
568 href = [href[0], href[1] || ''];
569 if (href[0].endsWith('.md')) {
570 const output =
571 nav[
572 path.join(
573 page.src.split(path.sep).slice(0, -1).join(path.sep),
574 href[0]
575 )
576 ];
577 let depth = page.depth.split('/');
578 if (depth.length == 1 && depth[0] == '') depth = [];
579 if (output) href[0] = [...depth, output].join('/');
580 }
581 href = href.join('#');
582 if (!href) return text;
583 return `<a href="${htmlescape(href)}" title="${
584 title || ''
585 }">${text}</a>`;
586 },
587 },
588 });
589
590 page.content = `
591 <section class="block">
592 ${marked.parser(tokens)}
593 </section>`.replace(/<section class="block">\s*<\/section>/g, '');
594 return page;
595}