feat: integrate XSLT Tools with toolbox portal

This commit is contained in:
2026-07-20 18:16:18 +02:00
parent 3193cc395c
commit 8f8a59e76c
36 changed files with 1737 additions and 243 deletions

View File

@@ -0,0 +1,33 @@
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { format } from 'prettier';
import { toolboxAppDefinition } from '../src/toolboxApp.ts';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const packageJson = JSON.parse(
await readFile(join(repositoryRoot, 'package.json'), 'utf8')
);
const manifestPath = join(repositoryRoot, 'public', 'toolbox-app.json');
if (toolboxAppDefinition.version !== packageJson.version) {
throw new Error(
`Version mismatch: package.json is ${packageJson.version}, while src/toolboxApp.ts is ${toolboxAppDefinition.version}.`
);
}
const output = await format(JSON.stringify(toolboxAppDefinition), {
parser: 'json',
});
if (process.argv.includes('--check')) {
const existing = await readFile(manifestPath, 'utf8').catch(() => '');
if (existing !== output) {
throw new Error(
'public/toolbox-app.json is out of date. Run `npm run manifest:generate`.'
);
}
} else {
await writeFile(manifestPath, output);
}

View File

@@ -0,0 +1,26 @@
export interface ReleaseEntry {
name: string;
data: Uint8Array;
}
export interface DosDateTime {
time: number;
date: number;
}
export function createDeterministicZip(
entries: ReleaseEntry[]
): Buffer<ArrayBuffer>;
export function collectReleaseEntries(): ReleaseEntry[];
export function collectRuntimeDependencyLicenseEntries(): ReleaseEntry[];
export function listFiles(directory: string): string[];
export function toDosDateTime(date: Date): DosDateTime;
export function writeReleaseFiles(options: {
archive: Uint8Array;
archiveTarget: string;
checksum: string;
checksumTarget: string;
force?: boolean;
}): void;
export function packageRelease(options?: { force?: boolean }): void;

346
scripts/package-release.mjs Normal file
View File

@@ -0,0 +1,346 @@
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') });
}

View File

@@ -0,0 +1,151 @@
import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { dirname, extname, join, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const distDirectory = join(repositoryRoot, 'dist');
const nestedPath = '/deep/nested/app/';
const mimeTypes = {
'.css': 'text/css',
'.html': 'text/html',
'.ico': 'image/x-icon',
'.js': 'text/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
};
const server = createServer(async (request, response) => {
try {
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
if (requestUrl.pathname === '/toolbox.catalog.json') {
response.setHeader('content-type', 'application/json');
response.end(
JSON.stringify({
schemaVersion: 1,
id: 'nested-smoke-test',
name: 'Nested smoke test',
home: './',
theme: {
mode: 'system',
brand: 'Nested smoke test',
},
apps: [
{
manifest: `${nestedPath}toolbox-app.json`,
enabled: true,
},
],
})
);
return;
}
if (!requestUrl.pathname.startsWith(nestedPath)) {
response.writeHead(404).end();
return;
}
const requestedPath = decodeURIComponent(
requestUrl.pathname.slice(nestedPath.length)
);
const filePath = resolve(
distDirectory,
requestedPath === '' ? 'index.html' : requestedPath
);
if (
filePath !== distDirectory &&
!filePath.startsWith(`${distDirectory}${sep}`)
) {
response.writeHead(403).end();
return;
}
if (!(await stat(filePath)).isFile()) {
response.writeHead(404).end();
return;
}
response.setHeader(
'content-type',
mimeTypes[extname(filePath)] ?? 'application/octet-stream'
);
response.end(await readFile(filePath));
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolveListening) => {
server.listen(0, '127.0.0.1', resolveListening);
});
try {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Static smoke server did not expose a TCP port.');
}
const appUrl = `http://127.0.0.1:${address.port}${nestedPath}`;
const standaloneHtml = await fetchText(appUrl);
const toolboxHtml = await fetchText(
`${appUrl}?toolbox=${encodeURIComponent('/toolbox.catalog.json')}`
);
if (toolboxHtml !== standaloneHtml) {
throw new Error('Toolbox mode did not serve the same static app artifact.');
}
const assetReferences = Array.from(
standaloneHtml.matchAll(/(?:href|src)="([^"]+)"/g),
(match) => match[1]
).filter(
(reference) =>
!reference.startsWith('#') &&
!reference.startsWith('data:') &&
!reference.startsWith('http:') &&
!reference.startsWith('https:')
);
for (const reference of assetReferences) {
const resolved = new URL(reference, appUrl);
if (!resolved.pathname.startsWith(nestedPath)) {
throw new Error(
`Built asset escaped the nested app path: ${reference} -> ${resolved.pathname}`
);
}
await fetchOk(resolved);
}
const manifestUrl = new URL('toolbox-app.json', appUrl);
const manifest = JSON.parse(await fetchText(manifestUrl));
await fetchOk(new URL(manifest.entry, appUrl));
await fetchOk(new URL(manifest.icon, manifestUrl));
await fetchOk(new URL('vendor/saxon/SaxonJS2.js', appUrl));
await fetchOk(`http://127.0.0.1:${address.port}/toolbox.catalog.json`);
console.log(
`Static smoke test passed at ${nestedPath} in standalone and toolbox modes.`
);
} finally {
await new Promise((resolveClosed, reject) => {
server.close((error) => (error ? reject(error) : resolveClosed()));
});
}
async function fetchOk(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Expected ${url} to be available, received HTTP ${response.status}.`
);
}
return response;
}
async function fetchText(url) {
return (await fetchOk(url)).text();
}