1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 | const Promise = require('bluebird');
|
15 | const sass = Promise.promisifyAll(require('node-sass'));
|
16 | const crypto = require('crypto');
|
17 |
|
18 | const nunjucks = require('nunjucks');
|
19 | const markdown = require('nunjucks-markdown');
|
20 | const md = require('markdown-it')({
|
21 | html: true
|
22 | });
|
23 |
|
24 | md.use(require('markdown-it-anchor'));
|
25 | md.use(require('markdown-it-table-of-contents'), {
|
26 | includeLevel: [1, 2, 3, 4, 5, 6]
|
27 | });
|
28 | md.use(require('markdown-it-prism'));
|
29 | md.use(require('markdown-it-highlight-lines'));
|
30 | md.use(require('markdown-it-container'), 'label');
|
31 |
|
32 | const fs = Promise.promisifyAll(require('fs-extra'));
|
33 | const path = require('path');
|
34 | const yaml = require('js-yaml');
|
35 | const rollup = require('rollup').rollup;
|
36 | const sha1 = require('sha1');
|
37 | const matter = require('gray-matter');
|
38 | const Sugar = require('sugar-date');
|
39 | const svgo = require('svgo');
|
40 | const { createSitemap } = require('sitemap');
|
41 | const sharp = require('sharp');
|
42 | const minifyHTML = require('html-minifier').minify;
|
43 | const { terser } = require('rollup-plugin-terser');
|
44 | const unified = require('unified');
|
45 | const parse = require('orga-unified');
|
46 | const mutate = require('orga-rehype');
|
47 | const highlight = require('@mapbox/rehype-prism');
|
48 | const html = require('rehype-stringify');
|
49 | const unistMap = require('unist-util-map');
|
50 | const unistBuilder = require('unist-builder');
|
51 | const spawn = require('child_process').spawnSync;
|
52 | const fg = require('fast-glob');
|
53 |
|
54 | const {
|
55 | unique,
|
56 | concat,
|
57 | merge,
|
58 | flatten,
|
59 | isEmpty,
|
60 | anchorize,
|
61 | exists,
|
62 | print,
|
63 | println,
|
64 | buildTableOfContents,
|
65 | unfold,
|
66 | symmetricDifference
|
67 | } = require('../cli/util');
|
68 |
|
69 | const LinkExt = require('./LinkExt');
|
70 |
|
71 | const CWD = process.cwd();
|
72 | Sugar.Date.extend();
|
73 |
|
74 |
|
75 | EXTENSIONS = {
|
76 | images: ['.jpg', '.png', '.jpeg', '.svg']
|
77 | };
|
78 |
|
79 | let env = nunjucks.configure(['website/pages', 'website/components'], {
|
80 | autoescape: true,
|
81 | noCache: true
|
82 | });
|
83 |
|
84 | env.addFilter('date', (date, format) =>
|
85 | Date.create(date).format(format || '{yyyy}-{MM}-{dd}')
|
86 | );
|
87 |
|
88 | env.addFilter('logo', names => {
|
89 | NameToLogo = {
|
90 | 'Ruby': 'https://upload.wikimedia.org/wikipedia/commons/f/f1/Ruby_logo.png',
|
91 | 'TypeScript': 'https://cdn.jsdelivr.net/gh/remojansen/logo.ts@master/ts.svg',
|
92 | 'Node.js': 'https://miro.medium.com/max/400/1*tfZa4vsI6UusJYt_fzvGnQ.png',
|
93 | 'JavaScript': 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/99/Unofficial_JavaScript_logo_2.svg/512px-Unofficial_JavaScript_logo_2.svg.png',
|
94 | 'Dart': 'https://pbs.twimg.com/profile_images/993555605078994945/Yr-pWI4G_400x400.jpg',
|
95 | 'Flutter': 'https://cdn.worldvectorlogo.com/logos/flutter-logo.svg'
|
96 | };
|
97 |
|
98 | return names
|
99 | .map(name => NameToLogo[name])
|
100 | .filter(element => element)
|
101 | .reduce((stored, current) => stored.concat('&', 'images=', encodeURIComponent(current)), '');
|
102 | });
|
103 |
|
104 |
|
105 |
|
106 | env.addExtension('LinkExt', new LinkExt());
|
107 |
|
108 | markdown.register(env, md.render.bind(md));
|
109 |
|
110 |
|
111 |
|
112 |
|
113 |
|
114 |
|
115 |
|
116 |
|
117 |
|
118 | const linkify = ({ filepath: pathOnDisk }) => tree =>
|
119 | unistMap(tree, node => {
|
120 | const { type, uri: { protocol = '', location = '/' } = '' } = node;
|
121 |
|
122 |
|
123 |
|
124 |
|
125 |
|
126 | if (type === 'link' && protocol === 'file') {
|
127 | let l = location;
|
128 |
|
129 | let finalPath = '';
|
130 | if (location.startsWith('~')) {
|
131 | l = location.split(path.join(config.directory, 'website/pages')).pop();
|
132 | const { dir, name } = path.parse(l);
|
133 | finalPath = path.join('/', dir, name, '/');
|
134 | } else {
|
135 | const splitted = pathOnDisk.split(path.sep);
|
136 | splitted.pop();
|
137 | splitted.pop();
|
138 | const locationDir = splitted.join(path.sep);
|
139 |
|
140 | const { dir, name } = path.parse(l);
|
141 | finalPath = path.join('/', locationDir, dir, name, '/');
|
142 | }
|
143 |
|
144 | node.uri.raw = finalPath;
|
145 | }
|
146 |
|
147 | return node;
|
148 | });
|
149 |
|
150 | let cache;
|
151 | let config;
|
152 | let bundles = { js: '', css: '' };
|
153 |
|
154 | let __data = {};
|
155 | let __pages = {};
|
156 | let __tags = {};
|
157 | let __cache = {};
|
158 | let __website = {};
|
159 |
|
160 | function __public(filename, inside = '') {
|
161 | return path.join(CWD, 'public', inside, filename);
|
162 | }
|
163 |
|
164 | function __current(prefix, f = '') {
|
165 | return path.join(CWD, 'website', prefix, f);
|
166 | }
|
167 |
|
168 | function profile(func, prefix, allowedExtensions) {
|
169 | return async file => {
|
170 | const result = await func(file);
|
171 |
|
172 | return result;
|
173 | };
|
174 | }
|
175 |
|
176 | function filterBy(entities) {
|
177 | return prefix => {
|
178 | return Object.entries(entities)
|
179 | .filter(
|
180 | ([p, meta]) => (prefix ? p.split(path.sep).includes(prefix) : true)
|
181 | )
|
182 | .map(([path, meta]) => meta)
|
183 | .filter(_ => _.publish === undefined || _.publish === true)
|
184 | .sort((a, b) => b.created_at - a.created_at);
|
185 | };
|
186 | }
|
187 |
|
188 | function compile(prefix) {
|
189 | const isProduction = process.env.KULFON_ENV === 'production';
|
190 | const { stylesheets, javascripts, includePaths } = config;
|
191 |
|
192 | let output;
|
193 | let filename;
|
194 |
|
195 | let compiler;
|
196 | switch (prefix) {
|
197 | case 'images':
|
198 | compiler = async file => {
|
199 | const imageExists = await exists(__public(file, 'images'));
|
200 | if (imageExists) return file;
|
201 |
|
202 | await fs.ensureDir(path.join(CWD, 'public', 'images', path.dirname(file)));
|
203 |
|
204 | switch (path.extname(file)) {
|
205 | case '.svg':
|
206 |
|
207 |
|
208 |
|
209 |
|
210 |
|
211 |
|
212 | fs.copyAsync(__current('images', file), __public(file, 'images'));
|
213 | break;
|
214 | case '.gif':
|
215 | fs.copyAsync(__current('images', file), __public(file, 'images'));
|
216 | break;
|
217 | default:
|
218 | await sharp(__current('images', file)).toFile(
|
219 | __public(file, 'images')
|
220 | );
|
221 | }
|
222 | };
|
223 | break;
|
224 | case 'javascripts':
|
225 | compiler = async file => {
|
226 | const dependencies = (javascripts || [])
|
227 | .map(name => name.split('/')[3].split('@')[0])
|
228 | .reduce((acc, name) => Object.assign(acc, { [name]: name }), {});
|
229 |
|
230 | let options = {
|
231 | input: path.join(CWD, 'website/javascripts', 'main.js'),
|
232 | cache: cache,
|
233 | external: Object.keys(dependencies),
|
234 | plugins: terser()
|
235 | };
|
236 |
|
237 | try {
|
238 | let bundle = await rollup(options);
|
239 | cache = bundle;
|
240 |
|
241 | let hash = sha1(bundle.cache.modules[0].code);
|
242 | bundles.js = `main.${hash}.js`;
|
243 |
|
244 | return bundle.write({
|
245 | format: 'iife',
|
246 | file: __public(bundles.js)
|
247 | });
|
248 | } catch (error) {
|
249 | println(error.message);
|
250 | }
|
251 | };
|
252 | break;
|
253 | case 'stylesheets':
|
254 | compiler = async file => {
|
255 | let filePath = __current(prefix, file);
|
256 |
|
257 | try {
|
258 | let result = await sass.renderAsync({
|
259 | file: filePath,
|
260 | includePaths: includePaths || [],
|
261 | outputStyle: 'compressed',
|
262 | sourceMap: true,
|
263 | sourceMapEmbed: !isProduction
|
264 | });
|
265 |
|
266 | output = result.css;
|
267 |
|
268 | let hash = sha1(output);
|
269 | let name = path.basename(file, path.extname(file));
|
270 |
|
271 | filename = !isProduction ? `${name}.css` : `${name}.${hash}.css`;
|
272 |
|
273 | bundles.css = filename;
|
274 |
|
275 | await fs.writeFileAsync(__public(filename), output);
|
276 | } catch (error) {
|
277 | println(error.formatted);
|
278 | }
|
279 | };
|
280 | break;
|
281 | case 'pages':
|
282 | compiler = async file => {
|
283 | try {
|
284 | const page = __pages[file];
|
285 |
|
286 | const { data, content } = matter(__website[`${prefix}/${file}`]);
|
287 |
|
288 | const isOrg = path.extname(file) === '.org';
|
289 | if (isOrg) {
|
290 | const processor = unified().use(parse);
|
291 | const ast = await processor.parse(content);
|
292 | data.ast = ast;
|
293 | }
|
294 |
|
295 | let { filepath } = page;
|
296 |
|
297 | let renderString = content;
|
298 | let renderParams = {
|
299 | config,
|
300 | page,
|
301 | website: __data,
|
302 | javascripts,
|
303 | stylesheets,
|
304 | bundles,
|
305 | pages: filterBy(__pages)
|
306 | };
|
307 |
|
308 | const extension = path.extname(file);
|
309 |
|
310 | if (['.md', '.org'].includes(extension)) {
|
311 | const parentDir = path
|
312 | .parse(file)
|
313 | .dir.split(path.sep)
|
314 | .slice(-1)[0];
|
315 |
|
316 | const foo = await fs.pathExists(
|
317 | __current(
|
318 | 'components/layouts',
|
319 | path.format({ name: parentDir, ext: '.njk' })
|
320 | )
|
321 | );
|
322 |
|
323 | const itself = path.parse(file).name;
|
324 | const itselfExists = await fs.pathExists(
|
325 | __current(
|
326 | 'components/layouts',
|
327 | path.format({ name: itself, ext: '.njk' })
|
328 | )
|
329 | );
|
330 |
|
331 | if (parentDir && foo) {
|
332 | renderString = `{% extends "layouts/${parentDir}.njk" %}`;
|
333 | } else if (itselfExists) {
|
334 | renderString = `{% extends "layouts/${itself}.njk" %}`;
|
335 | } else {
|
336 | renderString = `
|
337 | {% extends "layouts/index.njk" %}
|
338 | {% block content %}
|
339 | {{ content | safe }}
|
340 | {% endblock %}`;
|
341 | }
|
342 |
|
343 | if (path.extname(file) === '.md') {
|
344 | const tokens = md.parse(content, {});
|
345 | const toc = buildTableOfContents(0, tokens);
|
346 | renderParams.toc = toc ? toc[1] : false;
|
347 | renderParams.content = md.render(content);
|
348 | } else if (path.extname(file) === '.org') {
|
349 | const processor = await unified()
|
350 | .use(parse)
|
351 | .use(linkify, { filepath })
|
352 | .use(mutate)
|
353 | .use(highlight)
|
354 | .use(html);
|
355 |
|
356 | renderParams.content = await processor.process(content);
|
357 | }
|
358 | }
|
359 |
|
360 | output = nunjucks.renderString(renderString, renderParams);
|
361 |
|
362 | if (isProduction)
|
363 | output = minifyHTML(output, { collapseWhitespace: true });
|
364 |
|
365 | const filename = pathname(file);
|
366 | await fs.outputFileAsync(__public('index.html', filename), output);
|
367 |
|
368 | return page;
|
369 | } catch (error) {
|
370 | println(error.message);
|
371 | }
|
372 | };
|
373 | break;
|
374 | case 'root':
|
375 | compiler = async file => {
|
376 | fs.copyAsync(__current('root', file), __public(file));
|
377 | };
|
378 | break;
|
379 | default:
|
380 | compiler = async file => file;
|
381 | break;
|
382 | }
|
383 |
|
384 | return compiler;
|
385 | }
|
386 |
|
387 | function pathname(file) {
|
388 | const { name, dir } = path.parse(file);
|
389 |
|
390 |
|
391 |
|
392 | const segments = name.split('_');
|
393 | let d = Date.parse(segments[0]);
|
394 |
|
395 | const prefix = config.blog ? config.blog.prefix : false;
|
396 |
|
397 | if (d) {
|
398 | d = new Date(d);
|
399 |
|
400 | const year = String(d.getFullYear());
|
401 | const month = ('0' + (d.getMonth() + 1)).slice(-2);
|
402 | const day = String(d.getDate());
|
403 | const rest = segments.slice(1).join('_');
|
404 |
|
405 | if (prefix === 'blog') {
|
406 | return path.join(dir, year, month, rest, '/');
|
407 | } else if (prefix === 'none') {
|
408 | return path.join(rest, '/');
|
409 | } else {
|
410 | return path.join(dir, rest, '/');
|
411 | }
|
412 | } else if (name === 'index') {
|
413 | return '';
|
414 | } else {
|
415 | return path.join(dir, name, '/');
|
416 | }
|
417 | }
|
418 |
|
419 | async function loadConfig() {
|
420 | const yamlContent = await fs.readFileAsync(
|
421 | path.join(CWD, 'config.yml'),
|
422 | 'utf8'
|
423 | );
|
424 | config = yaml.safeLoad(yamlContent);
|
425 | }
|
426 |
|
427 | async function loadData() {
|
428 | let dataPath = path.join(CWD, 'website');
|
429 |
|
430 | let entries = ['data.yml'];
|
431 |
|
432 | let data = {};
|
433 |
|
434 | try {
|
435 | let stats = await fs.statAsync(path.join(dataPath, 'data'));
|
436 |
|
437 | if (stats.isDirectory()) {
|
438 | dataPath = path.join(dataPath, 'data');
|
439 | entries = fs.readdirAsync(dataPath);
|
440 | }
|
441 | } catch (error) {
|
442 |
|
443 | }
|
444 |
|
445 | let files = entries.filter(f => fs.statSync(path.join(dataPath, f)).isFile());
|
446 |
|
447 | const content = files.reduce(
|
448 | (acc, _) =>
|
449 | [acc, fs.readFileSync(path.join(dataPath, _), 'utf8')].join('---\n'),
|
450 | ''
|
451 | );
|
452 |
|
453 | yaml.safeLoadAll(content, doc => {
|
454 | data = merge(data, doc);
|
455 | });
|
456 |
|
457 | const unfolded = await unfold(data);
|
458 |
|
459 | __data = merge(__data, unfolded);
|
460 | }
|
461 |
|
462 | const buildTagsPages = async () => {
|
463 | const { stylesheets, javascripts, includePaths } = config;
|
464 |
|
465 | const tagsPage = await fs.readFileAsync(
|
466 | __current('pages', 'tags.njk'),
|
467 | 'utf8'
|
468 | );
|
469 |
|
470 | for (let tag in __tags) {
|
471 | let output = nunjucks.renderString(tagsPage, {
|
472 | tag,
|
473 | posts: __tags[tag],
|
474 | pages: filterBy({}),
|
475 | config,
|
476 | javascripts,
|
477 | stylesheets
|
478 | });
|
479 | await fs.outputFileAsync(__public('index.html', `tags/${tag}`), output);
|
480 | }
|
481 | };
|
482 |
|
483 | async function transform(prefix) {
|
484 | let startTime = new Date();
|
485 |
|
486 | const entries = await fg('**', {
|
487 | cwd: `website/${prefix}`
|
488 | });
|
489 |
|
490 | print(`${'●'.red} ${prefix.padEnd(11).blue} : `);
|
491 |
|
492 |
|
493 | if (prefix === 'pages') {
|
494 | const __categories = {};
|
495 |
|
496 | for (let file of entries) {
|
497 | const filepath = pathname(file);
|
498 | const breadcrumbs = filepath.split(path.sep).slice(0, -2);
|
499 | const raw = await fs.readFile(__current(prefix, file));
|
500 | __website[`${prefix}/${file}`] = raw;
|
501 |
|
502 | const { data, content } = matter(raw);
|
503 |
|
504 | const isOrg = path.extname(file) === '.org';
|
505 | const lacksFrontMatter = isEmpty(data);
|
506 |
|
507 | if (isOrg) {
|
508 | const processor = unified().use(parse);
|
509 | const ast = await processor.parse(content);
|
510 | data.ast = ast;
|
511 | }
|
512 |
|
513 | let { title = '', created_at, abstract, categories = [], tags = [] } =
|
514 | isOrg && lacksFrontMatter ? data.ast.meta : data;
|
515 |
|
516 | let title_raw = title;
|
517 | title = title.replace(/\*/g, '');
|
518 |
|
519 | if (typeof tags === 'string') tags = [tags];
|
520 |
|
521 |
|
522 | for (let category of categories || []) {
|
523 | let c = anchorize(category);
|
524 | (__categories[c] = __categories[c] || []).push({
|
525 | filepath,
|
526 | breadcrumbs,
|
527 | title,
|
528 | created_at,
|
529 | tags,
|
530 | categories
|
531 | });
|
532 | }
|
533 |
|
534 |
|
535 | for (let tag of tags || []) {
|
536 | let t = anchorize(tag);
|
537 | (__tags[t] = __tags[t] || []).push({
|
538 | filepath,
|
539 | breadcrumbs,
|
540 | title,
|
541 | created_at,
|
542 | tags,
|
543 | categories
|
544 | });
|
545 | }
|
546 |
|
547 | __pages[file] = {
|
548 | ...data,
|
549 | content,
|
550 | filepath,
|
551 | breadcrumbs,
|
552 | title,
|
553 | title_raw,
|
554 | abstract,
|
555 | categories,
|
556 | tags,
|
557 | created_at
|
558 | };
|
559 | }
|
560 |
|
561 |
|
562 | __data.categories = Object.keys(__categories);
|
563 | __data.tags = Object.keys(__tags);
|
564 | }
|
565 |
|
566 | for (let file of entries) {
|
567 | try {
|
568 | switch (prefix) {
|
569 | case 'images':
|
570 | if (!['.jpg', '.png', '.jpeg', '.svg'].includes(path.extname(file)))
|
571 | continue;
|
572 | }
|
573 |
|
574 | const raw = await fs.readFile(__current(prefix, file));
|
575 | let fileKey = `${prefix}/${file}`;
|
576 | __website[fileKey] = raw;
|
577 |
|
578 |
|
579 |
|
580 | const hash = crypto.createHash('md5').update(raw).digest('hex').slice(0, 10);
|
581 |
|
582 | if (__cache[fileKey] !== hash) {
|
583 | await compile(prefix)(file);
|
584 | __cache[fileKey] = hash;
|
585 | }
|
586 | } catch (error) {
|
587 | console.log('ERROR: ', error.message);
|
588 | }
|
589 | }
|
590 | let endTime = new Date();
|
591 | const timeAsString = `${endTime - startTime}ms`.underline;
|
592 | println(`${timeAsString.padStart(18)} ${'done'.green}`);
|
593 | }
|
594 |
|
595 | async function checkDirectoryStructure() {
|
596 | const paths = [
|
597 | 'website/images',
|
598 | 'website/javascripts',
|
599 | 'website/components',
|
600 | 'website/pages',
|
601 | 'website/stylesheets'
|
602 | ].map(el => path.join(CWD, el));
|
603 |
|
604 | const result = await Promise.resolve(paths)
|
605 | .map(fs.pathExists)
|
606 | .all();
|
607 |
|
608 | if (!result.every(_ => _)) {
|
609 | const tree = spawn('tree', ['-d', '-I', 'node_modules'], { cwd: '.' });
|
610 | throw new WrongDirectoryError(`It seems you are not in 'kulfon' compatible directory. Here's the proper structure:
|
611 |
|
612 | . (your project root)
|
613 | └── website
|
614 | ├── components
|
615 | ├── images
|
616 | ├── javascripts
|
617 | ├── pages
|
618 | └── stylesheets
|
619 |
|
620 | but your current directory at '${CWD}' looks like this:
|
621 |
|
622 | ${tree.stdout}
|
623 | `);
|
624 | }
|
625 | }
|
626 |
|
627 | class WrongDirectoryError extends Error {
|
628 | constructor(message) {
|
629 | super(message);
|
630 | }
|
631 | }
|
632 |
|
633 | async function generateSitemap() {
|
634 | const sitemap = createSitemap({
|
635 | hostname: config.base_url || 'https://localhost',
|
636 | urls: Object.values(__pages).map(({ sitemap, filepath }) => ({
|
637 | url: filepath,
|
638 | priority: sitemap ? sitemap.priority : 0.5
|
639 | }))
|
640 | });
|
641 |
|
642 | await fs.outputFileAsync(__public('sitemap.xml'), sitemap.toString());
|
643 | }
|
644 |
|
645 | async function recompile(file) {
|
646 | let fileSegments = file.split(path.sep);
|
647 | const prefix = fileSegments.shift();
|
648 | file = path.join(...fileSegments);
|
649 |
|
650 |
|
651 |
|
652 | await loadData();
|
653 |
|
654 | if (prefix.match(/pages/)) {
|
655 | await compile(prefix)(file);
|
656 | } else {
|
657 | await transform('pages');
|
658 | }
|
659 | }
|
660 |
|
661 | async function compileAll({ dir, env }) {
|
662 | process.env.KULFON_ENV = env;
|
663 |
|
664 | try {
|
665 | __cache = await fs.readJSON('public/.cache');
|
666 | } catch (error) {
|
667 | __cache = {};
|
668 | }
|
669 |
|
670 | try {
|
671 | await fs.ensureDirAsync('public/images');
|
672 | await checkDirectoryStructure();
|
673 | await loadConfig();
|
674 | await loadData();
|
675 |
|
676 |
|
677 | await transform('images');
|
678 | await transform('stylesheets');
|
679 | await transform('javascripts');
|
680 | await transform('pages');
|
681 | await transform('root');
|
682 |
|
683 | await buildTagsPages();
|
684 |
|
685 | await generateSitemap();
|
686 |
|
687 | await fs.outputFileAsync(path.join(CWD, 'public/.cache'), JSON.stringify(__cache));
|
688 | } catch (error) {
|
689 | console.error('Error: '.red + error.message);
|
690 | process.exit();
|
691 | }
|
692 | }
|
693 |
|
694 | module.exports = {
|
695 | recompile,
|
696 | compileAll
|
697 | };
|