Carbon/build.js

281 lines
8.1 KiB
JavaScript

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