Add scripts/precompress.ts, a dependency-free Node 26 tool that writes .br/.gz/.zst sidecars next to every text asset and keeps the originals, so static-web-server can serve the variant matching Accept-Encoding and fall back to the original. The coverage publish step runs it on the copied report. Wire scripts/*.ts into the toolchain: type-check (tsconfig include), lint and fix (oxlint paths), knip entry (CI invokes the file rather than importing it), and a narrow .oxlintrc override allowing node:* imports in Node CLI scripts.
145 lines
4.0 KiB
TypeScript
145 lines
4.0 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import zlib from "node:zlib";
|
|
|
|
/**
|
|
* Emit precompressed `.br` / `.gz` / `.zst` sidecars next to every text asset
|
|
* under the given directories, keeping the originals. The Gitea pages service
|
|
* (`static-web-server` with `SERVER_COMPRESSION_STATIC=true`) serves the sidecar
|
|
* matching `Accept-Encoding` and falls back to the original for the rest.
|
|
*
|
|
* Usage: node --strip-types scripts/precompress.ts <dir> [<dir>...]
|
|
*/
|
|
|
|
/**
|
|
* Only extensions worth compressing. Images, fonts and archives are already
|
|
* compressed, so a sidecar would only make them bigger.
|
|
*/
|
|
const TEXT_EXTENSIONS: ReadonlySet<string> = new Set([
|
|
".css",
|
|
".htm",
|
|
".html",
|
|
".info",
|
|
".js",
|
|
".json",
|
|
".map",
|
|
".md",
|
|
".mjs",
|
|
".svg",
|
|
".txt",
|
|
".xml",
|
|
".yaml",
|
|
".yml",
|
|
]);
|
|
|
|
const GZIP_LEVEL = 9;
|
|
const ZSTD_LEVEL = 19;
|
|
const INITIAL_COUNT = 0;
|
|
/** `process.argv` is `[node, script, ...args]`; drop the first two entries. */
|
|
const ARGV_PREFIX_LENGTH = 2;
|
|
const FAILURE_EXIT_CODE = 1;
|
|
|
|
interface Encoder {
|
|
readonly suffix: string;
|
|
readonly encode: (input: Buffer) => Buffer;
|
|
}
|
|
|
|
const ENCODERS: readonly Encoder[] = [
|
|
{
|
|
suffix: ".br",
|
|
encode: (input) =>
|
|
zlib.brotliCompressSync(input, {
|
|
params: {
|
|
[zlib.constants.BROTLI_PARAM_QUALITY]:
|
|
zlib.constants.BROTLI_MAX_QUALITY,
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
suffix: ".gz",
|
|
encode: (input) => zlib.gzipSync(input, { level: GZIP_LEVEL }),
|
|
},
|
|
{
|
|
suffix: ".zst",
|
|
encode: (input) =>
|
|
zlib.zstdCompressSync(input, {
|
|
params: {
|
|
[zlib.constants.ZSTD_c_compressionLevel]: ZSTD_LEVEL,
|
|
},
|
|
}),
|
|
},
|
|
];
|
|
|
|
interface Totals {
|
|
assets: number;
|
|
sidecars: number;
|
|
savedBytes: number;
|
|
}
|
|
|
|
/** Depth-first list of every regular file under `directory`, recursively. */
|
|
const listFiles = (directory: string): string[] => {
|
|
const files: string[] = [];
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...listFiles(entryPath));
|
|
} else if (entry.isFile()) {
|
|
files.push(entryPath);
|
|
}
|
|
}
|
|
return files;
|
|
};
|
|
|
|
const writeSidecar = (
|
|
target: string,
|
|
input: Buffer,
|
|
encoder: Encoder,
|
|
): number => {
|
|
const compressed = encoder.encode(input);
|
|
if (compressed.byteLength < input.byteLength) {
|
|
fs.writeFileSync(target, compressed);
|
|
return input.byteLength - compressed.byteLength;
|
|
}
|
|
// Drop a stale sidecar: it would still win at the server.
|
|
fs.rmSync(target, { force: true });
|
|
return INITIAL_COUNT;
|
|
};
|
|
|
|
const precompress = (file: string, totals: Totals): void => {
|
|
if (!TEXT_EXTENSIONS.has(path.extname(file).toLowerCase())) {
|
|
return;
|
|
}
|
|
totals.assets += 1;
|
|
const input = fs.readFileSync(file);
|
|
for (const encoder of ENCODERS) {
|
|
const saved = writeSidecar(`${file}${encoder.suffix}`, input, encoder);
|
|
if (saved > INITIAL_COUNT) {
|
|
totals.sidecars += 1;
|
|
totals.savedBytes += saved;
|
|
}
|
|
}
|
|
};
|
|
|
|
const main = (directories: readonly string[]): void => {
|
|
if (directories.length === INITIAL_COUNT) {
|
|
process.stderr.write("usage: precompress.ts <dir> [<dir>...]\n");
|
|
process.exitCode = FAILURE_EXIT_CODE;
|
|
return;
|
|
}
|
|
const totals: Totals = {
|
|
assets: INITIAL_COUNT,
|
|
sidecars: INITIAL_COUNT,
|
|
savedBytes: INITIAL_COUNT,
|
|
};
|
|
for (const directory of directories) {
|
|
for (const file of listFiles(directory)) {
|
|
precompress(file, totals);
|
|
}
|
|
}
|
|
process.stdout.write(
|
|
`precompressed ${totals.assets} text assets into ${totals.sidecars} sidecars (saved ${totals.savedBytes} bytes)\n`,
|
|
);
|
|
};
|
|
|
|
main(process.argv.slice(ARGV_PREFIX_LENGTH));
|