UNPKG

hikaru-coffee

Version:

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

484 lines (463 loc) 15.2 kB
fse = require("fs-extra") path = require("path") {URL} = require("url") glob = require("glob") cheerio = require("cheerio") moment = require("moment") 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") Renderer = require("./renderer") Generator = require("./generator") Translator = require("./translator") Router = require("./router") { escapeHTML, removeControlChars, paginate, dateStrCompare, sortCategories, paginateCategories, getAbsPathFn, getURLFn } = require("./utils") module.exports = class Hikaru constructor: (debug = false) -> @debug = debug @logger = new Logger(@debug) @logger.debug("Hikaru is starting...") process.on("exit", () => @logger.debug("Hikaru is stopping...") ) init: (workDir = ".", configPath) => return fse.mkdirp(workDir).then(() => @logger.debug("Hikaru started initialization in `#{path.join( workDir, path.sep )}`.") return fse.copy( path.join(__dirname, "..", "dist", "config.yml"), configPath or path.join(workDir, "config.yml") ) ).then(() => @logger.debug("Hikaru copyed `#{configPath or path.join( workDir, "config.yml" )}`.") return fse.mkdirp(path.join(workDir, "src")) ).then(() => @logger.debug("Hikaru created `#{path.join( workDir, "src", path.sep )}`.") return fse.copy( path.join(__dirname, "..", "dist", "archives.md"), path.join(workDir, "src", "archives", "index.md") ) ).then(() => @logger.debug("Hikaru copyed `#{path.join( workDir, "src", "archives", "index.md" )}`.") return fse.copy( path.join(__dirname, "..", "dist", "categories.md"), path.join(workDir, "src", "categories", "index.md") ) ).then(() => @logger.debug("Hikaru copyed `#{path.join( workDir, "src", "categories", "index.md" )}`.") return fse.copy( path.join(__dirname, "..", "dist", "tags.md"), path.join(workDir, "src", "tags", "index.md") ) ).then(() => @logger.debug("Hikaru copyed `#{path.join( workDir, "src", "tags", "index.md" )}`.") return fse.mkdirp(path.join(workDir, "doc")) ).then(() => @logger.debug("Hikaru created `#{path.join( workDir, "doc", path.sep )}`.") return fse.mkdirp(path.join(workDir, "themes")) ).then(() => @logger.debug("Hikaru created `#{path.join( workDir, "themes", path.sep )}`.") @logger.debug("Hikaru finished initialization in `#{workDir}`.") ).catch(@logger.error) clean: (workDir = ".", configPath) => configPath = configPath or path.join(workDir, "config.yml") siteConfig = yaml.safeLoad(fse.readFileSync(configPath, "utf8")) if siteConfig?["docDir"]? glob("*", { "cwd": path.join(workDir, siteConfig["docDir"]) }, (err, res) => if err return err for r in res then do (r) => fse.remove(path.join(workDir, siteConfig["docDir"], r)).then(() => @logger.debug("Hikaru removed `#{path.join( workDir, siteConfig["docDir"], r )}`.") ).catch(@logger.error) ) generate: (workDir = ".", configPath) => @site = { "workDir": workDir "templates": {}, "assets": [], "pages": [], "posts": [], "data": [] } configPath = configPath or path.join(@site["workDir"], "config.yml") @site["siteConfig"] = yaml.safeLoad(fse.readFileSync(configPath, "utf8")) @site["srcDir"] = path.join( @site["workDir"], @site["siteConfig"]["srcDir"] ) @site["docDir"] = path.join( @site["workDir"], @site["siteConfig"]["docDir"] ) @site["themeDir"] = path.join( @site["workDir"], "themes", @site["siteConfig"]["themeDir"] ) @site["themeSrcDir"] = path.join(@site["themeDir"], "src") try @site["themeConfig"] = yaml.safeLoad( fse.readFileSync(path.join(@site["themeDir"], "config.yml")) ) catch err if err["code"] is "ENOENT" @logger.info("Hikaru continues with a empty theme config...") @site["themeConfig"] = {} @renderer = new Renderer(@logger, @site["siteConfig"]["skipRender"]) @generator = new Generator(@logger) @translator = new Translator(@logger) defaultLanguage = yaml.safeLoad( fse.readFileSync( path.join(@site["themeDir"], "languages", "default.yml") ) ) @translator.register("default", defaultLanguage) @router = new Router(@logger, @renderer, @generator, @translator, @site) @registerInternalRenderers() @registerInternalGenerators() @registerInternalRoutes() @router.route() registerInternalRenderers: () => njkConfig = Object.assign( {"autoescape": false}, @site["siteConfig"]["nunjucks"] ) njkEnv = nunjucks.configure(@site["themeSrcDir"], njkConfig) @renderer.register([".njk", ".j2"], null, (data, ctx) -> # For template you must give a render function. template = nunjucks.compile(data["text"], njkEnv, data["srcPath"]) data["content"] = (ctx) -> return new Promise((resolve, reject) -> template.render(ctx, (err, res) -> if err return reject(err) return resolve(res) ) ) ) markedConfig = Object.assign( {"gfm": true}, @site["siteConfig"]["marked"] ) renderer = new marked.Renderer() renderer.heading = (text, level) -> escaped = escapeHTML(text) return "<h#{level} id=\"#{escaped}\">" + "<a class=\"headerlink\" href=\"##{escaped}\" title=\"#{escaped}\">" + "</a>" + "#{text}" + "</h#{level}>" marked.setOptions({ "langPrefix": "", "highlight": (code, lang) -> return highlight(code, { "lang": lang?.toLowerCase(), "hljs": markedConfig["hljs"] or true, "gutter": markedConfig["gutter"] or true }) }) @renderer.register(".md", ".html", (data, ctx) -> return new Promise((resolve, reject) -> try data["content"] = marked( data["text"], Object.assign({"renderer": renderer}, markedConfig) ) return resolve(data) catch err return reject(err) ) ) stylConfig = @site["siteConfig"]["stylus"] or {} @renderer.register(".styl", ".css", (data, ctx) => return new Promise((resolve, reject) => stylus(data["text"]) .use(nib()) .use((style) => style.define("getSiteConfig", (data) => keys = data["val"].toString().split(".") res = @site["siteConfig"] for k in keys if k not of res return null res = res[k] return res ) ).use((style) => style.define("getThemeConfig", (data) => keys = data["val"].toString().split(".") res = @site["themeConfig"] for k in keys if k not of res return null res = res[k] return res ) ).set("filename", path.join(@site["themeSrcDir"], data["srcPath"])) .set("sourcemap", stylConfig["sourcemap"]) .set("compress", stylConfig["compress"]) .set("include css", true) .render((err, res) -> if err return reject(err) data["content"] = res return resolve(data) ) ) ) # TODO: CoffeeScript render. @renderer.register(".coffee", ".js", (data, ctx) ->) registerInternalGenerators: () => @generator.register("index", (page, posts, ctx) => posts.sort(dateStrCompare) return paginate(page, posts, ctx, @site["siteConfig"]["perPage"]) ) @generator.register("archives", (page, posts, ctx) => posts.sort(dateStrCompare) return paginate(page, posts, ctx, @site["siteConfig"]["perPage"]) ) @generator.register("categories", (page, posts, ctx) => results = [] for sub in ctx["site"]["categories"] results = results.concat(paginateCategories( sub, page, path.dirname(page["docPath"]), @site["siteConfig"]["perPage"], ctx )) results.push(Object.assign({}, page, ctx, { "categories": ctx["site"]["categories"] })) return results ) @generator.register("tags", (page, posts, ctx) => results = [] for tag in ctx["site"]["tags"] p = Object.assign({}, page) p["layout"] = "tag" p["docPath"] = path.join(path.dirname(page["docPath"]), "#{tag["name"]}", "index.html") tag["docPath"] = p["docPath"] p["title"] = "tag" p["name"] = tag["name"].toString() results = results.concat(paginate(p, tag["posts"], ctx, @site["siteConfig"]["perPage"])) results.push(Object.assign({}, page, ctx, { "tags": ctx["site"]["tags"] })) return results ) @generator.register(["post", "page"], (page, posts, ctx) => $ = cheerio.load(page["content"]) # TOC generate. hNames = ["h1", "h2", "h3", "h4", "h5", "h6"] headings = $(hNames.join(", ")) toc = [] 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"] level.push({ "id": $(h).attr("id"), "name": h["name"] "text": $(h).text().trim(), "subs": [] }) # Replace relative path to absolute path. getURL = getURLFn( @site["siteConfig"]["baseURL"], @site["siteConfig"]["rootDir"] ) links = $("a") for a in links href = $(a).attr("href") if new URL(href, @site["siteConfig"]["baseURL"]).host isnt getURL().host $(a).attr("target", "_blank") if href.startsWith("https://") or href.startsWith("http://") or href.startsWith("//") or href.startsWith("/") or href.startsWith("#") continue $(a).attr("href", path.posix.join(path.posix.sep, path.posix.dirname(page["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("data:image") continue $(i).attr("src", path.posix.join(path.posix.sep, path.posix.dirname(page["docPath"]), src)) page["content"] = $("body").html() if page["content"].indexOf("<!--more-->") isnt -1 split = page["content"].split("<!--more-->") page["excerpt"] = split[0] page["more"] = split[1] return Object.assign({}, page, ctx, {"toc": toc}) ) registerInternalRoutes: () => @router.register("beforeGenerating", (site) -> # Generate categories ### [ { "name"String, "posts": [Post], "subs": [ { "name": String, "posts": [Post], "subs": [] } ] } ] ### categories = [] categoriesLength = 0 for post in site["posts"] if not post["categories"]? continue postCategories = [] subCategories = categories for cateName in post["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 = {"name": cateName, "posts": [post], "subs": []} ++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) site["categories"] = categories site["categoriesLength"] = categoriesLength return site ) @router.register("beforeGenerating", (site) -> # Generate tags. ### [ { "name": String, "posts": [Post] } ] ### tags = [] tagsLength = 0 for post in site["posts"] if not post["tags"]? continue postTags = [] for tagName in post["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 = {"name": tagName, "posts": [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(dateStrCompare) site["tags"] = tags site["tagsLength"] = tagsLength return site ) @router.register("afterGenerating", (site) -> if not site["siteConfig"]["search"]["enable"] return site # Generate search index. search = [] all = site["pages"].concat(site["posts"]) getAbsPath = getAbsPathFn(site["siteConfig"]["rootDir"]) for p in all search.push({ "title": "#{p["title"]}", "url": getAbsPath(p["docPath"]), "content": p["text"] }) site["data"].push({ # "srcPath": site["siteConfig"]["search"]["path"] or "search.json", "docPath": site["siteConfig"]["search"]["path"] or "search.json", "content": JSON.stringify(search) }) return site ) @router.register("afterGenerating", (site) -> if not site["siteConfig"]["feed"]["enable"] return site # Generate RSS feed. tmpContent = fse.readFileSync(path.join( __dirname, "..", "dist", "atom.njk" ), "utf8") content = nunjucks.renderString(tmpContent, { "site": site, "siteConfig": site["siteConfig"], "themeConfig": site["themeConfig"], "posts": site["posts"], "moment": moment, "removeControlChars": removeControlChars, "getURL": getURLFn(site["siteConfig"]["baseURL"], site["siteConfig"]["rootDir"]), "getAbsPath": getAbsPathFn(site["siteConfig"]["rootDir"]) }) site["data"].push({ # "srcPath": site["siteConfig"]["feed"]["path"] or "atom.xml", "docPath": site["siteConfig"]["feed"]["path"] or "atom.xml", "content": content }) return site )