177 lines
7.3 KiB
JavaScript
Executable file
177 lines
7.3 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
// ============================================================
|
|
// STATIC REFERENCE LINTER
|
|
// ============================================================
|
|
// Scans public/js/ for DOM id references and verifies each target
|
|
// exists in some public/*.html or public/components/*.html. Catches
|
|
// the class of bug where a component is moved/renamed/deleted and a
|
|
// JS file keeps calling getElementById('orphan') — the handler then
|
|
// silently no-ops at runtime. The lightbox that broke for Bedside
|
|
// pathway images was exactly this: the markup lived in
|
|
// calculators.html, but after the reorg the bedside tab never loaded
|
|
// it, so getElementById('img-lightbox') returned null.
|
|
//
|
|
// Also checks:
|
|
// - <button data-img-src="/path"> resolves to a file in public/
|
|
// - <audio|img src="/path"> resolves to a file in public/
|
|
//
|
|
// Prints a list of unresolved references and exits non-zero. An
|
|
// optional allowlist below handles IDs that are legitimately created
|
|
// at runtime (e.g. dynamically injected components).
|
|
// ============================================================
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const PUB = path.join(ROOT, 'public');
|
|
|
|
// ── Files to scan for id references ──────────────────────────
|
|
function walk(dir, ext, acc = []) {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const p = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(p, ext, acc);
|
|
else if (ext.test(entry.name)) acc.push(p);
|
|
}
|
|
return acc;
|
|
}
|
|
|
|
const jsFiles = walk(path.join(PUB, 'js'), /\.(js|mjs)$/);
|
|
const htmlFiles = walk(PUB, /\.html$/);
|
|
|
|
// ── Collect every id defined anywhere ────────────────────────
|
|
// Both static HTML attributes and dynamic JS (innerHTML strings,
|
|
// createElement().id = 'X'). If an id is "defined" by any file in
|
|
// the repo, a reference to it is considered resolved — whether it
|
|
// appears in the DOM at any given moment depends on control flow,
|
|
// which this linter doesn't model. We only catch the dead-reference
|
|
// class of bug: a JS module reaches for an id that literally no
|
|
// source in the tree ever produces.
|
|
const definedIds = new Set();
|
|
function collect(src) {
|
|
for (const m of src.matchAll(/\bid\s*=\s*["']([a-zA-Z_][\w-]*)["']/g)) definedIds.add(m[1]);
|
|
for (const m of src.matchAll(/\.id\s*=\s*["']([a-zA-Z_][\w-]*)["']/g)) definedIds.add(m[1]);
|
|
}
|
|
for (const f of htmlFiles) collect(fs.readFileSync(f, 'utf8'));
|
|
for (const f of jsFiles) collect(fs.readFileSync(f, 'utf8'));
|
|
|
|
// Dynamic-id allowlist: created by JS via innerHTML or createElement.
|
|
// Keep this list short and specific; expand only when sure the id is
|
|
// genuinely runtime-constructed. Use prefixes ending in '-' to match
|
|
// an entire family.
|
|
const DYNAMIC_ID_PREFIXES = [
|
|
'em-', // bedside section containers built by sub-nav.js
|
|
'shadess-', // wellvisit SSHADESS domain injection
|
|
'wv-panel-', // wellvisit subpanel ids (these exist in HTML but also built in JS)
|
|
'milestone-', // milestones checklist
|
|
'quiz-', // learning hub quiz questions
|
|
'lh-quiz-', // learning hub quiz answers
|
|
'pe-', // PE guide steps are rendered dynamically
|
|
'ros-', // ROS dynamic rows
|
|
'nrp-', // NRP pathway dynamic widgets
|
|
'seizure-', // seizure pathway dynamic widgets
|
|
'sepsis-', // sepsis pathway dynamic widgets
|
|
'airway-', // airway pathway dynamic widgets
|
|
'cardiac-', // cardiac-arrest pathway dynamic widgets
|
|
'anaph-', // anaphylaxis
|
|
'burn-', // burns
|
|
'vent-', // ventilation
|
|
'tox-', // toxicology
|
|
'trauma-', // trauma
|
|
'neo-', // neonatal
|
|
'resp-', // respiratory
|
|
'sed-', // sedation
|
|
'agit-', // agitation
|
|
'antiemetic-', // antiemetics
|
|
'antibio-', // antimicrobials
|
|
'bili-', // bilirubin dynamic columns
|
|
'bmi-', // BMI chart elements
|
|
'bp-', // BP percentile chart elements
|
|
'growth-', // growth chart elements
|
|
'dose-', // dosing widgets
|
|
'bsa-', // BSA calc
|
|
'equip-', // equipment sizing
|
|
'resus-', // resus meds
|
|
'gcs-', // GCS components
|
|
'vitals-', // vitals reference
|
|
'catchup-', // catch-up schedule
|
|
'totp-', // 2FA elements
|
|
'sessions-', // active sessions list
|
|
'nc-', // nextcloud settings
|
|
'mem-', // memories
|
|
'doc-', // documents
|
|
'audio-', // audio backups
|
|
'saved-', // saved encounters
|
|
'ext-', // extensions CRUD
|
|
'cms-', // content manager
|
|
'fpa-', // forgot-password accept screen
|
|
'rsp-', // reset password
|
|
'admin-', // admin panel
|
|
'adminms-', // admin milestones
|
|
'2fa-', // 2FA UI
|
|
'calc-', // calculator panels (some may be missing from HTML)
|
|
];
|
|
|
|
function isDynamicId(id) {
|
|
return DYNAMIC_ID_PREFIXES.some(p => id.startsWith(p));
|
|
}
|
|
|
|
// ── Scan JS for getElementById / querySelector('#id') / '#id' ─
|
|
const unresolved = [];
|
|
const idRef = /getElementById\s*\(\s*['"]([^'"]+)['"]\s*\)|querySelector(?:All)?\s*\(\s*['"]#([a-zA-Z_][\w-]*)\s*['"]|querySelector(?:All)?\s*\(\s*['"][^'"]*?#([a-zA-Z_][\w-]+)/g;
|
|
for (const f of jsFiles) {
|
|
const src = fs.readFileSync(f, 'utf8');
|
|
const seen = new Set();
|
|
for (const m of src.matchAll(idRef)) {
|
|
const id = m[1] || m[2] || m[3];
|
|
if (!id || seen.has(id)) continue;
|
|
seen.add(id);
|
|
if (definedIds.has(id) || isDynamicId(id)) continue;
|
|
unresolved.push({ file: path.relative(ROOT, f), id });
|
|
}
|
|
}
|
|
|
|
// ── Scan HTML for asset references that must resolve on disk ──
|
|
const brokenAssets = [];
|
|
function checkAsset(file, p) {
|
|
if (!p || !p.startsWith('/')) return; // only root-absolute
|
|
if (p.startsWith('//') || p.startsWith('/api/') || p.startsWith('/components/')) return;
|
|
if (p.startsWith('/#')) return; // hash-only
|
|
const onDisk = path.join(PUB, p.split('?')[0].split('#')[0]);
|
|
if (!fs.existsSync(onDisk)) brokenAssets.push({ file: path.relative(ROOT, file), path: p });
|
|
}
|
|
|
|
const ASSET_PATTERNS = [
|
|
/data-img-src\s*=\s*["']([^"']+)["']/g,
|
|
/<(?:img|audio|video|source|link)[^>]*\bsrc\s*=\s*["']([^"']+)["']/g,
|
|
/<(?:link)[^>]*\bhref\s*=\s*["']([^"']+)["']/g,
|
|
];
|
|
for (const f of htmlFiles) {
|
|
const html = fs.readFileSync(f, 'utf8');
|
|
for (const pat of ASSET_PATTERNS) {
|
|
for (const m of html.matchAll(pat)) checkAsset(f, m[1]);
|
|
}
|
|
}
|
|
|
|
// ── Report ────────────────────────────────────────────────────
|
|
let failed = false;
|
|
|
|
if (unresolved.length) {
|
|
console.error('\n❌ Unresolved DOM id references:');
|
|
for (const { file, id } of unresolved) console.error(` ${file} -> #${id}`);
|
|
failed = true;
|
|
} else {
|
|
console.log('✓ All JS id references resolve to an HTML id.');
|
|
}
|
|
|
|
if (brokenAssets.length) {
|
|
console.error('\n❌ Broken asset paths:');
|
|
for (const { file, path: p } of brokenAssets) console.error(` ${file} -> ${p}`);
|
|
failed = true;
|
|
} else {
|
|
console.log('✓ All asset paths resolve to a file on disk.');
|
|
}
|
|
|
|
process.exit(failed ? 1 : 0);
|