UNPKG

hikaru-coffee

Version:

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

617 lines (592 loc) 20.6 kB
fse = require("fs-extra") path = require("path") {URL} = require("url") glob = require("glob") cheerio = require("cheerio") moment = require("moment") colors = require("colors/safe") Promise = require("bluebird") yaml = require("js-yaml") nunjucks = require("nunjucks") marked = require("marked") stylus = require("stylus") nib = require("nib") coffee = require("coffeescript") highlight = require("./highlight") Logger = require("./logger") {Site, File, Category, Tag} = require("./type") Renderer = require("./renderer") Processer = require("./processer") Generator = require("./generator") Translator = require("./translator") Router = require("./router") { escapeHTML, removeControlChars, paginate, sortCategories, paginateCategories, getPathFn, getURLFn } = require("./utils") class Hikaru constructor: (debug = false) -> @debug = debug @logger = new Logger(@debug) @logger.debug("Hikaru is starting...") process.on("exit", () => @logger.debug("Hikaru is stopping...") ) if process.platform is "win32" require("readline").createInterface({ "input": process.stdin, "output": process.stdout }).on("SIGINT", () -> process.emit("SIGINT") ) process.on("SIGINT", () -> process.exit(0) ) init: (workDir = ".", configPath) => return fse.mkdirp(workDir).then(() => @logger.debug("Hikaru is copying `#{colors.cyan( configPath or path.join(workDir, "config.yml") )}`.") @logger.debug("Hikaru is creating `#{colors.cyan( path.join(workDir, "src", path.sep) )}`.") @logger.debug("Hikaru is creating `#{colors.cyan(path.join( workDir, "doc", path.sep ))}`.") @logger.debug("Hikaru is creating `#{colors.cyan(path.join( workDir, "themes", path.sep ))}`.") fse.copy( path.join(__dirname, "..", "dist", "config.yml"), configPath or path.join(workDir, "config.yml") ) fse.mkdirp(path.join(workDir, "src")).then(() => @logger.debug("Hikaru is copying `#{colors.cyan(path.join( workDir, "src", "archives", "index.md" ))}`.") @logger.debug("Hikaru is copying `#{colors.cyan(path.join( workDir, "src", "categories", "index.md" ))}`.") @logger.debug("Hikaru is copying `#{colors.cyan(path.join( workDir, "src", "tags", "index.md" ))}`.") fse.copy( path.join(__dirname, "..", "dist", "archives.md"), path.join(workDir, "src", "archives", "index.md") ) fse.copy( path.join(__dirname, "..", "dist", "categories.md"), path.join(workDir, "src", "categories", "index.md") ) fse.copy( path.join(__dirname, "..", "dist", "tags.md"), path.join(workDir, "src", "tags", "index.md") ) ) fse.mkdirp(path.join(workDir, "doc")) fse.mkdirp(path.join(workDir, "themes")) ).catch((err) => @logger.warn("Hikaru catched some error during initializing!") @logger.error(err) ) clean: (workDir = ".", configPath) => configPath = configPath or path.join(workDir, "config.yml") siteConfig = yaml.safeLoad(fse.readFileSync(configPath, "utf8")) if not siteConfig?["docDir"]? return glob("*", { "cwd": path.join(workDir, siteConfig["docDir"]) }, (err, res) => if err return err return res.map((r) => fse.stat(path.join(workDir, siteConfig["docDir"], r)).then((stats) => if stats.isDirectory() @logger.debug("Hikaru is removing `#{colors.cyan(path.join( workDir, siteConfig["docDir"], r, path.sep ))}`.") else @logger.debug("Hikaru is removing `#{colors.cyan(path.join( workDir, siteConfig["docDir"], r ))}`.") return fse.remove(path.join(workDir, siteConfig["docDir"], r)) ).catch((err) => @logger.warn("Hikaru catched some error during cleaning!") @logger.error(err) ) ) ) build: (workDir = ".", configPath) => @loadSite(workDir, configPath) @loadModules() try await @router.build() catch err @logger.warn("Hikaru catched some error during generating!") @logger.error(err) @logger.warn("Hikaru advise you to check generated files!") serve: (workDir = ".", configPath, ip, port) => @loadSite(workDir, configPath) @loadModules() try await @router.serve(ip or "localhost", Number.parseInt(port) or 2333) catch err @logger.warn("Hikaru catched some error during serving!") @logger.error(err) loadSite: (workDir, configPath) => @site = new Site(workDir) configPath = configPath or path.join(@site.get("workDir"), "config.yml") try @site.set( "siteConfig", yaml.safeLoad(fse.readFileSync(configPath, "utf8")) ) catch err @logger.warn("Hikaru cannot find site config!") @logger.error(err) process.exit(-1) @site.set("srcDir", path.join( @site.get("workDir"), @site.get("siteConfig")["srcDir"] or "srcs" )) @site.set("docDir", path.join( @site.get("workDir"), @site.get("siteConfig")["docDir"] or "docs" )) @site.set("themeDir", path.join( @site.get("workDir"), "themes", @site.get("siteConfig")["themeDir"] )) @site.set("themeSrcDir", path.join(@site.get("themeDir"), "srcs")) try @site.set("themeConfig", yaml.safeLoad( fse.readFileSync(path.join(@site.get("themeDir"), "config.yml")) )) catch err if err["code"] is "ENOENT" @logger.warn("Hikaru continues with a empty theme config...") @site.set( "categoryDir", @site.get("siteConfig")["categoryDir"] or "categories" ) @site.set("tagDir", @site.get("siteConfig")["tagDir"] or "tags") loadModules: () => @renderer = new Renderer(@logger, @site.get("siteConfig")["skipRender"]) @processer = new Processer(@logger) @generator = new Generator(@logger) @translator = new Translator(@logger) try defaultLanguage = yaml.safeLoad( fse.readFileSync( path.join(@site.get("themeDir"), "languages", "default.yml") ) ) @translator.register("default", defaultLanguage) catch err if err["code"] is "ENOENT" @logger.warn("Hikaru cannot find default language file in your theme.") @router = new Router( @logger, @renderer, @processer, @generator, @translator, @site ) try @registerInternalRenderers() @registerInternalProcessers() @registerInternalGenerators() catch err @logger.warn("Hikaru cannot register internal functions!") @logger.error(err) process.exit(-2) registerInternalRenderers: () => njkConfig = Object.assign( {"autoescape": false}, @site.get("siteConfig")["nunjucks"] ) njkEnv = nunjucks.configure(@site.get("themeSrcDir"), njkConfig) @renderer.register([".njk", ".j2"], null, (file, ctx) -> return new Promise((resolve, reject) -> try template = nunjucks.compile(file["text"], njkEnv, file["srcPath"]) # For template you must give a render function. file["content"] = (ctx) -> return new Promise((resolve, reject) -> template.render(ctx, (err, res) -> if err return reject(err) return resolve(res) ) ) return resolve(file) catch err return reject(err) ) ) markedConfig = Object.assign({ "gfm": true, "langPrefix": "", "highlight": (code, lang) => return highlight(code, Object.assign({ "lang": lang?.toLowerCase(), "hljs": true, "gutter": true }, @site.get("siteConfig")["highlight"])) }, @site.get("siteConfig")["marked"]) marked.setOptions(markedConfig) @renderer.register(".md", ".html", (file, ctx) -> return new Promise((resolve, reject) -> try file["content"] = marked(file["text"]) return resolve(file) catch err return reject(err) ) ) stylConfig = @site.get("siteConfig")["stylus"] or {} @renderer.register(".styl", ".css", (file, ctx) => return new Promise((resolve, reject) => stylus(file["text"]) .use(nib()) .use((style) => style.define("getSiteConfig", (file) => keys = file["val"].toString().split(".") res = @site.get("siteConfig") for k in keys if k not of res return null res = res[k] return res ) ).use((style) => style.define("getThemeConfig", (file) => keys = file["val"].toString().split(".") res = @site.get("themeConfig") for k in keys if k not of res return null res = res[k] return res ) ).set("filename", path.join(@site.get("themeSrcDir"), file["srcPath"])) .set("sourcemap", stylConfig["sourcemap"]) .set("compress", stylConfig["compress"]) .set("include css", true) .render((err, res) -> if err return reject(err) file["content"] = res return resolve(file) ) ) ) coffeeConfig = @site.get("siteConfig")["coffeescript"] or {} @renderer.register(".coffee", ".js", (file, ctx) -> return new Promise((resolve, reject) -> try file["content"] = coffee.compile(file["text"], coffeeConfig) return resolve(file) catch err return reject(err) ) ) registerInternalProcessers: () => @processer.register("index", (p, posts, ctx) => return new Promise((resolve, reject) => try posts.sort((a, b) -> return -(a["date"] - b["date"]) ) return resolve(paginate( p, posts, @site.get("siteConfig")["perPage"], ctx )) catch err return reject(err) ) ) @processer.register("archives", (p, posts, ctx) => return new Promise((resolve, reject) => try posts.sort((a, b) -> return -(a["date"] - b["date"]) ) return resolve(paginate( p, posts, @site.get("siteConfig")["perPage"], ctx )) catch err return reject(err) ) ) @processer.register("categories", (p, posts, ctx) => return new Promise((resolve, reject) => try return resolve(Object.assign(new File(), p, ctx, { "categories": @site.get("categories") })) catch err return reject(err) ) ) @processer.register("tags", (p, posts, ctx) => return new Promise((resolve, reject) => try return resolve(Object.assign(new File(), p, ctx, { "tags": @site.get("tags") })) catch err return reject(err) ) ) @processer.register(["post", "page"], (p, posts, ctx) => return new Promise((resolve, reject) => try getURL = getURLFn( @site.get("siteConfig")["baseURL"], @site.get("siteConfig")["rootDir"] ) getPath = getPathFn(@site.get("siteConfig")["rootDir"]) $ = cheerio.load(p["content"]) # TOC generate. hNames = ["h1", "h2", "h3", "h4", "h5", "h6"] headings = $(hNames.join(", ")) toc = [] headerIds = {} for h in headings level = toc while level.length > 0 and hNames.indexOf(level[level.length - 1]["name"]) < hNames.indexOf(h["name"]) level = level[level.length - 1]["subs"] text = $(h).text() # Remove space in escaped ID because # bootstrap scrollspy cannot support it. escaped = escapeHTML(text).trim().replace(/\s+/, "") if headerIds[escaped] id = "#{escaped}-#{headerIds[escaped]++}" else id = escaped headerIds[escaped] = 1 $(h).attr("id", "#{id}") $(h).html( "<a class=\"headerlink\" href=\"##{id}\" title=\"#{escaped}\">" + "</a>" + "#{text}" ) # Don't set archor to absolute path because bootstrap scrollspy # can only accept relative path for ID. level.push({ "archor": "##{id}", "name": h["name"] "text": text.trim(), "subs": [] }) # Replace relative path to absolute path. links = $("a") for a in links href = $(a).attr("href") if new URL( href, @site.get("siteConfig")["baseURL"] ).host isnt getURL(p["docPath"]).host $(a).attr("target", "_blank") if href.startsWith("https://") or href.startsWith("http://") or href.startsWith("//") or href.startsWith("/") or href.startsWith("javascript:") continue $(a).attr("href", getPath(path.join( path.dirname(p["docPath"]), href ))) imgs = $("img") for i in imgs src = $(i).attr("src") if src.startsWith("https://") or src.startsWith("http://") or src.startsWith("//") or src.startsWith("/") or src.startsWith("file:image") continue $(i).attr("src", getPath(path.join( path.dirname(p["docPath"]), src ))) p["content"] = $("body").html() if p["content"].indexOf("<!--more-->") isnt -1 split = p["content"].split("<!--more-->") p["excerpt"] = split[0] p["more"] = split[1] return resolve(Object.assign( new File(), p, ctx, {"toc": toc, "$": $} )) catch err return reject(err) ) ) registerInternalGenerators: () => @generator.register("beforeProcessing", (site) -> # Generate categories return new Promise((resolve, reject) -> try categories = [] categoriesLength = 0 for post in site.get("posts") if not post["frontMatter"]["categories"]? continue postCategories = [] subCategories = categories for cateName in post["frontMatter"]["categories"] found = false for category in subCategories if category["name"] is cateName found = true postCategories.push(category) category["posts"].push(post) subCategories = category["subs"] break if not found newCate = new Category(cateName, [post], []) ++categoriesLength postCategories.push(newCate) subCategories.push(newCate) subCategories = newCate["subs"] post["categories"] = postCategories categories.sort((a, b) -> return a["name"].localeCompare(b["name"]) ) for sub in categories sortCategories(sub) for p in paginateCategories( sub, site.get("categoryDir"), site.get("siteConfig")["perPage"], site ) site.put("pages", p) site.set("categories", categories) site.set("categoriesLength", categoriesLength) return resolve(site) catch err return reject(err) ) ) @generator.register("beforeProcessing", (site) -> # Generate tags. return new Promise((resolve, reject) -> try tags = [] tagsLength = 0 for post in site.get("posts") if not post["frontMatter"]["tags"]? continue postTags = [] for tagName in post["frontMatter"]["tags"] found = false for tag in tags if tag["name"] is tagName found = true postTags.push(tag) tag["posts"].push(post) break if not found newTag = new Tag(tagName, [post]) ++tagsLength postTags.push(newTag) tags.push(newTag) post["tags"] = postTags tags.sort((a, b) -> return a["name"].localeCompare(b["name"]) ) for tag in tags tag["posts"].sort((a, b) -> return -(a["date"] - b["date"]) ) sp = Object.assign(new File(site.get("docDir")), { "layout": "tag", "docPath": path.join( site.get("tagDir"), "#{tag["name"]}", "index.html" ), "title": "tag", "name": tag["name"].toString() }) tag["docPath"] = sp["docPath"] for p in paginate( sp, tag["posts"], site.get("siteConfig")["perPage"] ) site.put("pages", p) site.set("tags", tags) site.set("tagsLength", tagsLength) return resolve(site) catch err return reject(err) ) ) @generator.register("afterProcessing", (site) -> return new Promise((resolve, reject) -> try if not site.get("siteConfig")["search"]["enable"] return resolve(site) # Generate search index. search = [] all = site.get("pages").concat(site.get("posts")) getPath = getPathFn(site.get("siteConfig")["rootDir"]) for p in all search.push({ "title": "#{p["title"]}", "url": getPath(p["docPath"]), "content": p["text"] }) file = new File(site.get("docDir")) file["docPath"] = site.get( "siteConfig" )["search"]["path"] or "search.json" file["content"] = JSON.stringify(search) site.put("files", file) return resolve(site) catch err return reject(err) ) ) @generator.register("afterProcessing", (site) -> return new Promise((resolve, reject) -> try if not site.get("siteConfig")["sitemap"]["enable"] return resolve(site) # Generate sitemap. tmpContent = fse.readFileSync(path.join( __dirname, "..", "dist", "sitemap.njk" ), "utf8") content = nunjucks.renderString(tmpContent, { "posts": site.get("posts"), "getURL": getURLFn(site.get("siteConfig")["baseURL"], site.get("siteConfig")["rootDir"]), "getPath": getPathFn(site.get("siteConfig")["rootDir"]) }) file = new File(site.get("docDir")) file["docPath"] = site.get( "siteConfig" )["sitemap"]["path"] or "sitemap.xml" file["content"] = content site.put("files", file) return resolve(site) catch err return reject(err) ) ) @generator.register("afterProcessing", (site) -> return new Promise((resolve, reject) -> try if not site.get("siteConfig")["feed"]["enable"] return resolve(site) # Generate RSS feed. tmpContent = fse.readFileSync(path.join( __dirname, "..", "dist", "atom.njk" ), "utf8") content = nunjucks.renderString(tmpContent, { "siteConfig": site.get("siteConfig"), "themeConfig": site.get("themeConfig"), "posts": site.get("posts"), "removeControlChars": removeControlChars, "getURL": getURLFn(site.get("siteConfig")["baseURL"], site.get("siteConfig")["rootDir"]), "getPath": getPathFn(site.get("siteConfig")["rootDir"]) }) file = new File(site.get("docDir")) file["docPath"] = site.get("siteConfig")["feed"]["path"] or "atom.xml" file["content"] = content site.put("files", file) return resolve(site) catch err return reject(err) ) ) module.exports = Hikaru