diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a656110..4ff3a99 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -35,7 +35,10 @@ jobs: # served read-only at # https://pages.e1nsnull.de////coverage/. Wipe only # this tag's `coverage/`, so sibling docs/landing trees and older - # tags survive; pruning stale tags is a manual chore. + # tags survive; pruning stale tags is a manual chore. The + # precompress pass emits `.br` / `.gz` / `.zst` sidecars next to + # every text asset, so `static-web-server` can serve the precompressed + # variant and keep the original as fallback. - name: Publish coverage to the pages server if: startsWith(gitea.ref, 'refs/tags/') env: @@ -47,6 +50,7 @@ jobs: rm -rf "${DEST}" mkdir -p "${DEST}" cp -R coverage/. "${DEST}/" + node --strip-types scripts/precompress.ts "${DEST}" echo "Coverage: https://pages.e1nsnull.de/${REPO}/${TAG}/coverage/" # Fast, offline packaging gate. `attw` stays in `publish` (it needs # a pack + full resolution matrix); `publint` packs too but is cheap diff --git a/.oxlintrc.json b/.oxlintrc.json index 89e16b6..7c4a227 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -33,6 +33,12 @@ "import/no-nodejs-modules": "off", "eslint/no-magic-numbers": "off" } + }, + { + "files": ["scripts/**/*.ts"], + "rules": { + "import/no-nodejs-modules": "off" + } } ], "ignorePatterns": ["dist", "node_modules", "coverage"] diff --git a/knip.json b/knip.json index aeeffa9..19b899b 100644 --- a/knip.json +++ b/knip.json @@ -1,4 +1,5 @@ { "$schema": "./node_modules/knip/schema.json", + "entry": ["scripts/*.ts"], "ignoreDependencies": ["@runwisp/pubv"] } diff --git a/package.json b/package.json index ee3234f..1e5d56e 100644 --- a/package.json +++ b/package.json @@ -42,12 +42,12 @@ "check": "npm run check:tsc && npm run check:oxlint && npm run check:oxfmt && npm run check:cspell", "check:cspell": "cspell lint ${LEFTHOOK_FILES:-.}", "check:oxfmt": "oxfmt --check ${LEFTHOOK_FILES:-.}", - "check:oxlint": "oxlint ${LEFTHOOK_FILES:-src}", + "check:oxlint": "oxlint ${LEFTHOOK_FILES:-src scripts}", "check:tsc": "tsc", "clean": "rm -rf dist coverage", "fix": "npm run fix:oxlint && npm run fix:oxfmt", "fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}", - "fix:oxlint": "oxlint --fix src", + "fix:oxlint": "oxlint --fix src scripts", "create:branch": "./scripts/branch.sh", "create:release": "./scripts/release.sh", "maintain": "npm run maintain:knip; npm run maintain:outdated", diff --git a/scripts/precompress.ts b/scripts/precompress.ts new file mode 100644 index 0000000..2327a01 --- /dev/null +++ b/scripts/precompress.ts @@ -0,0 +1,144 @@ +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 [...] + */ + +/** + * Only extensions worth compressing. Images, fonts and archives are already + * compressed, so a sidecar would only make them bigger. + */ +const TEXT_EXTENSIONS: ReadonlySet = 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 [...]\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)); diff --git a/tsconfig.json b/tsconfig.json index cc01332..4f5544a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,5 +8,5 @@ "allowImportingTsExtensions": true, "verbatimModuleSyntax": true }, - "include": ["src"] + "include": ["src", "scripts"] }