import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync, } from 'node:fs'; import { basename, dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const distDirectory = join(repositoryRoot, 'dist'); const releaseDirectory = join(repositoryRoot, 'release'); const packageJson = JSON.parse( readFileSync(join(repositoryRoot, 'package.json'), 'utf8') ); const artifactName = `xslt-tools-${packageJson.version}`; const archivePath = join(releaseDirectory, `${artifactName}.zip`); const checksumPath = join(releaseDirectory, `${artifactName}.sha256`); const zipEpoch = new Date(1980, 0, 1, 0, 0, 0); export function listFiles(directory) { return readdirSync(directory, { withFileTypes: true }) .sort((left, right) => compareText(left.name, right.name)) .flatMap((entry) => { const path = join(directory, entry.name); if (entry.isDirectory()) return listFiles(path); if (entry.isFile()) return [path]; throw new Error( `Refusing to package symlink or special filesystem entry: ${path}` ); }); } function zipPath(path) { return path.split(sep).join('/'); } export function collectReleaseEntries() { if (!existsSync(join(distDirectory, 'index.html'))) { throw new Error('dist/index.html is missing. Run `npm run build` first.'); } if (!existsSync(join(distDirectory, 'toolbox-app.json'))) { throw new Error( 'dist/toolbox-app.json is missing. Generate and build the toolbox manifest first.' ); } const entries = listFiles(distDirectory).map((path) => ({ name: zipPath(relative(distDirectory, path)), data: readFileSync(path), })); entries.push( { name: 'CHANGELOG.md', data: readFileSync(join(repositoryRoot, 'CHANGELOG.md')), }, { name: 'LICENSE', data: readFileSync(join(repositoryRoot, 'LICENSE')), }, { name: 'LICENSES/xslt-tools-LICENSE.txt', data: readFileSync(join(repositoryRoot, 'LICENSE')), }, { name: 'LICENSES/SaxonJS-LICENSE.txt', data: readFileSync( join(repositoryRoot, 'public/vendor/saxon/LICENSE.txt') ), }, { name: 'THIRD_PARTY_NOTICES.md', data: readFileSync(join(repositoryRoot, 'THIRD_PARTY_NOTICES.md')), }, { name: 'SOURCE.md', data: readFileSync(join(repositoryRoot, 'SOURCE.md')), } ); entries.push(...collectRuntimeDependencyLicenseEntries()); return entries.sort((left, right) => compareText(left.name, right.name)); } export function collectRuntimeDependencyLicenseEntries() { const packages = collectRuntimePackages(); const entries = []; for (const runtimePackage of packages) { const legalFiles = readdirSync(runtimePackage.directory, { withFileTypes: true, }) .filter( (entry) => entry.isFile() && /^(?:licen[cs]e|copying|notice)(?:\.|$)/i.test(entry.name) ) .sort((left, right) => compareText(left.name, right.name)); if (legalFiles.length === 0) { throw new Error( `Runtime dependency ${runtimePackage.name}@${runtimePackage.version} has no licence file.` ); } const directoryName = `${safePackageName(runtimePackage.name)}-${runtimePackage.version}`; for (const legalFile of legalFiles) { entries.push({ name: `LICENSES/npm/${directoryName}/${basename(legalFile.name)}`, data: readFileSync(join(runtimePackage.directory, legalFile.name)), }); } } return entries.sort((left, right) => compareText(left.name, right.name)); } function collectRuntimePackages() { const queue = Object.keys(packageJson.dependencies ?? {}).map((name) => ({ name, fromDirectory: repositoryRoot, })); const visited = new Set(); const packages = []; while (queue.length > 0) { const dependency = queue.shift(); const directory = findInstalledPackageDirectory( dependency.name, dependency.fromDirectory ); const manifest = JSON.parse( readFileSync(join(directory, 'package.json'), 'utf8') ); const identity = `${manifest.name}@${manifest.version}`; if (visited.has(identity)) continue; visited.add(identity); packages.push({ name: manifest.name, version: manifest.version, directory, }); const childNames = new Set([ ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.optionalDependencies ?? {}), ]); for (const name of [...childNames].sort(compareText)) { const childDirectory = tryFindInstalledPackageDirectory(name, directory); if (childDirectory) { queue.push({ name, fromDirectory: directory }); } } } return packages.sort((left, right) => compareText( `${left.name}@${left.version}`, `${right.name}@${right.version}` ) ); } function findInstalledPackageDirectory(name, fromDirectory) { const directory = tryFindInstalledPackageDirectory(name, fromDirectory); if (!directory) { throw new Error(`Runtime dependency ${name} is not installed.`); } return directory; } function tryFindInstalledPackageDirectory(name, fromDirectory) { let current = resolve(fromDirectory); const packageSegments = name.split('/'); while (true) { const candidate = join(current, 'node_modules', ...packageSegments); if (existsSync(join(candidate, 'package.json'))) { return realpathSync(candidate); } const parent = dirname(current); if (parent === current) return null; current = parent; } } function safePackageName(name) { return name.replace(/^@/, '').replaceAll('/', '--'); } function compareText(left, right) { return left < right ? -1 : left > right ? 1 : 0; } const crcTable = Array.from({ length: 256 }, (_, value) => { let result = value; for (let bit = 0; bit < 8; bit += 1) { result = (result & 1) !== 0 ? 0xedb88320 ^ (result >>> 1) : result >>> 1; } return result >>> 0; }); function crc32(data) { let result = 0xffffffff; for (const byte of data) { result = crcTable[(result ^ byte) & 0xff] ^ (result >>> 8); } return (result ^ 0xffffffff) >>> 0; } export function createDeterministicZip(entries) { const localRecords = []; const centralRecords = []; let offset = 0; for (const entry of entries) { const name = Buffer.from(entry.name, 'utf8'); const data = Buffer.from(entry.data); const checksum = crc32(data); const localHeader = Buffer.alloc(30); localHeader.writeUInt32LE(0x04034b50, 0); localHeader.writeUInt16LE(20, 4); localHeader.writeUInt16LE(0x0800, 6); localHeader.writeUInt16LE(0, 8); localHeader.writeUInt16LE(toDosDateTime(zipEpoch).time, 10); localHeader.writeUInt16LE(toDosDateTime(zipEpoch).date, 12); localHeader.writeUInt32LE(checksum, 14); localHeader.writeUInt32LE(data.length, 18); localHeader.writeUInt32LE(data.length, 22); localHeader.writeUInt16LE(name.length, 26); localHeader.writeUInt16LE(0, 28); localRecords.push(localHeader, name, data); const centralHeader = Buffer.alloc(46); centralHeader.writeUInt32LE(0x02014b50, 0); centralHeader.writeUInt16LE(20, 4); centralHeader.writeUInt16LE(20, 6); centralHeader.writeUInt16LE(0x0800, 8); centralHeader.writeUInt16LE(0, 10); centralHeader.writeUInt16LE(toDosDateTime(zipEpoch).time, 12); centralHeader.writeUInt16LE(toDosDateTime(zipEpoch).date, 14); centralHeader.writeUInt32LE(checksum, 16); centralHeader.writeUInt32LE(data.length, 20); centralHeader.writeUInt32LE(data.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(0, 38); centralHeader.writeUInt32LE(offset, 42); centralRecords.push(centralHeader, name); offset += localHeader.length + name.length + data.length; } const centralDirectory = Buffer.concat(centralRecords); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(0, 4); end.writeUInt16LE(0, 6); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(centralDirectory.length, 12); end.writeUInt32LE(offset, 16); end.writeUInt16LE(0, 20); return Buffer.concat([...localRecords, centralDirectory, end]); } export function toDosDateTime(date) { const year = date.getFullYear(); if (year < 1980 || year > 2107) { throw new RangeError('ZIP timestamps must be between 1980 and 2107.'); } return { time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2), date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(), }; } export function writeReleaseFiles({ archive, archiveTarget, checksum, checksumTarget, force = false, }) { mkdirSync(dirname(archiveTarget), { recursive: true }); mkdirSync(dirname(checksumTarget), { recursive: true }); const existingTargets = [archiveTarget, checksumTarget].filter(existsSync); if (!force && existingTargets.length > 0) { throw new Error( `Refusing to overwrite existing release file${existingTargets.length === 1 ? '' : 's'}: ${existingTargets.join(', ')}. Pass --force to replace the same-version artifact.` ); } const writeOptions = force ? undefined : { flag: 'wx' }; writeFileSync(archiveTarget, archive, writeOptions); writeFileSync(checksumTarget, checksum, { encoding: 'utf8', ...(writeOptions ?? {}), }); } export function packageRelease({ force = false } = {}) { const archive = createDeterministicZip(collectReleaseEntries()); const checksum = createHash('sha256').update(archive).digest('hex'); writeReleaseFiles({ archive, archiveTarget: archivePath, checksum: `${checksum} ${artifactName}.zip\n`, checksumTarget: checksumPath, force, }); console.log(relative(repositoryRoot, archivePath)); console.log(relative(repositoryRoot, checksumPath)); } if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) { const arguments_ = process.argv.slice(2); const unknownArguments = arguments_.filter( (argument) => argument !== '--force' ); if (unknownArguments.length > 0) { throw new Error(`Unknown release argument: ${unknownArguments.join(', ')}`); } packageRelease({ force: arguments_.includes('--force') }); }