The filename check sat inside the content word scan, which skips the third-party folders, so vendor/arasaac-loader.js passed. Filenames are now checked in every folder; only the content scan leaves third-party data out (its dictionaries hold the Italian word "sclera"). Planted: files named for a set in vendor/, voices/, models/lang/ (a folder) and pkg/ are caught. prerender-static.sh's comment and the README now describe both this and the decompressed magic-byte check. Co-Authored-By: Claude Opus 5.5 <[email protected]>
134 lines
5.7 KiB
JavaScript
134 lines
5.7 KiB
JavaScript
// The optional non-commercial symbol sets are never in a build made without
|
|
// them (aac-web's nc-symbols feature off): the standard's own build always,
|
|
// a deployment's when it does not offer them.
|
|
//
|
|
// node tools/symbols/check-no-nc.mjs <site-dir> [<prefix>]
|
|
//
|
|
// <prefix> is where the app sits in the site: "" for the standard's own
|
|
// build (prerender-static.sh, target/site), "app/" for a deployment that
|
|
// mounts it at /app/. Fails, naming each file, on:
|
|
// - any symbol-set pack anywhere in the site: a file under models/symbols/,
|
|
// a *.pack file, or a file that starts with a pack's magic bytes, as
|
|
// stored or, for a .br/.gz sidecar, once decompressed;
|
|
// - the service worker (sw.js) precaching anything under models/symbols/
|
|
// or any *.pack;
|
|
// - any file named for a set, in any folder;
|
|
// - any file of the app's own naming the sets, each .gz/.br sidecar
|
|
// decompressed first. Left out of this word scan, and only of it: the
|
|
// third-party data copied in beside the app — voices/, ai/, asr/, ae/,
|
|
// vendor/ and the word lists in models/lang/ (dictionaries hold such
|
|
// words as Italian "sclera", the eye).
|
|
// Mirrors aac-aurasense-web scripts/check-nc-off.mjs.
|
|
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
import { join, relative, sep } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { brotliDecompressSync, constants, gunzipSync } from 'node:zlib';
|
|
|
|
export const PACK_MAGIC = Buffer.from('AACSYMP1');
|
|
const NAMES = /arasaac|sclera/i;
|
|
const THIRD_PARTY = ['voices/', 'ai/', 'asr/', 'ae/', 'vendor/', 'models/lang/'];
|
|
|
|
// How much of a file is read to find its first bytes: enough for the start of
|
|
// any .br/.gz stream; a stream that needs more is read whole.
|
|
const PREFIX = 64 * 1024;
|
|
|
|
function start(path) {
|
|
const fd = openSync(path, 'r');
|
|
try {
|
|
const b = Buffer.alloc(PREFIX);
|
|
return b.subarray(0, readSync(fd, b, 0, b.length, 0));
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
|
|
const inflate = (rel, bytes, partial) =>
|
|
rel.endsWith('.br')
|
|
? brotliDecompressSync(bytes, partial ? { finishFlush: constants.BROTLI_OPERATION_FLUSH } : {})
|
|
: gunzipSync(bytes, partial ? { finishFlush: constants.Z_SYNC_FLUSH } : {});
|
|
|
|
/** Does the file begin with a pack's magic bytes, as stored or, for a .br/.gz
|
|
* sidecar, once decompressed? Checked in every folder, third-party ones too. */
|
|
function isPackBytes(path, rel) {
|
|
const raw = start(path);
|
|
if (raw.subarray(0, PACK_MAGIC.length).equals(PACK_MAGIC)) return true;
|
|
if (!/\.(br|gz)$/.test(rel)) return false;
|
|
try {
|
|
let out = inflate(rel, raw, true);
|
|
if (out.length < PACK_MAGIC.length && raw.length === PREFIX) out = inflate(rel, readFileSync(path), false);
|
|
return out.subarray(0, PACK_MAGIC.length).equals(PACK_MAGIC);
|
|
} catch {
|
|
return false; // not compressed after all: its stored bytes were checked
|
|
}
|
|
}
|
|
|
|
/** What in `site` breaks the rule: { packs, named, precached }. */
|
|
export function check(site, prefix = '') {
|
|
const packs = [];
|
|
const named = [];
|
|
const thirdParty = THIRD_PARTY.map((p) => prefix + p);
|
|
const walk = (dir) => {
|
|
for (const name of readdirSync(dir)) {
|
|
const path = join(dir, name);
|
|
if (statSync(path).isDirectory()) {
|
|
walk(path);
|
|
continue;
|
|
}
|
|
const rel = relative(site, path).split(sep).join('/');
|
|
if (/(^|\/)models\/symbols\//.test(rel) || /\.pack(\.br|\.gz)?$/.test(rel) || isPackBytes(path, rel)) {
|
|
packs.push(rel);
|
|
continue;
|
|
}
|
|
// A file named for a set, in any folder.
|
|
if (NAMES.test(rel)) {
|
|
named.push(rel);
|
|
continue;
|
|
}
|
|
// The content word scan leaves out third-party data.
|
|
if (thirdParty.some((p) => rel.startsWith(p))) continue;
|
|
let bytes = readFileSync(path);
|
|
try {
|
|
if (rel.endsWith('.br')) bytes = brotliDecompressSync(bytes);
|
|
else if (rel.endsWith('.gz')) bytes = gunzipSync(bytes);
|
|
} catch {
|
|
// Not compressed after all: scanned as it is.
|
|
}
|
|
if (NAMES.test(bytes.toString('latin1'))) named.push(rel);
|
|
}
|
|
};
|
|
walk(site);
|
|
let precached = [];
|
|
const sw = join(site, 'sw.js');
|
|
if (existsSync(sw)) {
|
|
// A worker whose list cannot be read cannot be cleared.
|
|
const m = readFileSync(sw, 'utf8').match(/PRECACHE_URLS = (\[.*?\]);/s);
|
|
if (!m) throw new Error(`${sw}: no PRECACHE_URLS list to check`);
|
|
precached = JSON.parse(m[1]).filter((u) => /models\/symbols\/|\.pack$/.test(u));
|
|
}
|
|
return { packs, named, precached };
|
|
}
|
|
|
|
// Run as a command (not imported by the tests)? import.meta.url is the real
|
|
// path, percent-encoded: compare it with argv[1] made the same way, so a path
|
|
// with a space or through a symlink still runs the check.
|
|
function runAsCommand() {
|
|
try {
|
|
return pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (runAsCommand()) {
|
|
const [site, prefix = ''] = process.argv.slice(2);
|
|
if (!site) {
|
|
console.error('usage: node tools/symbols/check-no-nc.mjs <site-dir> [<prefix>]');
|
|
process.exit(2);
|
|
}
|
|
const { packs, named, precached } = check(site, prefix);
|
|
if (packs.length) console.error(`✗ a build without the optional symbol sets ships ${packs.length} pack file(s):\n ${packs.join('\n ')}`);
|
|
if (named.length) console.error(`✗ a build without the optional symbol sets names them in ${named.length} file(s):\n ${named.join('\n ')}`);
|
|
if (precached.length) console.error(`✗ the service worker precaches ${precached.join(', ')}`);
|
|
if (packs.length || named.length || precached.length) process.exit(1);
|
|
console.log(`✓ no symbol pack, and no file of the app's own names the optional symbol sets (word scan leaves out ${THIRD_PARTY.map((p) => prefix + p).join(', ')})`);
|
|
}
|