// 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 [] // // 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 []'); 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(', ')})`); }