mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-11 02:44:57 +08:00
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
/**
|
|
* Copyright (C) 2025 eamonxg <eamonxiong@gmail.com>
|
|
* Licensed under the Apache License, Version 2.0.
|
|
*
|
|
* Theme identity is in luci-theme.config.js. The dev/serve layer (local
|
|
* serve, mock pages, .ut sync, redirect) and the client router come from
|
|
* @eamonxg/luci-theme-devkit; only the build transforms that shape THIS
|
|
* theme's htdocs output stay here.
|
|
*/
|
|
|
|
import tailwindcss from "@tailwindcss/vite";
|
|
import { luciRouter } from "@eamonxg/luci-theme-devkit/vite/router";
|
|
import { luciDev, injectMockBar } from "@eamonxg/luci-theme-devkit/vite/dev";
|
|
import browserslist from "browserslist";
|
|
import { existsSync, readdirSync } from "fs";
|
|
import { mkdir, readdir, readFile, rm, writeFile } from "fs/promises";
|
|
import {
|
|
browserslistToTargets,
|
|
transform as lightningcssTransform,
|
|
} from "lightningcss";
|
|
import { dirname, join, resolve } from "path";
|
|
import { minify as terserMinify } from "terser";
|
|
import { defineConfig, loadEnv, Plugin, ResolvedConfig } from "vite";
|
|
import config from "./luci-theme.config.js";
|
|
|
|
const CURRENT_DIR = process.cwd();
|
|
const PROJECT_ROOT = resolve(CURRENT_DIR, "..");
|
|
const BUILD_OUTPUT = resolve(PROJECT_ROOT, "htdocs/luci-static");
|
|
const PATCH_SRC_DIR = resolve(CURRENT_DIR, "src/media/patches");
|
|
|
|
const LIGHTNINGCSS_TARGETS = browserslistToTargets(
|
|
browserslist("last 4 versions, Firefox ESR, not dead"),
|
|
);
|
|
|
|
// See luci-theme.config.js. Empty today: the log viewer needs only
|
|
// admin-status-logs, whose prefix covers both log pages on every release.
|
|
const PATCH_ALIASES: Record<string, string[]> = config.patchAliases ?? {};
|
|
|
|
const tag = (name: string): string =>
|
|
`${new Date().toLocaleTimeString("en-US")} [${name}]`;
|
|
|
|
function createLuciJsCompressPlugin(): Plugin {
|
|
let outDir: string;
|
|
|
|
return {
|
|
name: "luci-js-compress",
|
|
apply: "build",
|
|
configResolved(config: ResolvedConfig) {
|
|
outDir = config.build.outDir;
|
|
},
|
|
async generateBundle() {
|
|
const srcDir = resolve(CURRENT_DIR, "src/resource");
|
|
const jsFiles = (await readdir(srcDir, { recursive: true })).filter((f) =>
|
|
f.endsWith(".js"),
|
|
);
|
|
await Promise.all(
|
|
jsFiles.map(async (relPath) => {
|
|
const normalized = relPath.replace(/\\/g, "/");
|
|
try {
|
|
const sourceCode = await readFile(join(srcDir, relPath), "utf-8");
|
|
const compressed = await terserMinify(sourceCode, {
|
|
parse: { bare_returns: true },
|
|
/* LuCI dependency declarations are string directives. Keep
|
|
them while enabling normal compression and local mangling. */
|
|
compress: { directives: false, passes: 2 },
|
|
mangle: true,
|
|
format: { comments: false, beautify: false },
|
|
});
|
|
// patches/* are payloads of the on-demand patches mechanism and
|
|
// ship next to the CSS patches (media dir), not under resources/.
|
|
const stem = normalized.startsWith("patches/")
|
|
? normalized.slice("patches/".length, -".js".length)
|
|
: null;
|
|
const outputPaths = stem
|
|
? [stem, ...(PATCH_ALIASES[stem] ?? [])].map((p) =>
|
|
join(outDir, "aurora", "patches", `${p}.js`),
|
|
)
|
|
: [join(outDir, "resources", normalized)];
|
|
for (const outputPath of outputPaths) {
|
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
await writeFile(
|
|
outputPath,
|
|
compressed.code || sourceCode,
|
|
"utf-8",
|
|
);
|
|
}
|
|
} catch (error: any) {
|
|
console.error(
|
|
`${tag("JS Compress")} src/resource/${normalized}: ${error?.message}`,
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/* Duplicate built CSS patches under their PATCH_ALIASES names (JS aliases are
|
|
handled inside the compress plugin). Runs post-bundle because the sources
|
|
are Rollup entries. */
|
|
function createPatchAliasPlugin(): Plugin {
|
|
return {
|
|
name: "patch-alias",
|
|
apply: "build",
|
|
enforce: "post",
|
|
async closeBundle() {
|
|
for (const [stem, aliases] of Object.entries(PATCH_ALIASES)) {
|
|
const source = resolve(BUILD_OUTPUT, `aurora/patches/${stem}.css`);
|
|
if (!existsSync(source)) continue;
|
|
const css = await readFile(source, "utf-8");
|
|
for (const alias of aliases)
|
|
await writeFile(
|
|
resolve(BUILD_OUTPUT, `aurora/patches/${alias}.css`),
|
|
css,
|
|
"utf-8",
|
|
);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
async function removeMacOsMetadata(dir: string): Promise<void> {
|
|
if (!existsSync(dir)) return;
|
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) await removeMacOsMetadata(path);
|
|
else if (entry.name === ".DS_Store") await rm(path, { force: true });
|
|
}
|
|
}
|
|
|
|
/* login.css imports the full shared token sheet but the login page consumes
|
|
only a fraction of it. Prune every custom-property declaration (and --tw-*
|
|
@property registration) that no var() reference can reach, following
|
|
declaration-value chains (--a: var(--b) keeps --b alive). --login-bg and
|
|
--login-bg-lqip stay consumed-without-declaration by design: header.ut
|
|
injects them from UCI at render time, so pruning never touches consumers. */
|
|
function createLoginCssPrunePlugin(): Plugin {
|
|
return {
|
|
name: "login-css-prune",
|
|
apply: "build",
|
|
enforce: "post",
|
|
async closeBundle() {
|
|
const path = resolve(BUILD_OUTPUT, "aurora/login.css");
|
|
if (!existsSync(path)) return;
|
|
const css = await readFile(path, "utf-8");
|
|
|
|
// Roots are vars consumed by normal declarations; custom-property
|
|
// declarations only contribute edges to the reachability walk.
|
|
const VALUE = `(?:"[^"]*"|'[^']*'|[^;{}"'])*`;
|
|
const roots = new Set<string>();
|
|
const edges = new Map<string, Set<string>>();
|
|
for (const [, name, value] of css.matchAll(
|
|
new RegExp(`(?<=[{;])(--[\\w-]+|[a-zA-Z-]+):(${VALUE})`, "g"),
|
|
)) {
|
|
const refs = [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map(
|
|
(m) => m[1],
|
|
);
|
|
if (!name.startsWith("--")) refs.forEach((r) => roots.add(r));
|
|
else {
|
|
const deps = edges.get(name) ?? new Set<string>();
|
|
refs.forEach((r) => deps.add(r));
|
|
edges.set(name, deps);
|
|
}
|
|
}
|
|
const keep = new Set(roots);
|
|
const stack = [...roots];
|
|
while (stack.length) {
|
|
for (const dep of edges.get(stack.pop()!) ?? []) {
|
|
if (!keep.has(dep)) {
|
|
keep.add(dep);
|
|
stack.push(dep);
|
|
}
|
|
}
|
|
}
|
|
|
|
const pruned = css
|
|
.replace(
|
|
new RegExp(`(?<=[{;])(--[\\w-]+):${VALUE};?`, "g"),
|
|
(decl, name) => (keep.has(name) ? decl : ""),
|
|
)
|
|
.replace(/@property\s+(--[\w-]+)\{[^{}]*\}/g, (rule, name) =>
|
|
keep.has(name) ? rule : "",
|
|
);
|
|
|
|
// Re-minify with the shared targets: validates the edited syntax and
|
|
// drops any rule the pruning emptied out.
|
|
const { code } = lightningcssTransform({
|
|
filename: "login.css",
|
|
code: Buffer.from(pruned),
|
|
minify: true,
|
|
targets: LIGHTNINGCSS_TARGETS,
|
|
});
|
|
await writeFile(path, code);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createPackageHygienePlugin(): Plugin {
|
|
return {
|
|
name: "package-hygiene",
|
|
apply: "build",
|
|
enforce: "post",
|
|
async closeBundle() {
|
|
await Promise.all([
|
|
removeMacOsMetadata(resolve(PROJECT_ROOT, "htdocs")),
|
|
removeMacOsMetadata(resolve(PROJECT_ROOT, "ucode")),
|
|
]);
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig(({ mode }) => {
|
|
const env = loadEnv(mode, CURRENT_DIR);
|
|
// VITE_OPENWRT_HOST is just the router address — a bare IP/hostname like
|
|
// 192.168.1.1 (host:port and http:// URL forms also work). The web proxy
|
|
// target and the .ut-sync ssh target are both derived from it; ssh key
|
|
// selection etc. belongs in ~/.ssh/config, not here.
|
|
const OPENWRT_RAW = env.VITE_OPENWRT_HOST || "192.168.1.1";
|
|
const OPENWRT = new URL(
|
|
/^https?:\/\//.test(OPENWRT_RAW) ? OPENWRT_RAW : `http://${OPENWRT_RAW}`,
|
|
);
|
|
const OPENWRT_URL = OPENWRT.origin;
|
|
const OPENWRT_SSH_HOST = `root@${OPENWRT.hostname}`;
|
|
const DEV_HOST = env.VITE_DEV_HOST || "127.0.0.1";
|
|
const DEV_PORT = Number(env.VITE_DEV_PORT) || 5173;
|
|
|
|
return {
|
|
plugins: [
|
|
tailwindcss(),
|
|
luciRouter({ name: config.name }),
|
|
...luciDev(config, { sshHost: OPENWRT_SSH_HOST }),
|
|
createLuciJsCompressPlugin(),
|
|
createLoginCssPrunePlugin(),
|
|
createPatchAliasPlugin(),
|
|
createPackageHygienePlugin(),
|
|
],
|
|
css: {
|
|
lightningcss: {
|
|
targets: LIGHTNINGCSS_TARGETS,
|
|
},
|
|
},
|
|
build: {
|
|
outDir: BUILD_OUTPUT,
|
|
emptyOutDir: false,
|
|
cssMinify: "lightningcss",
|
|
rollupOptions: {
|
|
input: {
|
|
main: resolve(CURRENT_DIR, "src/media/main.css"),
|
|
login: resolve(CURRENT_DIR, "src/media/login.css"),
|
|
// On-demand third-party patches: one entry per page, output to
|
|
// aurora/patches/<page>.css (the `patches/` key prefix lands them there
|
|
// via assetFileNames below). header.ut links the matching one per page.
|
|
// `_`-prefixed files are shared partials @imported by entries, not
|
|
// entries themselves (they'd otherwise ship as never-matching patches).
|
|
...Object.fromEntries(
|
|
(existsSync(PATCH_SRC_DIR) ? readdirSync(PATCH_SRC_DIR) : [])
|
|
.filter((f) => f.endsWith(".css") && !f.startsWith("_"))
|
|
.map((f) => [`patches/${f.slice(0, -4)}`, join(PATCH_SRC_DIR, f)]),
|
|
),
|
|
},
|
|
output: { assetFileNames: "aurora/[name].[ext]" },
|
|
},
|
|
},
|
|
server: {
|
|
host: DEV_HOST,
|
|
port: DEV_PORT,
|
|
proxy: {
|
|
"/luci-static": {
|
|
target: OPENWRT_URL,
|
|
changeOrigin: true,
|
|
secure: false,
|
|
},
|
|
"/cgi-bin": {
|
|
target: OPENWRT_URL,
|
|
changeOrigin: true,
|
|
secure: false,
|
|
// We write every response ourselves in `proxyRes` below, so the Vite
|
|
// client can be injected into proxied LuCI HTML.
|
|
selfHandleResponse: true,
|
|
configure: (proxy) => {
|
|
// Force an uncompressed upstream response: the HTML injection below
|
|
// treats the body as UTF-8 text and would corrupt a gzipped payload.
|
|
proxy.on("proxyReq", (proxyReq) => {
|
|
proxyReq.removeHeader("accept-encoding");
|
|
});
|
|
proxy.on("proxyRes", (proxyRes, req, res) => {
|
|
const status = proxyRes.statusCode ?? 200;
|
|
const ct = proxyRes.headers["content-type"] || "";
|
|
if (!ct.includes("text/html")) {
|
|
res.writeHead(status, proxyRes.headers);
|
|
proxyRes.pipe(res);
|
|
return;
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
proxyRes.on("data", (c: Buffer) => chunks.push(c));
|
|
proxyRes.on("end", () => {
|
|
let html = Buffer.concat(chunks).toString("utf-8");
|
|
const client = `<script type="module" src="/@vite/client"></script>`;
|
|
if (
|
|
html.includes("</head>") &&
|
|
!html.includes("/@vite/client")
|
|
) {
|
|
html = html.replace("</head>", `${client}\n\t</head>`);
|
|
}
|
|
// Real device pages also get the mock bar, so the snapshot
|
|
// workflow is one click away instead of a URL to remember:
|
|
// it lists what .dev/mocks/ holds, opens this page's own
|
|
// snapshot when there is one, and captures the open page
|
|
// (same as Alt/Option+Shift+S). `current` is null — a live
|
|
// page is not itself a snapshot.
|
|
if (html.includes("</head>") && !html.includes("/__bar.js")) {
|
|
html = injectMockBar(html, null);
|
|
}
|
|
const { "transfer-encoding": _, ...headers } = proxyRes.headers;
|
|
res.writeHead(status, {
|
|
...headers,
|
|
"content-length": Buffer.byteLength(html),
|
|
});
|
|
res.end(html);
|
|
});
|
|
});
|
|
},
|
|
},
|
|
},
|
|
headers: { "Cache-Control": "no-store" },
|
|
},
|
|
resolve: {
|
|
alias: {
|
|
"@": resolve(CURRENT_DIR, "src"),
|
|
"@assets": resolve(CURRENT_DIR, "src/assets"),
|
|
},
|
|
},
|
|
};
|
|
});
|