feat: integrate XSLT Tools with toolbox portal
This commit is contained in:
33
scripts/generate-toolbox-manifest.mjs
Normal file
33
scripts/generate-toolbox-manifest.mjs
Normal 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);
|
||||
}
|
||||
26
scripts/package-release.d.mts
Normal file
26
scripts/package-release.d.mts
Normal 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
346
scripts/package-release.mjs
Normal 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') });
|
||||
}
|
||||
151
scripts/smoke-static-build.mjs
Normal file
151
scripts/smoke-static-build.mjs
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user