Files
glagolify/scripts/package-extension.js
T
2026-08-08 19:39:47 +02:00

207 lines
6.3 KiB
JavaScript

"use strict";
const { createHash } = require("node:crypto");
const { mkdir, readFile, writeFile } = require("node:fs/promises");
const path = require("node:path");
const { deflateRawSync } = require("node:zlib");
const ARCHIVE_FILES = Object.freeze([
"background.js",
"content.css",
"content.js",
"fonts/NotoSansGlagolitic-Regular.ttf",
"fonts/OFL.txt",
"fonts/Shafarik-OFL.txt",
"fonts/Shafarik-Regular.ttf",
"icons/icon-16.svg",
"icons/icon-32.svg",
"icons/icon-48.svg",
"icons/icon-96.svg",
"manifest.json",
"popup.css",
"popup.html",
"popup.js",
"transliterate.js"
]);
const VERSION_PATTERN = /^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$/;
const UTF8_FLAG = 0x0800;
const DEFLATE_METHOD = 8;
const DOS_TIME = 0;
const DOS_DATE = (1 << 5) | 1;
function normalizeVersion(value) {
if (typeof value !== "string" || value.length === 0) {
throw new Error("A package or release version is required.");
}
const version = /^[vV]/.test(value) ? value.slice(1) : value;
if (!VERSION_PATTERN.test(version)) {
throw new Error(
`Invalid Mozilla extension version ${JSON.stringify(value)}. ` +
"Use one to four dot-separated integers without leading zeroes; a leading v is allowed."
);
}
return version;
}
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function createZip(entries) {
const localRecords = [];
const centralRecords = [];
let offset = 0;
for (const entry of entries) {
const name = Buffer.from(entry.name, "utf8");
const compressed = deflateRawSync(entry.contents, { level: 9 });
const checksum = crc32(entry.contents);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(UTF8_FLAG, 6);
localHeader.writeUInt16LE(DEFLATE_METHOD, 8);
localHeader.writeUInt16LE(DOS_TIME, 10);
localHeader.writeUInt16LE(DOS_DATE, 12);
localHeader.writeUInt32LE(checksum, 14);
localHeader.writeUInt32LE(compressed.length, 18);
localHeader.writeUInt32LE(entry.contents.length, 22);
localHeader.writeUInt16LE(name.length, 26);
localHeader.writeUInt16LE(0, 28);
const localRecord = Buffer.concat([localHeader, name, compressed]);
localRecords.push(localRecord);
const centralHeader = Buffer.alloc(46);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(0x0314, 4);
centralHeader.writeUInt16LE(20, 6);
centralHeader.writeUInt16LE(UTF8_FLAG, 8);
centralHeader.writeUInt16LE(DEFLATE_METHOD, 10);
centralHeader.writeUInt16LE(DOS_TIME, 12);
centralHeader.writeUInt16LE(DOS_DATE, 14);
centralHeader.writeUInt32LE(checksum, 16);
centralHeader.writeUInt32LE(compressed.length, 20);
centralHeader.writeUInt32LE(entry.contents.length, 24);
centralHeader.writeUInt16LE(name.length, 28);
centralHeader.writeUInt16LE(0, 30);
centralHeader.writeUInt16LE(0, 32);
centralHeader.writeUInt16LE(0, 34);
centralHeader.writeUInt16LE(0, 36);
centralHeader.writeUInt32LE((0o100644 << 16) >>> 0, 38);
centralHeader.writeUInt32LE(offset, 42);
centralRecords.push(Buffer.concat([centralHeader, name]));
offset += localRecord.length;
}
const centralDirectory = Buffer.concat(centralRecords);
const endRecord = Buffer.alloc(22);
endRecord.writeUInt32LE(0x06054b50, 0);
endRecord.writeUInt16LE(0, 4);
endRecord.writeUInt16LE(0, 6);
endRecord.writeUInt16LE(entries.length, 8);
endRecord.writeUInt16LE(entries.length, 10);
endRecord.writeUInt32LE(centralDirectory.length, 12);
endRecord.writeUInt32LE(offset, 16);
endRecord.writeUInt16LE(0, 20);
return Buffer.concat([...localRecords, centralDirectory, endRecord]);
}
async function packageExtension({ rootDirectory, outputDirectory, version: requestedVersion }) {
const packageMetadata = JSON.parse(
await readFile(path.join(rootDirectory, "package.json"), "utf8")
);
const version = normalizeVersion(requestedVersion ?? packageMetadata.version);
const entries = [];
for (const relativePath of ARCHIVE_FILES) {
let contents = await readFile(path.join(rootDirectory, relativePath));
if (relativePath === "manifest.json") {
const manifest = JSON.parse(contents.toString("utf8"));
manifest.version = version;
contents = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
}
entries.push({ name: relativePath, contents });
}
const archive = createZip(entries);
const archiveName = `glagolify-${version}.xpi`;
const archivePath = path.join(outputDirectory, archiveName);
const checksumPath = `${archivePath}.sha256`;
const digest = createHash("sha256").update(archive).digest("hex");
await mkdir(outputDirectory, { recursive: true });
await writeFile(archivePath, archive);
await writeFile(checksumPath, `${digest} ${archiveName}\n`);
return { archiveName, archivePath, checksumPath, digest, version };
}
function parseArguments(arguments_) {
let outputDirectory = "dist";
let version;
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index];
if (argument === "--version" || argument === "--output-dir") {
const value = arguments_[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`${argument} requires a value.`);
}
if (argument === "--version") {
version = value;
} else {
outputDirectory = value;
}
index += 1;
} else {
throw new Error(`Unknown argument: ${argument}`);
}
}
return { outputDirectory, version };
}
async function main() {
const rootDirectory = path.resolve(__dirname, "..");
const options = parseArguments(process.argv.slice(2));
const result = await packageExtension({
rootDirectory,
outputDirectory: path.resolve(rootDirectory, options.outputDirectory),
version: options.version
});
process.stdout.write(
`Created ${path.relative(rootDirectory, result.archivePath)} for version ${result.version}\n` +
`SHA-256 ${result.digest}\n`
);
}
if (require.main === module) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
module.exports = { ARCHIVE_FILES, normalizeVersion, packageExtension };