UNPKG

18.8 kBJavaScriptView Raw
1// Copyright 2019 Zaiste & contributors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6// http://www.apache.org/licenses/LICENSE-2.0
7//
8// Unless required by applicable law or agreed to in writing, software
9// distributed under the License is distributed on an "AS IS" BASIS,
10// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11// See the License for the specific language governing permissions and
12// limitations under the License.
13
14const Promise = require('bluebird');
15const sass = Promise.promisifyAll(require('node-sass'));
16const crypto = require('crypto');
17
18const nunjucks = require('nunjucks');
19const markdown = require('nunjucks-markdown');
20const md = require('markdown-it')({
21 html: true
22});
23
24md.use(require('markdown-it-anchor'));
25md.use(require('markdown-it-table-of-contents'), {
26 includeLevel: [1, 2, 3, 4, 5, 6]
27});
28md.use(require('markdown-it-prism'));
29md.use(require('markdown-it-highlight-lines'));
30md.use(require('markdown-it-container'), 'label');
31
32const fs = Promise.promisifyAll(require('fs-extra'));
33const path = require('path');
34const yaml = require('js-yaml');
35const rollup = require('rollup').rollup;
36const sha1 = require('sha1');
37const matter = require('gray-matter');
38const Sugar = require('sugar-date');
39const svgo = require('svgo');
40const { createSitemap } = require('sitemap');
41const sharp = require('sharp');
42const minifyHTML = require('html-minifier').minify;
43const { terser } = require('rollup-plugin-terser');
44const unified = require('unified');
45const parse = require('orga-unified');
46const mutate = require('orga-rehype');
47const highlight = require('@mapbox/rehype-prism');
48const html = require('rehype-stringify');
49const unistMap = require('unist-util-map');
50const unistBuilder = require('unist-builder');
51const spawn = require('child_process').spawnSync;
52const fg = require('fast-glob');
53
54const {
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
69const LinkExt = require('./LinkExt');
70
71const CWD = process.cwd();
72Sugar.Date.extend();
73// const svgOptimizer = new svgo({});
74
75EXTENSIONS = {
76 images: ['.jpg', '.png', '.jpeg', '.svg']
77};
78
79let env = nunjucks.configure(['website/pages', 'website/components'], {
80 autoescape: true,
81 noCache: true
82});
83
84env.addFilter('date', (date, format) =>
85 Date.create(date).format(format || '{yyyy}-{MM}-{dd}')
86);
87
88env.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
106env.addExtension('LinkExt', new LinkExt());
107
108markdown.register(env, md.render.bind(md));
109
110// const addTitle = () => tree =>
111// unistBuilder('root', { meta: { title: 'asdf' } }, [
112// unistBuilder('section', { level: 1 }, [
113// unistBuilder('headline', { level: 1 }, [unistBuilder('text', {}, 'boo')]),
114// ...tree.children
115// ])
116// ]);
117
118const linkify = ({ filepath: pathOnDisk }) => tree =>
119 unistMap(tree, node => {
120 const { type, uri: { protocol = '', location = '/' } = '' } = node;
121
122 // if (type === 'headline') {
123 // node.level += 1;
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(); // cannot chain, mutable function
137 splitted.pop(); // cannot chain, mutable function
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
150let cache;
151let config;
152let bundles = { js: '', css: '' };
153
154let __data = {};
155let __pages = {};
156let __tags = {};
157let __cache = {};
158let __website = {};
159
160function __public(filename, inside = '') {
161 return path.join(CWD, 'public', inside, filename);
162}
163
164function __current(prefix, f = '') {
165 return path.join(CWD, 'website', prefix, f);
166}
167
168function profile(func, prefix, allowedExtensions) {
169 return async file => {
170 const result = await func(file);
171
172 return result;
173 };
174}
175
176function 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
188function 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; // poor's man optimizaion
201
202 await fs.ensureDir(path.join(CWD, 'public', 'images', path.dirname(file)));
203
204 switch (path.extname(file)) {
205 case '.svg':
206 // const data = await fs.readFileAsync(
207 // __current("images", file),
208 // "utf8"
209 // );
210 // const result = await svgOptimizer.optimize(data);
211 // fs.writeFileSync(__public(file, "images"), result.data);
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
387function pathname(file) {
388 const { name, dir } = path.parse(file);
389
390 // detect if date in the `name`
391 // XXX ugly
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
419async function loadConfig() {
420 const yamlContent = await fs.readFileAsync(
421 path.join(CWD, 'config.yml'),
422 'utf8'
423 );
424 config = yaml.safeLoad(yamlContent);
425}
426
427async function loadData() {
428 let dataPath = path.join(CWD, 'website');
429
430 let entries = ['data.yml']; // by default parse `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 // do nothing
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
462const 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
483async 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 // preprocessing for `pages` so to make references between them
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 // update categories
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 // update tags
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 // all categories & tags from all pages to be available globally
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 // get rev
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
595async 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
620but your current directory at '${CWD}' looks like this:
621
622${tree.stdout}
623 `);
624 }
625}
626
627class WrongDirectoryError extends Error {
628 constructor(message) {
629 super(message);
630 }
631}
632
633async 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
645async function recompile(file) {
646 let fileSegments = file.split(path.sep);
647 const prefix = fileSegments.shift();
648 file = path.join(...fileSegments);
649
650 //debug(`file to recompile: ${file}`);
651
652 await loadData();
653
654 if (prefix.match(/pages/)) {
655 await compile(prefix)(file);
656 } else {
657 await transform('pages');
658 }
659}
660
661async 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 // order is important
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
694module.exports = {
695 recompile,
696 compileAll
697};