"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 FIREFOX_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 CHROMIUM_ARCHIVE_FILES = Object.freeze([ ...FIREFOX_ARCHIVE_FILES.map((relativePath) => relativePath.endsWith(".svg") ? relativePath.replace(/\.svg$/, ".png") : relativePath ), "icons/icon-128.png", "icons/icon-48.svg" ]); const TARGETS = new Set(["firefox", "chromium"]); 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 createManifest(contents, version, target) { const manifest = JSON.parse(contents.toString("utf8")); manifest.version = version; if (target === "firefox") { return manifest; } if (version.split(".").some((part) => Number(part) > 65535)) { throw new Error(`Chromium extension version parts cannot exceed 65535: ${version}`); } manifest.manifest_version = 3; manifest.minimum_chrome_version = "102"; delete manifest.browser_specific_settings; manifest.host_permissions = manifest.permissions.filter( (permission) => permission === "" || permission.includes("://") ); manifest.permissions = manifest.permissions.filter( (permission) => !manifest.host_permissions.includes(permission) ); manifest.background = { service_worker: "background.js" }; manifest.action = manifest.browser_action; delete manifest.browser_action; manifest.web_accessible_resources = [ { resources: manifest.web_accessible_resources, matches: [""] } ]; for (const icons of [manifest.icons, manifest.action.default_icon]) { for (const size of Object.keys(icons)) { icons[size] = icons[size].replace(/\.svg$/, ".png"); } } manifest.icons["128"] = "icons/icon-128.png"; return manifest; } 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, target = "firefox", version: requestedVersion }) { if (!TARGETS.has(target)) { throw new Error(`Unknown extension target: ${target}`); } const packageMetadata = JSON.parse( await readFile(path.join(rootDirectory, "package.json"), "utf8") ); const version = normalizeVersion(requestedVersion ?? packageMetadata.version); const archiveFiles = target === "firefox" ? FIREFOX_ARCHIVE_FILES : CHROMIUM_ARCHIVE_FILES; const entries = []; for (const relativePath of archiveFiles) { let contents = await readFile(path.join(rootDirectory, relativePath)); if (relativePath === "manifest.json") { const manifest = createManifest(contents, version, target); contents = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); } entries.push({ name: relativePath, contents }); } const archive = createZip(entries); const archiveName = target === "firefox" ? `glagolify-${version}.xpi` : `glagolify-${version}-chromium.zip`; 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, target, version }; } function parseArguments(arguments_) { let outputDirectory = "dist"; let target = "all"; let version; for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index]; if ( argument === "--version" || argument === "--output-dir" || argument === "--target" ) { const value = arguments_[index + 1]; if (!value || value.startsWith("--")) { throw new Error(`${argument} requires a value.`); } if (argument === "--version") { version = value; } else if (argument === "--output-dir") { outputDirectory = value; } else { target = value; } index += 1; } else { throw new Error(`Unknown argument: ${argument}`); } } if (target !== "all" && !TARGETS.has(target)) { throw new Error(`Unknown extension target: ${target}`); } return { outputDirectory, target, version }; } async function main() { const rootDirectory = path.resolve(__dirname, ".."); const options = parseArguments(process.argv.slice(2)); const targets = options.target === "all" ? [...TARGETS] : [options.target]; for (const target of targets) { const result = await packageExtension({ rootDirectory, outputDirectory: path.resolve(rootDirectory, options.outputDirectory), target, version: options.version }); process.stdout.write( `Created ${path.relative(rootDirectory, result.archivePath)} for ${target} ` + `version ${result.version}\nSHA-256 ${result.digest}\n` ); } } if (require.main === module) { main().catch((error) => { console.error(error.message); process.exitCode = 1; }); } module.exports = { CHROMIUM_ARCHIVE_FILES, FIREFOX_ARCHIVE_FILES, normalizeVersion, packageExtension };