const pug = require("pug"); const sass = require("sass"); const fs = require("fs").promises; 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; process.chdir(pj(__dirname, "src")); const buildDir = "../build"; const validationQueue = []; const validationHost = os.hostname() === "future" ? "http://localhost:8888/" : "http://validator.w3.org/nu/"; const static = new Map(); const links = new Map(); const pugLocals = { static, links }; const spec = require("./spec.js"); function hash(buffer) { return crypto.createHash("sha256").update(buffer).digest("hex").slice(0, 10); } 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"], browser: true, asi: 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.readFile(pj(".", sourcePath), { encoding: null }); static.set(sourcePath, `${targetPath}?static=${hash(contents)}`); fs.writeFile(pj(buildDir, targetPath), contents); } async function addJS(sourcePath, targetPath) { const source = await fs.readFile(pj(".", sourcePath), { encoding: "utf8" }); static.set(sourcePath, `${targetPath}?static=${hash(source)}`); runHint(sourcePath, source); fs.writeFile(pj(buildDir, targetPath), source); } async function addSass(sourcePath, targetPath) { const renderedCSS = sass.renderSync({ file: pj(".", sourcePath), outputStyle: "expanded", indentType: "tab", indentWidth: 1, functions: { "static($name)": function (name) { if (!(name instanceof sass.types.String)) { throw "$name: expected a string"; } const result = static.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; static.set(sourcePath, `${targetPath}?static=${hash(renderedCSS)}`); validate(sourcePath, renderedCSS, "css"); await fs.writeFile(pj(buildDir, targetPath), renderedCSS); } async function addPug(sourcePath, targetPath) { function getRelative(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 getStatic(target) { return getRelative(static.get(target)); } function getStaticName(target) { return getRelative(static.get(target)).replace(/\?.*$/, ""); } function getLink(target) { return getRelative(links.get(target)); } const renderedHTML = pug.compileFile(pj(".", sourcePath), { pretty: true })({ getStatic, getStaticName, getLink, ...pugLocals, }); let renderedWithoutPHP = renderedHTML.replace(/<\?(?:php|=).*?\?>/gms, ""); validate(sourcePath, renderedWithoutPHP, "html"); await fs.writeFile(pj(buildDir, targetPath), renderedHTML); } async function addBabel(sourcePath, targetPath) { const originalCode = await fs.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)}`; static.set(sourcePath, filenameWithQuery); await Promise.all([ fs.writeFile(pj(buildDir, targetPath), originalCode), fs.writeFile(pj(buildDir, minFilename), compiled.code), fs.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: 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 { 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"); })();