UNPKG

hikarujs

Version:

A static site generator that generates routes based on directories naturally.

373 lines (353 loc) 9.81 kB
/** * @module router */ import fse from "fs-extra"; import * as http from "node:http"; import {Site, File} from "./types.js"; import { isBinaryPath, default404, matchFiles, getPathFn, getContentType, putSite, delSite, getFullSrcPath, getFullDocPath, parseFrontMatter } from "./utils.js"; /** * @description Core module that handling file routing. */ class Router { /** * @param {Logger} logger * @param {Renderer} renderer * @param {Processor} processor * @param {Generator} generator * @param {Decorator} decorator * @param {Helper} helper * @param {Translator} translator * @param {Site} site * @param {Watcher} [watcher] * @return {Router} */ constructor( logger, renderer, processor, generator, decorator, helper, translator, site, watcher = null ) { this.logger = logger; this.renderer = renderer; this.processor = processor; this.generator = generator; this.decorator = decorator; this.helper = helper; this.translator = translator; this.site = site; this._ = new Map(); this.server = null; this.ip = null; this.port = null; this.listening = false; this.watcher = watcher; this.queuedFlush = false; this.getPath = getPathFn(this.site["siteConfig"]["rootDir"]); } /** * @private * @description Get decorated content with context. * @param {File} file * @return {Promise<String>} */ async getDecoratedContent(file) { // Running helper and decorator for file without layout is meaningless, // because layout is used for decorator to select template. if (!file["binary"] && file["layout"] != null) { return this.decorator.decorate( file, await this.helper.getContext(this.site, file) ); } return file["content"]; } /** * @private * @description Read file content. * @param {String} filepath * @return {Promise<Buffer>} */ read(filepath) { return fse.readFile(filepath); } /** * @private * @description Write or copy file to docDir. * @param {File} file * @param {(Buffer|String)} content This is for decorated content, if the file * is not decorated, leave this empty and file's content property is used. */ write(file, content) { return file["binary"] ? fse.copy(getFullSrcPath(file), getFullDocPath(file)) : fse.outputFile(getFullDocPath(file), content || file["content"]); } /** * @private * @description Load files into site data via parsing front-matter. * @param {File} file */ async loadFile(file) { this.logger.debug(`Hikaru is reading \`${ this.logger.cyan(getFullSrcPath(file)) }\`...`); // Binary files are not supposed to be handled by SSGs. We can copy or pipe // it to save memory. file["binary"] = isBinaryPath(getFullSrcPath(file)); if (!file["binary"]) { file["raw"] = await this.read(getFullSrcPath(file)); file["text"] = file["raw"].toString("utf8"); file = parseFrontMatter(file); } const results = await this.renderer.render(file); for (const result of results) { if (result["layout"] === "post") { putSite(this.site, "posts", result); } else if (result["layout"] != null) { putSite(this.site, "pages", result); } else { putSite(this.site, "assets", result); } } } /** * @private * @description Save file via layout. * @param {File} file */ async saveFile(file) { const content = await this.getDecoratedContent(file); this.logger.debug(`Hikaru is writing \`${ this.logger.cyan(getFullDocPath(file)) }\`...`); this.write(file, content); } /** * @private * @description Match all src files. * @return {Promise<File[]>} */ async matchAll() { // Globs must not contain windows spearators. return (await matchFiles("**/*", { "ignoreHidden": false, "workDir": this.site["siteConfig"]["themeSrcDir"] })).map((srcPath) => { return new File({ "docDir": this.site["siteConfig"]["docDir"], "srcDir": this.site["siteConfig"]["themeSrcDir"], "srcPath": srcPath }); }).concat((await matchFiles("**/*", { "ignoreHidden": false, "workDir": this.site["siteConfig"]["srcDir"] })).map((srcPath) => { return new File({ "docDir": this.site["siteConfig"]["docDir"], "srcDir": this.site["siteConfig"]["srcDir"], "srcPath": srcPath }); })); } /** * @private * @description Build routes for all built files to serve. * @param {File[]} allFiles All built files. */ buildServerRoutes(allFiles) { this._.clear(); for (const f of allFiles) { const key = this.getPath(f["docPath"]); this.logger.debug(`Hikaru is serving \`${this.logger.cyan(key)}\`...`); this._.set(key, f); } } /** * @private * @description Queue operations and handle only once. */ flush() { if (!this.queuedFlush) { this.queuedFlush = true; setImmediate(async () => { this.queuedFlush = false; await this.handle(); this.buildServerRoutes( this.site["assets"] .concat(this.site["posts"]) .concat(this.site["pages"]) .concat(this.site["files"]) ); }); } } /** * @private * @description Watch all src files. */ watchAll() { if (this.watcher == null) { return; } this.watcher.register( [ this.site["siteConfig"]["themeSrcDir"], this.site["siteConfig"]["srcDir"] ], async (srcDir, srcPaths) => { const {added, changed, removed} = srcPaths; for (const srcPath of removed) { const file = new File({ "docDir": this.site["siteConfig"]["docDir"], "srcDir": srcDir, "srcPath": srcPath }); for (const key of Site.arrayKeys) { delSite(this.site, key, file); } } await Promise.all(added.concat(changed).map((srcPath) => { const newFile = new File({ "docDir": this.site["siteConfig"]["docDir"], "srcDir": srcDir, "srcPath": srcPath }); return this.loadFile(newFile); })); this.flush(); } ); } /** * @private * @description Unwatch all src files. */ unwatchAll() { if (this.watcher == null) { return; } this.watcher.unregister([ this.site["siteConfig"]["themeSrcDir"], this.site["siteConfig"]["srcDir"] ]); } /** * @private * @description Start a listening server. * @param {String} ip * @param {Number} port */ listen(ip, port) { this.ip = ip; this.port = port; this.server = http.createServer(async (request, response) => { // Remove query string. const pathname = request["url"].split(/[?#]/)[0]; const code = this._.has(pathname) ? 200 : 404; // Use custom 404 file if available. const real404File = this._.get(this.getPath("404.html")) || new File({ "docDir": "docs", "docPath": this.getPath("404.html"), "content": default404 }); const file = this._.get(pathname) || real404File; this.logger.log(`${ code === 200 ? code : this.logger.yellow(code) } ${this.logger.cyan(pathname)}`); response.writeHead(code, { "Content-Type": getContentType(file["docPath"]) }); if (!file["binary"]) { const content = await this.getDecoratedContent(file); this.logger.debug( `Hikaru is sending \`${this.logger.cyan(getFullDocPath(file))}\`...` ); response.write(content); response.end(); } else { // Pipe a binary instead of send. this.logger.debug( `Hikaru is piping \`${this.logger.cyan(getFullDocPath(file))}\`...` ); fse.createReadStream(getFullSrcPath(file)).pipe(response); } }); this.logger.log(`Hikaru is starting to listen on \`${ this.logger.cyan(`http://${this.ip}:${this.port}${this.getPath()}`) }\`...`); if (!this.listening) { if (this.ip !== "localhost") { this.server.listen(this.port, this.ip); } else { this.server.listen(this.port); } this.listening = true; this.watchAll(); } } /** * @private * @description Close the listening server. */ close() { if (this.listening) { this.server.close(); this.listening = false; this.unwatchAll(); this.logger.log(`Hikaru is stopping to listen on \`${ this.logger.cyan(`http://${this.ip}:${this.port}${this.getPath()}`) }\`...`); this.server = null; } } /** * @private * @description Handle all processor and generator. */ async handle() { await this.processor.process(this.site); this.site["files"] = await this.generator.generate(this.site); } /** * @description Build all site docs. */ async build() { await Promise.all((await this.matchAll()).map(this.loadFile.bind(this))); await this.handle(); this.site["assets"] .concat(this.site["posts"]) .concat(this.site["pages"]) .concat(this.site["files"]) .map(this.saveFile.bind(this)); } /** * @description Serve all site docs. */ async serve(ip, port) { await Promise.all((await this.matchAll()).map(this.loadFile.bind(this))); await this.handle(); this.buildServerRoutes( this.site["assets"] .concat(this.site["posts"]) .concat(this.site["pages"]) .concat(this.site["files"]) ); this.listen(ip, port); } } export default Router;