hikaru-coffee
Version:
A static site generator that generates routes based on directories naturally.
484 lines (463 loc) • 15.2 kB
text/coffeescript
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
)