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") })()