Replace the old Lisp-like array expression representation (["AND", ["PARAM", ...], ...]) with a class hierarchy rooted in an abstract Expression class (Literal, Parameter, Binary, Unary, FunctionCall, Conditional). This gives compile-time type safety to expression construction, evaluation, and pattern matching. Key changes: - New lib/common/expression.ts defines the class hierarchy with map/evaluate/toString methods - Rewrite parser as hand-rolled recursive descent, removing the parsimmon dependency - Extend Path with a colon separator to unify parameter identity and attributes into a single address space (e.g. "Device.WiFi.SSID.1.Name:writable"). The colon field tracks how many trailing segments are attribute qualifiers. PathSet rewritten with separate param/attr segment indexes - Flatten device attribute representation in the UI to colon-delimited keys mirroring the new path syntax
384 lines
11 KiB
TypeScript
384 lines
11 KiB
TypeScript
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import { promisify } from "node:util";
|
|
import { exec } from "node:child_process";
|
|
import * as esbuild from "esbuild";
|
|
import { optimize } from "svgo";
|
|
import * as xmlParser from "../lib/xml-parser.ts";
|
|
|
|
const fsAsync = {
|
|
readdir: promisify(fs.readdir),
|
|
readFile: promisify(fs.readFile),
|
|
writeFile: promisify(fs.writeFile),
|
|
copyFile: promisify(fs.copyFile),
|
|
rename: promisify(fs.rename),
|
|
chmod: promisify(fs.chmod),
|
|
lstat: promisify(fs.lstat),
|
|
exists: promisify(fs.exists),
|
|
rmdir: promisify(fs.rmdir),
|
|
unlink: promisify(fs.unlink),
|
|
mkdir: promisify(fs.mkdir),
|
|
};
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
const MODE = process.env["NODE_ENV"] || "production";
|
|
|
|
const INPUT_DIR = process.cwd();
|
|
const OUTPUT_DIR = path.join(INPUT_DIR, "dist");
|
|
|
|
async function rmDir(dirPath: string): Promise<void> {
|
|
if (!(await fsAsync.exists(dirPath))) return;
|
|
const files = await fsAsync.readdir(dirPath);
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(dirPath, file);
|
|
if ((await fsAsync.lstat(filePath)).isDirectory()) await rmDir(filePath);
|
|
else await fsAsync.unlink(filePath);
|
|
}
|
|
await fsAsync.rmdir(dirPath);
|
|
}
|
|
|
|
// For lockfileVersion = 1
|
|
function stripDevDeps(deps): void {
|
|
if (!deps["dependencies"]) return;
|
|
for (const [k, v] of Object.entries(deps["dependencies"])) {
|
|
if (v["dev"]) delete deps["dependencies"][k];
|
|
else stripDevDeps(v);
|
|
}
|
|
if (!Object.keys(deps["dependencies"]).length) delete deps["dependencies"];
|
|
}
|
|
|
|
// For lockfileVersion = 2
|
|
function stripDevDeps2(deps): void {
|
|
if (!deps["packages"]) return;
|
|
for (const [k, v] of Object.entries(deps["packages"])) {
|
|
delete v["devDependencies"];
|
|
if (v["dev"]) delete deps["packages"][k];
|
|
}
|
|
}
|
|
|
|
function xmlTostring(xml): string {
|
|
const children = [];
|
|
for (const c of xml.children || []) children.push(xmlTostring(c));
|
|
|
|
return xml.name === "root" && xml.bodyIndex === 0
|
|
? children.join("")
|
|
: `<${xml.name} ${xml.attrs}>${children.join("")}</${xml.name}>`;
|
|
}
|
|
|
|
function assetHash(buffer: Buffer | string): string {
|
|
return createHash("md5").update(buffer).digest("hex").slice(0, 8);
|
|
}
|
|
|
|
const ASSETS = {} as {
|
|
APP_JS?: string;
|
|
APP_CSS?: string;
|
|
ICONS_SVG?: string;
|
|
LOGO_SVG?: string;
|
|
FAVICON_PNG?: string;
|
|
};
|
|
|
|
const assetsPlugin = {
|
|
name: "assets",
|
|
setup(build) {
|
|
build.onLoad({ filter: /\/build\/assets.ts$/ }, () => {
|
|
const lines = Object.entries(ASSETS).map(
|
|
([k, v]) => `export const ${k} = ${JSON.stringify(v)};`,
|
|
);
|
|
return { contents: lines.join("\n") };
|
|
});
|
|
},
|
|
} as esbuild.Plugin;
|
|
|
|
const packageDotJsonPlugin = {
|
|
name: "packageDotJson",
|
|
setup(build) {
|
|
const sourcePath = path.join(INPUT_DIR, "package.json");
|
|
build.onResolve({ filter: /\/package.json$/ }, (args) => {
|
|
const p = path.join(args.resolveDir, args.path);
|
|
if (p !== sourcePath) return undefined;
|
|
return { path: path.join(OUTPUT_DIR, "package.json") };
|
|
});
|
|
},
|
|
} as esbuild.Plugin;
|
|
|
|
const inlineDepsPlugin = {
|
|
name: "inlineDeps",
|
|
setup(build) {
|
|
const deps = ["espresso-iisojs", "@codemirror", "mithril", "yaml"];
|
|
const depFiles = new Set();
|
|
build.onResolve({ filter: /^[^.]/ }, async (args) => {
|
|
if (args.pluginData === "inlineDeps") return undefined;
|
|
if (
|
|
depFiles.has(args.importer) ||
|
|
deps.some((d) => args.path.startsWith(d))
|
|
) {
|
|
const res = await build.resolve(args.path, {
|
|
importer: args.importer,
|
|
namespace: args.namespace,
|
|
resolveDir: args.resolveDir,
|
|
kind: args.kind,
|
|
with: args.with,
|
|
pluginData: "inlineDeps",
|
|
});
|
|
depFiles.add(res.path);
|
|
return res;
|
|
}
|
|
return { sideEffects: false, external: true };
|
|
});
|
|
},
|
|
} as esbuild.Plugin;
|
|
|
|
function generateSymbol(id: string, svgStr: string): string {
|
|
const xml = xmlParser.parseXml(svgStr);
|
|
const svg = xml.children[0];
|
|
const svgAttrs = xmlParser.parseAttrs(svg.attrs);
|
|
let viewBox = "";
|
|
for (const a of svgAttrs) {
|
|
if (a.name === "viewBox") {
|
|
viewBox = `viewBox="${a.value}"`;
|
|
break;
|
|
}
|
|
}
|
|
const symbolBody = xml.children[0].children
|
|
.map((c) => xmlTostring(c))
|
|
.join("");
|
|
return `<symbol id="icon-${id}" ${viewBox}>${symbolBody}</symbol>`;
|
|
}
|
|
|
|
async function getBuildMetadata(): Promise<string> {
|
|
const date = new Date().toISOString().slice(2, 10).replaceAll("-", "");
|
|
|
|
const [commit, diff, newFiles] = await Promise.all([
|
|
execAsync("git rev-parse HEAD"),
|
|
execAsync("git diff HEAD"),
|
|
execAsync("git ls-files --others --exclude-standard"),
|
|
]).then((res) => res.map((r) => r.stdout.trim()));
|
|
|
|
if (!diff && !newFiles) return date + commit.slice(0, 4);
|
|
|
|
const hash = createHash("md5");
|
|
hash.update(commit).update(diff).update(newFiles);
|
|
for (const file of newFiles.split("\n").filter((f) => f))
|
|
hash.update(await fsAsync.readFile(file));
|
|
return date + hash.digest("hex").slice(0, 4);
|
|
}
|
|
|
|
async function init(): Promise<void> {
|
|
const [buildMetadata, packageJsonFile, npmShrinkwrapFile] = await Promise.all(
|
|
[
|
|
getBuildMetadata(),
|
|
fsAsync.readFile(path.join(INPUT_DIR, "package.json")),
|
|
fsAsync.readFile(path.join(INPUT_DIR, "npm-shrinkwrap.json")),
|
|
],
|
|
);
|
|
|
|
const packageJson = JSON.parse(packageJsonFile.toString());
|
|
delete packageJson["devDependencies"];
|
|
delete packageJson["private"];
|
|
delete packageJson["scripts"];
|
|
packageJson["version"] = `${packageJson["version"]}+${buildMetadata}`;
|
|
|
|
const npmShrinkwrap = JSON.parse(npmShrinkwrapFile.toString());
|
|
npmShrinkwrap["version"] = packageJson["version"];
|
|
stripDevDeps(npmShrinkwrap);
|
|
stripDevDeps2(npmShrinkwrap);
|
|
|
|
await rmDir(OUTPUT_DIR);
|
|
|
|
await fsAsync.mkdir(OUTPUT_DIR);
|
|
|
|
await Promise.all([
|
|
fsAsync.mkdir(path.join(OUTPUT_DIR, "bin")),
|
|
fsAsync.mkdir(path.join(OUTPUT_DIR, "public")),
|
|
fsAsync.writeFile(
|
|
path.join(OUTPUT_DIR, "package.json"),
|
|
JSON.stringify(packageJson, null, 2),
|
|
),
|
|
fsAsync.writeFile(
|
|
path.join(OUTPUT_DIR, "npm-shrinkwrap.json"),
|
|
JSON.stringify(npmShrinkwrap, null, 2),
|
|
),
|
|
]);
|
|
}
|
|
|
|
async function copyStatic(): Promise<void> {
|
|
const files = [
|
|
"LICENSE",
|
|
"README.md",
|
|
"CHANGELOG.md",
|
|
"public/logo.svg",
|
|
"public/favicon.png",
|
|
];
|
|
|
|
const [logo, favicon] = await Promise.all([
|
|
fsAsync.readFile(path.join(INPUT_DIR, "public/logo.svg")),
|
|
fsAsync.readFile(path.join(INPUT_DIR, "public/favicon.png")),
|
|
]);
|
|
|
|
ASSETS.LOGO_SVG = `logo-${assetHash(logo)}.svg`;
|
|
ASSETS.FAVICON_PNG = `favicon-${assetHash(favicon)}.png`;
|
|
|
|
const filenames = {} as Record<string, string>;
|
|
filenames["public/logo.svg"] = path.join("public", ASSETS.LOGO_SVG);
|
|
filenames["public/favicon.png"] = path.join("public", ASSETS.FAVICON_PNG);
|
|
|
|
await Promise.all(
|
|
files.map((f) =>
|
|
fsAsync.copyFile(
|
|
path.join(INPUT_DIR, f),
|
|
path.join(OUTPUT_DIR, filenames[f] || f),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function generateCss(): Promise<void> {
|
|
const tailwindPlugin = {
|
|
name: "tailwind",
|
|
setup(build) {
|
|
build.onLoad({ filter: /\/ui\/css\/app.css$/ }, async (args) => {
|
|
const res = await execAsync(`npx @tailwindcss/cli -i ${args.path}`);
|
|
return { loader: "css", contents: res.stdout };
|
|
});
|
|
},
|
|
} as esbuild.Plugin;
|
|
|
|
const res = await esbuild.build({
|
|
bundle: true,
|
|
absWorkingDir: INPUT_DIR,
|
|
minify: MODE === "production",
|
|
sourcemap: "linked",
|
|
sourcesContent: false,
|
|
entryPoints: ["ui/css/app.css"],
|
|
entryNames: "[dir]/[name]-[hash]",
|
|
outfile: path.join(OUTPUT_DIR, "public/app.css"),
|
|
plugins: [tailwindPlugin],
|
|
loader: {
|
|
".woff2": "dataurl",
|
|
},
|
|
target: ["chrome111", "safari16.4", "firefox128"],
|
|
metafile: true,
|
|
});
|
|
|
|
for (const [k, v] of Object.entries(res.metafile.outputs)) {
|
|
if (v.entryPoint === "ui/css/app.css") {
|
|
ASSETS.APP_CSS = path.relative(
|
|
path.join(OUTPUT_DIR, "public"),
|
|
path.join(INPUT_DIR, k),
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function generateBackendJs(): Promise<void> {
|
|
const services = [
|
|
"genieacs-cwmp",
|
|
"genieacs-ext",
|
|
"genieacs-nbi",
|
|
"genieacs-fs",
|
|
"genieacs-ui",
|
|
];
|
|
|
|
await esbuild.build({
|
|
bundle: true,
|
|
absWorkingDir: INPUT_DIR,
|
|
minify: MODE === "production",
|
|
sourcemap: "inline",
|
|
sourcesContent: false,
|
|
platform: "node",
|
|
target: "node12.13.0",
|
|
packages: "external",
|
|
banner: { js: "#!/usr/bin/env node" },
|
|
entryPoints: services.map((s) => `bin/${s}.ts`),
|
|
outdir: path.join(OUTPUT_DIR, "bin"),
|
|
plugins: [packageDotJsonPlugin, assetsPlugin],
|
|
});
|
|
|
|
for (const bin of services) {
|
|
const p = path.join(OUTPUT_DIR, "bin", bin);
|
|
await fsAsync.rename(`${p}.js`, p);
|
|
// Mark as executable
|
|
const mode = (await fsAsync.lstat(p)).mode;
|
|
await fsAsync.chmod(p, mode | 73);
|
|
}
|
|
}
|
|
|
|
async function generateFrontendJs(): Promise<void> {
|
|
const res = await esbuild.build({
|
|
bundle: true,
|
|
absWorkingDir: INPUT_DIR,
|
|
splitting: true,
|
|
minify: MODE === "production",
|
|
sourcemap: "linked",
|
|
sourcesContent: false,
|
|
platform: "browser",
|
|
format: "esm",
|
|
target: ["chrome111", "safari16.4", "firefox128"],
|
|
entryPoints: ["ui/app.ts"],
|
|
entryNames: "[dir]/[name]-[hash]",
|
|
outdir: path.join(OUTPUT_DIR, "public"),
|
|
plugins: [packageDotJsonPlugin, inlineDepsPlugin, assetsPlugin],
|
|
metafile: true,
|
|
});
|
|
|
|
for (const [k, v] of Object.entries(res.metafile.outputs)) {
|
|
for (const imp of v.imports)
|
|
if (imp.external) throw new Error(`External import found: ${imp.path}`);
|
|
|
|
if (v.entryPoint === "ui/app.ts") {
|
|
ASSETS.APP_JS = path.relative(
|
|
path.join(OUTPUT_DIR, "public"),
|
|
path.join(INPUT_DIR, k),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function generateIconsSprite(): Promise<void> {
|
|
const symbols = [] as string[];
|
|
const iconsDir = path.join(INPUT_DIR, "ui/icons");
|
|
for (const file of await fsAsync.readdir(iconsDir)) {
|
|
const id = path.parse(file).name;
|
|
const filePath = path.join(iconsDir, file);
|
|
const src = (await fsAsync.readFile(filePath)).toString();
|
|
const { data } = await optimize(src, {
|
|
plugins: [
|
|
{
|
|
name: "preset-default",
|
|
params: {
|
|
overrides: {
|
|
removeViewBox: false,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
symbols.push(generateSymbol(id, data));
|
|
}
|
|
const data = `<svg xmlns="http://www.w3.org/2000/svg">${symbols.join(
|
|
"",
|
|
)}</svg>`;
|
|
ASSETS.ICONS_SVG = `icons-${assetHash(data)}.svg`;
|
|
await fsAsync.writeFile(
|
|
path.join(OUTPUT_DIR, "public", ASSETS.ICONS_SVG),
|
|
data,
|
|
);
|
|
}
|
|
|
|
init()
|
|
.then(() =>
|
|
Promise.all([
|
|
Promise.all([generateIconsSprite(), copyStatic()]).then(
|
|
generateFrontendJs,
|
|
),
|
|
generateCss(),
|
|
]).then(generateBackendJs),
|
|
)
|
|
.catch((err) => {
|
|
process.stderr.write(err.stack + "\n");
|
|
});
|