1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 | module.exports = { build, serve };
|
8 |
|
9 | const 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 | '&': '&',
|
22 | '<': '<',
|
23 | '>': '>',
|
24 | '"': '"',
|
25 | "'": ''',
|
26 | };
|
27 |
|
28 | function htmlescape(text) {
|
29 | return text.replace(htmlescape_chars, (ch) => htmlescape_chars[ch]);
|
30 | }
|
31 |
|
32 | const $ = {
|
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 | };
|
50 | marked.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 |
|
78 | async 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 |
|
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 |
|
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 |
|
199 | async 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 |
|
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 |
|
310 | function 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 | }
|
337 | function 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 |
|
363 | async 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 |
|
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 | }
|
390 | async 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 |
|
413 | function 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 |
|
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 |
|
434 | return {
|
435 | type: 'page',
|
436 | output: entry[0].endsWith('.html')
|
437 | ? entry[0]
|
438 | : entry[0] + '.html',
|
439 | src: entry[1],
|
440 | };
|
441 |
|
442 | return {
|
443 | type: 'link',
|
444 | text: entry[0],
|
445 | url: entry[1],
|
446 | };
|
447 | }
|
448 |
|
449 |
|
450 |
|
451 |
|
452 |
|
453 |
|
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 | }
|
530 | async 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 |
|
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 | }
|