307 lines
9 KiB
JavaScript
307 lines
9 KiB
JavaScript
const pug = require("pug")
|
|
const sass = require("sass")
|
|
const fs = require("fs")
|
|
const os = require("os")
|
|
const crypto = require("crypto")
|
|
const path = require("path")
|
|
const pj = path.join
|
|
const babel = require("@babel/core")
|
|
const fetch = require("node-fetch")
|
|
const chalk = require("chalk")
|
|
const hint = require("jshint").JSHINT
|
|
const browserify = require("browserify")
|
|
const {Transform} = require("stream")
|
|
|
|
process.chdir(pj(__dirname, "src"))
|
|
|
|
const buildDir = "../build"
|
|
|
|
const validationQueue = []
|
|
const validationHost = os.hostname() === "future" ? "http://localhost:8888/" : "http://validator.w3.org/nu/"
|
|
const staticFiles = new Map()
|
|
const links = new Map()
|
|
const sources = new Map()
|
|
const pugLocals = {static: staticFiles, links}
|
|
|
|
const spec = require("./spec.js")
|
|
|
|
function hash(buffer) {
|
|
return crypto.createHash("sha256").update(buffer).digest("hex").slice(0, 10)
|
|
}
|
|
|
|
function getRelative(targetPath, staticTarget) {
|
|
const pathLayer = (path.dirname(targetPath).replace(/\/$/, "").match(/\//g) || []).length
|
|
const prefix = Array(pathLayer).fill("../").join("")
|
|
const result = prefix + staticTarget.replace(/^\//, "")
|
|
if (result) return result
|
|
else return "./"
|
|
}
|
|
|
|
function validate(filename, body, type) {
|
|
const promise = fetch(validationHost+"?out=json", {
|
|
method: "POST",
|
|
body,
|
|
headers: {
|
|
"content-type": `text/${type}; charset=UTF-8`
|
|
}
|
|
}).then(res => res.json()).then(root => {
|
|
return function cont() {
|
|
let concerningMessages = 0
|
|
for (const message of root.messages) {
|
|
if (message.hiliteStart) {
|
|
let type = message.type
|
|
if (message.type === "error") {
|
|
type = chalk.red("error")
|
|
} else if (message.type === "warning") {
|
|
type = chalk.yellow("warning")
|
|
} else {
|
|
continue // don't care about info
|
|
}
|
|
let match
|
|
if (match = message.message.match(/Property “([\w-]+)” doesn't exist.$/)) {
|
|
// allow these properties specifically
|
|
if (["scrollbar-width", "scrollbar-color", "overflow-anchor"].includes(match[1])) {
|
|
continue
|
|
}
|
|
}
|
|
concerningMessages++
|
|
console.log(`validation: ${type} in ${filename}`)
|
|
console.log(` ${message.message}`)
|
|
const text = message.extract.replace(/\n/g, "⏎").replace(/\t/g, " ")
|
|
console.log(chalk.grey(
|
|
" "
|
|
+ text.slice(0, message.hiliteStart)
|
|
+ chalk.inverse(text.substr(message.hiliteStart, message.hiliteLength))
|
|
+ text.slice(message.hiliteStart+message.hiliteLength)
|
|
))
|
|
} else {
|
|
console.log(message)
|
|
}
|
|
}
|
|
if (!concerningMessages) {
|
|
console.log(`validation: ${chalk.green("ok")} for ${filename}`)
|
|
}
|
|
}
|
|
})
|
|
validationQueue.push(promise)
|
|
return promise
|
|
}
|
|
|
|
function runHint(filename, source) {
|
|
hint(source, {
|
|
esversion: 9,
|
|
undef: true,
|
|
// unused: true,
|
|
loopfunc: true,
|
|
globals: ["console", "URLSearchParams", "staticFiles"],
|
|
browser: true,
|
|
asi: true,
|
|
node: true
|
|
})
|
|
const result = hint.data()
|
|
let problems = 0
|
|
if (result.errors) {
|
|
for (const error of result.errors) {
|
|
if (error.evidence) {
|
|
const text = error.evidence.replace(/\t/g, " ")
|
|
if ([
|
|
"W014"
|
|
].includes(error.code)) continue
|
|
let type = error.code.startsWith("W") ? chalk.yellow("warning") : chalk.red("error")
|
|
console.log(`hint: ${type} in ${filename}`)
|
|
console.log(` ${error.line}:${error.character}: ${error.reason} (${error.code})`)
|
|
console.log(chalk.gray(
|
|
" "
|
|
+ text.slice(0, error.character)
|
|
+ chalk.inverse(text.substr(error.character, 1))
|
|
+ text.slice(error.character+1)
|
|
))
|
|
problems++
|
|
}
|
|
}
|
|
}
|
|
if (problems) {
|
|
console.log(`hint: ${chalk.cyan(problems+" problems")} in ${filename}`)
|
|
} else {
|
|
console.log(`hint: ${chalk.green("ok")} for ${filename}`)
|
|
}
|
|
}
|
|
|
|
async function addFile(sourcePath, targetPath) {
|
|
const contents = await fs.promises.readFile(pj(".", sourcePath), {encoding: null});
|
|
staticFiles.set(sourcePath, `${targetPath}?static=${hash(contents)}`)
|
|
await fs.promises.writeFile(pj(buildDir, targetPath), contents)
|
|
}
|
|
|
|
async function loadJS(sourcePath, targetPath) {
|
|
let content = await fs.promises.readFile(pj(".", sourcePath), {encoding: "utf8"})
|
|
sources.set(sourcePath, content)
|
|
staticFiles.set(sourcePath, `${targetPath}?static=${hash(content)}`)
|
|
}
|
|
|
|
async function addJS(sourcePath, targetPath) {
|
|
let content = sources.get(sourcePath)
|
|
runHint(sourcePath, content)
|
|
await fs.promises.writeFile(pj(buildDir, targetPath), content)
|
|
}
|
|
|
|
async function addBundle(sourcePath, targetPath, module = false) {
|
|
let opts = {}
|
|
if (module) opts.standalone = sourcePath
|
|
const content = await new Promise(resolve => {
|
|
browserify([], opts)
|
|
.add(pj(".", sourcePath))
|
|
.transform(file => {
|
|
let content = ""
|
|
const transform = new Transform({
|
|
transform(chunk, encoding, callback) {
|
|
content += chunk.toString()
|
|
callback(null, chunk)
|
|
}
|
|
})
|
|
transform.on("finish", () => {
|
|
const relativePath = path.relative(process.cwd(), file).replace(/^\/*/, "/")
|
|
runHint(relativePath, content)
|
|
})
|
|
return transform
|
|
})
|
|
.bundle((err, res) => {
|
|
if (err) {
|
|
delete err.stream
|
|
throw err // Quit; problem parsing file to bundle
|
|
}
|
|
resolve(res)
|
|
})
|
|
})
|
|
const writer = fs.promises.writeFile(pj(buildDir, targetPath), content)
|
|
staticFiles.set(sourcePath, `${targetPath}?static=${hash(content)}`)
|
|
await writer
|
|
}
|
|
|
|
async function addSass(sourcePath, targetPath) {
|
|
const renderedCSS = sass.renderSync({
|
|
file: pj(".", sourcePath),
|
|
outputStyle: "expanded",
|
|
indentType: "tab",
|
|
indentWidth: 1,
|
|
functions: {
|
|
"relative($name)": function(name) {
|
|
if (!(name instanceof sass.types.String)) {
|
|
throw "$name: expected a string"
|
|
}
|
|
const result = getRelative(targetPath, staticFiles.get(name.getValue()))
|
|
if (typeof result === "string") {
|
|
return new sass.types.String(result)
|
|
} else {
|
|
throw new Error("static file '"+name.getValue()+"' does not exist")
|
|
}
|
|
}
|
|
}
|
|
}).css;
|
|
staticFiles.set(sourcePath, `${targetPath}?static=${hash(renderedCSS)}`)
|
|
validate(sourcePath, renderedCSS, "css")
|
|
await fs.promises.writeFile(pj(buildDir, targetPath), renderedCSS)
|
|
}
|
|
|
|
async function addPug(sourcePath, targetPath) {
|
|
function getRelativeHere(staticTarget) {
|
|
return getRelative(targetPath, staticTarget)
|
|
}
|
|
function getStatic(target) {
|
|
return getRelativeHere(staticFiles.get(target))
|
|
}
|
|
function getStaticName(target) {
|
|
return getRelativeHere(staticFiles.get(target)).replace(/\?.*$/, "")
|
|
}
|
|
function getLink(target) {
|
|
return getRelativeHere(links.get(target))
|
|
}
|
|
const renderedHTML = pug.compileFile(pj(".", sourcePath), {pretty: true})({getStatic, getStaticName, getLink, ...pugLocals})
|
|
let renderedWithoutPHP = renderedHTML.replace(/<\?(?:php|=).*?\?>/gsm, "")
|
|
validate(sourcePath, renderedWithoutPHP, "html")
|
|
await fs.promises.writeFile(pj(buildDir, targetPath), renderedHTML)
|
|
}
|
|
|
|
async function addBabel(sourcePath, targetPath) {
|
|
const originalCode = await fs.promises.readFile(pj(".", sourcePath), "utf8")
|
|
|
|
const compiled = babel.transformSync(originalCode, {
|
|
sourceMaps: false,
|
|
sourceType: "script",
|
|
presets: [
|
|
[
|
|
"@babel/env", {
|
|
targets: {
|
|
"ie": 11
|
|
}
|
|
}
|
|
]
|
|
],
|
|
generatorOpts: {
|
|
comments: false,
|
|
minified: false,
|
|
sourceMaps: false,
|
|
}
|
|
})
|
|
|
|
const filenameWithQuery = `${targetPath}?static=${hash(compiled.code)}`;
|
|
|
|
staticFiles.set(sourcePath, filenameWithQuery)
|
|
|
|
await Promise.all([
|
|
fs.promises.writeFile(pj(buildDir, targetPath), originalCode),
|
|
fs.promises.writeFile(pj(buildDir, minFilename), compiled.code),
|
|
fs.promises.writeFile(pj(buildDir, mapFilename), JSON.stringify(compiled.map))
|
|
])
|
|
}
|
|
|
|
;(async () => {
|
|
// Stage 1: Register
|
|
for (const item of spec) {
|
|
if (item.type === "pug") {
|
|
links.set(item.source, item.target.replace(/index.html$/, ""))
|
|
}
|
|
}
|
|
|
|
// Stage 2: Load JS
|
|
await Promise.all(spec.filter(item => {
|
|
return item.type === "js"
|
|
}).map(item => {
|
|
return loadJS(item.source, item.target)
|
|
}))
|
|
|
|
// Stage 3: Create dirs
|
|
const dirs = [...new Set(spec.map(item => path.dirname(item.target))).values()]
|
|
await Promise.all(dirs.map(d => fs.promises.mkdir(pj(buildDir, d), {recursive: true})))
|
|
|
|
// Stage 4: Build
|
|
for (const item of spec) {
|
|
if (item.type === "file") {
|
|
await addFile(item.source, item.target)
|
|
} else if (item.type === "js") {
|
|
await addJS(item.source, item.target)
|
|
} else if (item.type === "sass") {
|
|
await addSass(item.source, item.target)
|
|
} else if (item.type === "babel") {
|
|
await addBabel(item.source, item.target)
|
|
} else if (item.type === "pug") {
|
|
await addPug(item.source, item.target)
|
|
} else if (item.type === "bundle") {
|
|
await addBundle(item.source, item.target)
|
|
} else if (item.type === "module") {
|
|
// Creates a standalone bundle that can be imported on runtime
|
|
await addBundle(item.source, item.target, true)
|
|
} else {
|
|
throw new Error("Unknown item type: "+item.type)
|
|
}
|
|
}
|
|
|
|
console.log(chalk.green("All files emitted."))
|
|
|
|
await Promise.all(validationQueue).then(v => {
|
|
console.log(`validation: using host ${chalk.cyan(validationHost)}`)
|
|
v.forEach(cont => cont())
|
|
})
|
|
|
|
console.log(chalk.green("Build complete.") + "\n\n------------\n")
|
|
})()
|